package admin
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/game"
)
// GiveCardAction handles the admin action to give a card to a player
// NOTE: Card validation is skipped (admin action with trusted input)
type GiveCardAction struct {
gameRepo game.GameRepository
cardRegistry cards.CardRegistry
logger *zap.Logger
}
// NewGiveCardAction creates a new give card admin action
func NewGiveCardAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
logger *zap.Logger,
) *GiveCardAction {
return &GiveCardAction{
gameRepo: gameRepo,
cardRegistry: cardRegistry,
logger: logger,
}
}
// Execute performs the give card admin action
func (a *GiveCardAction) Execute(ctx context.Context, gameID string, playerID string, cardID string) error {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("player_id", playerID),
zap.String("action", "admin_give_card"),
zap.String("card_id", cardID),
)
log.Debug("Admin: Giving card to player")
game, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
return fmt.Errorf("game not found: %s", gameID)
}
player, err := game.GetPlayer(playerID)
if err != nil {
log.Error("Player not found in game", zap.Error(err))
return fmt.Errorf("player not found: %s", playerID)
}
player.Hand().AddCard(cardID)
log.Info("Admin give card completed")
return nil
}
package admin
import (
"context"
"fmt"
"time"
"go.uber.org/zap"
baseaction "terraforming-mars-backend/internal/action"
"terraforming-mars-backend/internal/awards"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game"
gamecards "terraforming-mars-backend/internal/game/cards"
)
// SetCorporationAction handles the admin action to set a player's corporation
type SetCorporationAction struct {
gameRepo game.GameRepository
cardRegistry cards.CardRegistry
awardRegistry awards.AwardRegistry
corpProc *gamecards.CorporationProcessor
logger *zap.Logger
}
// NewSetCorporationAction creates a new set corporation admin action
func NewSetCorporationAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
awardRegistry awards.AwardRegistry,
logger *zap.Logger,
) *SetCorporationAction {
return &SetCorporationAction{
gameRepo: gameRepo,
cardRegistry: cardRegistry,
awardRegistry: awardRegistry,
corpProc: gamecards.NewCorporationProcessor(cardRegistry, awardRegistry, logger),
logger: logger,
}
}
// Execute performs the set corporation admin action
func (a *SetCorporationAction) Execute(ctx context.Context, gameID string, playerID string, corporationID string) error {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("player_id", playerID),
zap.String("action", "admin_set_corporation"),
zap.String("corporation_id", corporationID),
)
log.Debug("Admin: Setting player corporation")
g, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
return fmt.Errorf("game not found: %s", gameID)
}
player, err := g.GetPlayer(playerID)
if err != nil {
log.Error("Player not found in game", zap.Error(err))
return fmt.Errorf("player not found: %s", playerID)
}
oldCorpID := player.CorporationID()
if oldCorpID != "" {
log.Debug("Clearing old corporation effects", zap.String("old_corporation_id", oldCorpID))
player.Effects().RemoveEffectsByCardID(oldCorpID)
player.Actions().RemoveActionsByCardID(oldCorpID)
player.Resources().RemoveCardStorage(oldCorpID)
player.Resources().ClearPaymentSubstitutes()
player.Resources().ClearValueModifiers()
player.VPGranters().RemoveByCardID(oldCorpID)
log.Debug("Old corporation effects cleared")
}
corpCard, err := a.cardRegistry.GetByID(corporationID)
if err != nil {
log.Error("Failed to fetch corporation card", zap.Error(err))
return fmt.Errorf("corporation card not found: %s", corporationID)
}
if corpCard.Type != gamecards.CardTypeCorporation {
log.Error("Card is not a corporation", zap.String("card_type", string(corpCard.Type)))
return fmt.Errorf("card %s is not a corporation card", corporationID)
}
player.SetCorporationID(corporationID)
if corpCard.ResourceStorage != nil {
player.Resources().AddToStorage(corporationID, corpCard.ResourceStorage.Starting)
}
log.Debug("Corporation ID set", zap.String("corporation_name", corpCard.Name))
// Register trigger effects BEFORE applying starting effects so that
// production-increased triggers (e.g. Manutech) fire on starting production
triggerEffects := a.corpProc.GetTriggerEffects(corpCard)
for _, effect := range triggerEffects {
player.Effects().AddEffect(effect)
log.Debug("Registered trigger effect",
zap.String("card_name", effect.CardName),
zap.Int("behavior_index", effect.BehaviorIndex))
baseaction.SubscribePassiveEffectToEvents(ctx, g, player, effect, log, a.cardRegistry)
}
if err := a.corpProc.ApplyStartingEffects(ctx, corpCard, player, g); err != nil {
log.Error("Failed to apply corporation starting effects", zap.Error(err))
return fmt.Errorf("failed to apply corporation starting effects: %w", err)
}
if err := a.corpProc.ApplyAutoEffects(ctx, corpCard, player, g); err != nil {
log.Error("Failed to apply corporation auto effects", zap.Error(err))
return fmt.Errorf("failed to apply corporation auto effects: %w", err)
}
autoEffects := a.corpProc.GetAutoEffects(corpCard)
for _, effect := range autoEffects {
player.Effects().AddEffect(effect)
log.Debug("Registered auto effect",
zap.String("card_name", effect.CardName),
zap.Int("behavior_index", effect.BehaviorIndex))
}
// Publish TagPlayedEvent for each corporation tag (triggers Saturn Systems, etc.)
for _, tag := range corpCard.Tags {
events.Publish(g.EventBus(), events.TagPlayedEvent{
GameID: gameID,
PlayerID: playerID,
CardID: corporationID,
CardName: corpCard.Name,
Tag: string(tag),
Timestamp: time.Now(),
})
}
g.RegisterCorporationVPGranter(playerID, corporationID)
manualActions := a.corpProc.GetManualActions(corpCard)
for _, action := range manualActions {
player.Actions().AddAction(action)
log.Debug("Registered manual action",
zap.String("card_name", action.CardName),
zap.Int("behavior_index", action.BehaviorIndex))
}
if err := a.corpProc.SetupForcedFirstAction(ctx, corpCard, g, playerID); err != nil {
log.Error("Failed to setup forced first action", zap.Error(err))
return fmt.Errorf("failed to setup forced first action: %w", err)
}
log.Info("Admin set corporation completed with all effects applied",
zap.String("corporation_name", corpCard.Name))
return nil
}
package admin
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game"
)
// SetCurrentTurnAction handles the admin action to set the current turn
type SetCurrentTurnAction struct {
gameRepo game.GameRepository
logger *zap.Logger
}
// NewSetCurrentTurnAction creates a new set current turn admin action
func NewSetCurrentTurnAction(
gameRepo game.GameRepository,
logger *zap.Logger,
) *SetCurrentTurnAction {
return &SetCurrentTurnAction{
gameRepo: gameRepo,
logger: logger,
}
}
// Execute performs the set current turn admin action
func (a *SetCurrentTurnAction) Execute(ctx context.Context, gameID string, playerID string) error {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("player_id", playerID),
zap.String("action", "admin_set_current_turn"),
)
log.Debug("Admin: Setting current turn")
game, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
return fmt.Errorf("game not found: %s", gameID)
}
_, err = game.GetPlayer(playerID)
if err != nil {
log.Error("Player not found in game", zap.Error(err))
return fmt.Errorf("player not found: %s", playerID)
}
err = game.SetCurrentTurn(ctx, playerID, -1)
if err != nil {
log.Error("Failed to update current turn", zap.Error(err))
return fmt.Errorf("failed to update current turn: %w", err)
}
log.Info("Admin set current turn completed")
return nil
}
package admin
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game"
)
// SetGlobalParametersRequest contains the parameters to set
type SetGlobalParametersRequest struct {
Temperature int
Oxygen int
Oceans int
Venus int
}
// SetGlobalParametersAction handles the admin action to set global parameters
type SetGlobalParametersAction struct {
gameRepo game.GameRepository
logger *zap.Logger
}
// NewSetGlobalParametersAction creates a new set global parameters admin action
func NewSetGlobalParametersAction(
gameRepo game.GameRepository,
logger *zap.Logger,
) *SetGlobalParametersAction {
return &SetGlobalParametersAction{
gameRepo: gameRepo,
logger: logger,
}
}
// Execute performs the set global parameters admin action
func (a *SetGlobalParametersAction) Execute(ctx context.Context, gameID string, params SetGlobalParametersRequest) error {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("action", "admin_set_global_parameters"),
zap.Int("temperature", params.Temperature),
zap.Int("oxygen", params.Oxygen),
zap.Int("oceans", params.Oceans),
zap.Int("venus", params.Venus),
)
log.Debug("Admin: Setting global parameters")
game, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
return fmt.Errorf("game not found: %s", gameID)
}
if err := game.GlobalParameters().SetTemperature(ctx, params.Temperature); err != nil {
log.Error("Failed to update temperature", zap.Error(err))
return fmt.Errorf("failed to update temperature: %w", err)
}
if err := game.GlobalParameters().SetOxygen(ctx, params.Oxygen); err != nil {
log.Error("Failed to update oxygen", zap.Error(err))
return fmt.Errorf("failed to update oxygen: %w", err)
}
if err := game.GlobalParameters().SetOceans(ctx, params.Oceans); err != nil {
log.Error("Failed to update oceans", zap.Error(err))
return fmt.Errorf("failed to update oceans: %w", err)
}
if err := game.GlobalParameters().SetVenus(ctx, params.Venus); err != nil {
log.Error("Failed to update venus", zap.Error(err))
return fmt.Errorf("failed to update venus: %w", err)
}
log.Info("Admin set global parameters completed")
return nil
}
package admin
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/shared"
)
// SetPhaseAction handles the admin action to set the game phase
type SetPhaseAction struct {
gameRepo game.GameRepository
logger *zap.Logger
}
// NewSetPhaseAction creates a new set phase admin action
func NewSetPhaseAction(
gameRepo game.GameRepository,
logger *zap.Logger,
) *SetPhaseAction {
return &SetPhaseAction{
gameRepo: gameRepo,
logger: logger,
}
}
// Execute performs the set phase admin action
func (a *SetPhaseAction) Execute(ctx context.Context, gameID string, phase shared.GamePhase) error {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("action", "admin_set_phase"),
zap.String("phase", string(phase)),
)
log.Debug("Admin: Setting game phase")
game, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
return fmt.Errorf("game not found: %s", gameID)
}
err = game.UpdatePhase(ctx, phase)
if err != nil {
log.Error("Failed to update phase", zap.Error(err))
return fmt.Errorf("failed to update phase: %w", err)
}
log.Info("Admin set phase completed")
return nil
}
package admin
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/shared"
)
// SetProductionAction handles the admin action to set player production
type SetProductionAction struct {
gameRepo game.GameRepository
logger *zap.Logger
}
// NewSetProductionAction creates a new set production admin action
func NewSetProductionAction(
gameRepo game.GameRepository,
logger *zap.Logger,
) *SetProductionAction {
return &SetProductionAction{
gameRepo: gameRepo,
logger: logger,
}
}
// Execute performs the set production admin action
func (a *SetProductionAction) Execute(ctx context.Context, gameID string, playerID string, production shared.Production) error {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("player_id", playerID),
zap.String("action", "admin_set_production"),
zap.Int("credits", production.Credits),
zap.Int("steel", production.Steel),
zap.Int("titanium", production.Titanium),
zap.Int("plants", production.Plants),
zap.Int("energy", production.Energy),
zap.Int("heat", production.Heat),
)
log.Debug("Admin: Setting player production")
game, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
return fmt.Errorf("game not found: %s", gameID)
}
player, err := game.GetPlayer(playerID)
if err != nil {
log.Error("Player not found in game", zap.Error(err))
return fmt.Errorf("player not found: %s", playerID)
}
player.Resources().SetProduction(production)
log.Info("Admin set production completed")
return nil
}
package admin
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/shared"
)
// SetResourcesAction handles the admin action to set player resources
type SetResourcesAction struct {
gameRepo game.GameRepository
logger *zap.Logger
}
// NewSetResourcesAction creates a new set resources admin action
func NewSetResourcesAction(
gameRepo game.GameRepository,
logger *zap.Logger,
) *SetResourcesAction {
return &SetResourcesAction{
gameRepo: gameRepo,
logger: logger,
}
}
// Execute performs the set resources admin action
func (a *SetResourcesAction) Execute(ctx context.Context, gameID string, playerID string, resources shared.Resources) error {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("player_id", playerID),
zap.String("action", "admin_set_resources"),
zap.Int("credits", resources.Credits),
zap.Int("steel", resources.Steel),
zap.Int("titanium", resources.Titanium),
zap.Int("plants", resources.Plants),
zap.Int("energy", resources.Energy),
zap.Int("heat", resources.Heat),
)
log.Debug("Admin: Setting player resources")
game, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
return fmt.Errorf("game not found: %s", gameID)
}
player, err := game.GetPlayer(playerID)
if err != nil {
log.Error("Player not found in game", zap.Error(err))
return fmt.Errorf("player not found: %s", playerID)
}
player.Resources().Set(resources)
log.Info("Admin set resources completed")
return nil
}
package admin
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game"
)
// SetTRAction handles the admin action to set player terraform rating
type SetTRAction struct {
gameRepo game.GameRepository
logger *zap.Logger
}
// NewSetTRAction creates a new set TR admin action
func NewSetTRAction(
gameRepo game.GameRepository,
logger *zap.Logger,
) *SetTRAction {
return &SetTRAction{
gameRepo: gameRepo,
logger: logger,
}
}
// Execute performs the set TR admin action
func (a *SetTRAction) Execute(ctx context.Context, gameID string, playerID string, terraformRating int) error {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("player_id", playerID),
zap.String("action", "admin_set_tr"),
zap.Int("terraform_rating", terraformRating),
)
log.Debug("Admin: Setting player terraform rating")
game, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
return fmt.Errorf("game not found: %s", gameID)
}
player, err := game.GetPlayer(playerID)
if err != nil {
log.Error("Player not found in game", zap.Error(err))
return fmt.Errorf("player not found: %s", playerID)
}
player.Resources().SetTerraformRating(terraformRating)
log.Info("Admin set terraform rating completed")
return nil
}
package admin
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/board"
"terraforming-mars-backend/internal/game/shared"
)
// StartTileSelectionAction handles the admin action to start tile selection for a player
type StartTileSelectionAction struct {
gameRepo game.GameRepository
logger *zap.Logger
}
// NewStartTileSelectionAction creates a new start tile selection admin action
func NewStartTileSelectionAction(
gameRepo game.GameRepository,
logger *zap.Logger,
) *StartTileSelectionAction {
return &StartTileSelectionAction{
gameRepo: gameRepo,
logger: logger,
}
}
// Execute performs the start tile selection admin action
func (a *StartTileSelectionAction) Execute(ctx context.Context, gameID string, playerID string, tileType string) error {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("player_id", playerID),
zap.String("action", "admin_start_tile_selection"),
zap.String("tile_type", tileType),
)
log.Debug("Admin: Starting tile selection")
if !board.ValidPlaceableTileType(tileType) {
log.Error("Invalid tile type", zap.String("tile_type", tileType))
return fmt.Errorf("invalid tile type: %s", tileType)
}
g, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
return fmt.Errorf("game not found: %s", gameID)
}
_, err = g.GetPlayer(playerID)
if err != nil {
log.Error("Player not found in game", zap.Error(err))
return fmt.Errorf("player not found: %s", playerID)
}
queue := &shared.PendingTileSelectionQueue{
Items: []string{tileType},
Source: "admin-tile-selection",
}
if err := g.SetPendingTileSelectionQueue(ctx, playerID, queue); err != nil {
log.Error("Failed to set tile selection queue", zap.Error(err))
return fmt.Errorf("failed to start tile selection: %w", err)
}
log.Info("Admin start tile selection completed")
return nil
}
package award
import (
"context"
"fmt"
"slices"
"go.uber.org/zap"
baseaction "terraforming-mars-backend/internal/action"
"terraforming-mars-backend/internal/awards"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/shared"
)
// FundAwardAction handles the business logic for funding an award
type FundAwardAction struct {
baseaction.BaseAction
awardRegistry awards.AwardRegistry
}
// NewFundAwardAction creates a new fund award action
func NewFundAwardAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
stateRepo game.GameStateRepository,
awardRegistry awards.AwardRegistry,
logger *zap.Logger,
) *FundAwardAction {
return &FundAwardAction{
BaseAction: baseaction.NewBaseActionWithStateRepo(gameRepo, cardRegistry, stateRepo),
awardRegistry: awardRegistry,
}
}
// Execute funds an award for the player
func (a *FundAwardAction) Execute(ctx context.Context, gameID string, playerID string, awardType string) error {
log := a.InitLogger(gameID, playerID).With(zap.String("action", "fund_award"), zap.String("award", awardType))
log.Debug("Funding award")
def, err := a.awardRegistry.GetByID(awardType)
if err != nil {
log.Warn("Invalid award type", zap.String("award_type", awardType))
return fmt.Errorf("invalid award type: %s", awardType)
}
g, err := baseaction.ValidateActiveGame(ctx, a.GameRepository(), gameID, log)
if err != nil {
return err
}
if err := baseaction.ValidateGamePhase(g, shared.GamePhaseAction, log); err != nil {
return err
}
if err := baseaction.ValidateCurrentTurn(g, playerID, log); err != nil {
return err
}
if err := baseaction.ValidateActionsRemaining(g, playerID, log); err != nil {
return err
}
if err := baseaction.ValidateNoPendingSelections(g, playerID, log); err != nil {
return err
}
player, err := a.GetPlayerFromGame(g, playerID, log)
if err != nil {
return err
}
// Validate award is in the selected set for this game
if selected := g.SelectedAwards(); len(selected) > 0 && !slices.Contains(selected, awardType) {
log.Warn("Award not available in this game", zap.String("award", awardType))
return fmt.Errorf("award %s is not available in this game", awardType)
}
awardState := g.Awards()
at := shared.AwardType(awardType)
if awardState.IsFunded(at) {
log.Warn("Award already funded", zap.String("award", awardType))
return fmt.Errorf("award %s is already funded", awardType)
}
if !awardState.CanFundMore() {
log.Warn("Maximum awards already funded", zap.Int("max", game.MaxFundedAwards))
return fmt.Errorf("maximum awards (%d) already funded", game.MaxFundedAwards)
}
fundingCost := def.GetCostForFundedCount(awardState.FundedCount())
resources := player.Resources().Get()
if resources.Credits < fundingCost {
log.Warn("Insufficient credits for award",
zap.Int("cost", fundingCost),
zap.Int("player_credits", resources.Credits))
return fmt.Errorf("insufficient credits: need %d, have %d", fundingCost, resources.Credits)
}
player.Resources().Add(map[shared.ResourceType]int{
shared.ResourceCredit: -fundingCost,
})
log.Debug("Deducted award funding cost",
zap.Int("cost", fundingCost),
zap.Int("remaining_credits", player.Resources().Get().Credits))
if err := awardState.FundAward(ctx, at, playerID, fundingCost); err != nil {
log.Error("Failed to fund award", zap.Error(err))
return fmt.Errorf("failed to fund award: %w", err)
}
a.ConsumePlayerAction(g, log)
a.WriteStateLog(ctx, g, def.Name, shared.SourceTypeAward, playerID, fmt.Sprintf("Funded %s award", def.Name))
log.Info("Award funded",
zap.String("award", awardType),
zap.Int("total_funded", awardState.FundedCount()))
return nil
}
package action
import (
"context"
"fmt"
"time"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
"terraforming-mars-backend/internal/logger"
"go.uber.org/zap"
)
// BaseAction provides common dependencies for all actions.
// Following the new architecture: actions use ONLY GameRepository (+ logger + card registry)
// Broadcasting happens automatically via events published by Game methods
type BaseAction struct {
gameRepo game.GameRepository
cardRegistry cards.CardRegistry
stateRepo game.GameStateRepository
logger *zap.Logger
}
// NewBaseAction creates a new BaseAction with minimal dependencies
func NewBaseAction(gameRepo game.GameRepository, cardRegistry cards.CardRegistry) BaseAction {
return BaseAction{
gameRepo: gameRepo,
cardRegistry: cardRegistry,
logger: logger.Get(),
}
}
// NewBaseActionWithStateRepo creates a new BaseAction with state repository for logging
func NewBaseActionWithStateRepo(gameRepo game.GameRepository, cardRegistry cards.CardRegistry, stateRepo game.GameStateRepository) BaseAction {
return BaseAction{
gameRepo: gameRepo,
cardRegistry: cardRegistry,
stateRepo: stateRepo,
logger: logger.Get(),
}
}
// InitLogger creates a logger with game and player context
// This should be called at the start of every Execute method
func (b *BaseAction) InitLogger(gameID, playerID string) *zap.Logger {
return b.logger.With(
zap.String("game_id", gameID),
zap.String("player_id", playerID),
)
}
// GetLogger returns the base logger for actions that don't have game/player context
func (b *BaseAction) GetLogger() *zap.Logger {
return b.logger
}
// GameRepository returns the game repository
func (b *BaseAction) GameRepository() game.GameRepository {
return b.gameRepo
}
// CardRegistry returns the card registry
func (b *BaseAction) CardRegistry() cards.CardRegistry {
return b.cardRegistry
}
// StateRepository returns the game state repository (may be nil)
func (b *BaseAction) StateRepository() game.GameStateRepository {
return b.stateRepo
}
// WriteStateLog writes a state diff to the state repository if configured
func (b *BaseAction) WriteStateLog(ctx context.Context, g *game.Game, source string, sourceType shared.SourceType, playerID, description string) {
b.WriteStateLogWithChoice(ctx, g, source, sourceType, playerID, description, nil)
}
// WriteStateLogWithChoice writes a state diff with an optional choice index
func (b *BaseAction) WriteStateLogWithChoice(ctx context.Context, g *game.Game, source string, sourceType shared.SourceType, playerID, description string, choiceIndex *int) {
b.WriteStateLogWithChoiceAndOutputs(ctx, g, source, sourceType, playerID, description, choiceIndex, nil)
}
// WriteStateLogWithChoiceAndOutputs writes a state diff with optional choice index and calculated outputs
func (b *BaseAction) WriteStateLogWithChoiceAndOutputs(ctx context.Context, g *game.Game, source string, sourceType shared.SourceType, playerID, description string, choiceIndex *int, calculatedOutputs []shared.CalculatedOutput) {
b.WriteStateLogFull(ctx, g, source, sourceType, playerID, description, choiceIndex, calculatedOutputs, nil)
}
// WriteStateLogFull writes a state diff with all optional fields including display data
func (b *BaseAction) WriteStateLogFull(ctx context.Context, g *game.Game, source string, sourceType shared.SourceType, playerID, description string, choiceIndex *int, calculatedOutputs []shared.CalculatedOutput, displayData *game.LogDisplayData) {
if b.stateRepo == nil {
return
}
_, err := b.stateRepo.WriteFull(ctx, g.ID(), g, source, sourceType, playerID, description, choiceIndex, calculatedOutputs, displayData)
if err != nil {
b.logger.Warn("Failed to write state log",
zap.String("game_id", g.ID()),
zap.String("source", source),
zap.Error(err))
}
}
// GetPlayerFromGame fetches a player from the game with consistent error handling
func (b *BaseAction) GetPlayerFromGame(g *game.Game, playerID string, log *zap.Logger) (*player.Player, error) {
p, err := g.GetPlayer(playerID)
if err != nil {
log.Error("Player not found in game", zap.Error(err))
return nil, fmt.Errorf("player not found: %s", playerID)
}
return p, nil
}
// ConsumePlayerAction consumes an action from the game's current turn
// Returns true if an action was consumed, false if unlimited (-1) or no actions remaining (0)
// This properly handles unlimited actions by not consuming them
// When the last action is consumed and no tile placement is pending, auto-advances to the next player
func (b *BaseAction) ConsumePlayerAction(g *game.Game, log *zap.Logger) bool {
currentTurn := g.CurrentTurn()
if currentTurn == nil {
log.Warn("No current turn set, cannot consume action")
return false
}
currentTurn.IncrementGlobalActionCounter()
playerID := currentTurn.PlayerID()
consumed := currentTurn.ConsumeAction()
if consumed {
log.Debug("Action consumed", zap.Int("remaining_actions", currentTurn.ActionsRemaining()))
if currentTurn.ActionsRemaining() == 0 {
AutoAdvanceTurnIfNeeded(g, playerID, log)
}
if eventBus := g.EventBus(); eventBus != nil {
events.Publish(eventBus, events.GameStateChangedEvent{
GameID: g.ID(),
Timestamp: time.Now(),
})
}
}
return consumed
}
// AutoAdvanceTurnIfNeeded advances the turn to the next non-passed player
// if the current player has 0 actions remaining and no pending tile selection.
// This is called after consuming an action or after completing a tile placement.
func AutoAdvanceTurnIfNeeded(g *game.Game, playerID string, log *zap.Logger) {
currentTurn := g.CurrentTurn()
if currentTurn == nil {
return
}
if currentTurn.PlayerID() != playerID {
return
}
if currentTurn.ActionsRemaining() != 0 {
return
}
if g.HasAnyPendingSelection(playerID) {
return
}
turnOrder := g.TurnOrder()
if len(turnOrder) == 0 {
return
}
currentIndex := -1
for i, id := range turnOrder {
if id == playerID {
currentIndex = i
break
}
}
if currentIndex == -1 {
return
}
// Count non-passed players to determine if next player should get unlimited actions
nonPassedCount := 0
for _, id := range turnOrder {
p, err := g.GetPlayer(id)
if err == nil && !p.HasPassed() {
nonPassedCount++
}
}
// If current player is the only non-passed player, give them unlimited actions
if nonPassedCount == 1 {
currentPlayer, err := g.GetPlayer(playerID)
if err == nil && !currentPlayer.HasPassed() {
if err := g.SetCurrentTurn(context.Background(), playerID, -1); err != nil {
log.Error("Failed to grant unlimited actions to last player", zap.Error(err))
} else {
log.Debug("Last non-passed player granted unlimited actions",
zap.String("player_id", playerID))
}
return
}
}
for i := 1; i < len(turnOrder); i++ {
nextIndex := (currentIndex + i) % len(turnOrder)
nextID := turnOrder[nextIndex]
nextPlayer, err := g.GetPlayer(nextID)
if err != nil {
continue
}
if !nextPlayer.HasPassed() {
// If next player will be the only non-passed player, give unlimited actions
nextActions := 2
if nonPassedCount == 1 {
nextActions = -1
log.Debug("Last non-passed player granted unlimited actions",
zap.String("player_id", nextID))
}
if err := g.SetCurrentTurn(context.Background(), nextID, nextActions); err != nil {
log.Error("Failed to auto-advance turn", zap.Error(err))
} else {
log.Debug("Auto-advanced turn to next player",
zap.String("from", playerID),
zap.String("to", nextID),
zap.Int("actions", nextActions))
}
return
}
}
}
package card
import (
"context"
"fmt"
"time"
baseaction "terraforming-mars-backend/internal/action"
"go.uber.org/zap"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game"
gamecards "terraforming-mars-backend/internal/game/cards"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
)
// PlayCardAction handles the business logic for playing a project card from hand
// Card playing involves: validating requirements, calculating costs (with discounts),
// moving card to played cards, applying immediate effects, and deducting payment
type PlayCardAction struct {
baseaction.BaseAction
colonyBonusLookup gamecards.ColonyBonusLookup
}
// NewPlayCardAction creates a new play card action
func NewPlayCardAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
stateRepo game.GameStateRepository,
logger *zap.Logger,
colonyBonusLookup ...gamecards.ColonyBonusLookup,
) *PlayCardAction {
var lookup gamecards.ColonyBonusLookup
if len(colonyBonusLookup) > 0 {
lookup = colonyBonusLookup[0]
}
return &PlayCardAction{
BaseAction: baseaction.NewBaseActionWithStateRepo(gameRepo, cardRegistry, stateRepo),
colonyBonusLookup: lookup,
}
}
// PaymentRequest represents the payment resources provided by the player
type PaymentRequest struct {
Credits int `json:"credits"`
Steel int `json:"steel"`
Titanium int `json:"titanium"`
Substitutes map[shared.ResourceType]int `json:"substitutes"`
StorageSubstitutes map[string]int `json:"storageSubstitutes"` // cardID -> amount of storage resources to use as payment
}
// Execute performs the play card action
func (a *PlayCardAction) Execute(
ctx context.Context,
gameID string,
playerID string,
cardID string,
payment PaymentRequest,
choiceIndex *int,
cardStorageTargets []string,
targetPlayerID *string,
selectedAmount *int,
) error {
log := a.InitLogger(gameID, playerID).With(
zap.String("card_id", cardID),
zap.String("action", "play_card"),
)
if choiceIndex != nil {
log = log.With(zap.Int("choice_index", *choiceIndex))
}
if len(cardStorageTargets) > 0 {
log = log.With(zap.Strings("card_storage_targets", cardStorageTargets))
}
if targetPlayerID != nil {
log = log.With(zap.String("target_player_id", *targetPlayerID))
}
log.Debug("Player attempting to play card")
g, err := baseaction.ValidateActiveGame(ctx, a.GameRepository(), gameID, log)
if err != nil {
return err
}
if err := baseaction.ValidateGamePhase(g, shared.GamePhaseAction, log); err != nil {
return err
}
if err := baseaction.ValidateCurrentTurn(g, playerID, log); err != nil {
return err
}
if err := baseaction.ValidateActionsRemaining(g, playerID, log); err != nil {
return err
}
if err := baseaction.ValidateNoPendingSelections(g, playerID, log); err != nil {
return err
}
player, err := a.GetPlayerFromGame(g, playerID, log)
if err != nil {
return err
}
// Collect temporary "next-card" effect card IDs BEFORE playing
// so we can clean them up after this card is played (but not any new ones created by this card)
prePlayTemporaryCardIDs := collectTemporaryEffectCardIDs(player, shared.TemporaryNextCard)
if !player.Hand().HasCard(cardID) {
log.Error("Card not in player's hand")
return fmt.Errorf("card %s not in hand", cardID)
}
card, err := a.CardRegistry().GetByID(cardID)
if err != nil {
log.Error("Card not found in registry", zap.Error(err))
return fmt.Errorf("card not found: %w", err)
}
log.Debug("Card data retrieved",
zap.String("card_name", card.Name),
zap.Int("base_cost", card.Cost))
calculator := gamecards.NewRequirementModifierCalculator(a.CardRegistry())
if err := validateCardRequirements(card, g, player, calculator, a.CardRegistry()); err != nil {
log.Error("Card requirements not met", zap.Error(err))
return fmt.Errorf("cannot play card: %w", err)
}
log.Debug("Card requirements validated")
if tileErrors := baseaction.ValidateTileOutputs(card, player, g); len(tileErrors) > 0 {
log.Error("Tile placement not available", zap.String("error", tileErrors[0].Message))
return fmt.Errorf("cannot play card: %s", tileErrors[0].Message)
}
if err := validateProductionOutputs(card, player); err != nil {
log.Error("Production output validation failed", zap.Error(err))
return fmt.Errorf("cannot play card: %w", err)
}
if err := validateNegativeResourceOutputs(card, player); err != nil {
log.Error("Negative resource output validation failed", zap.Error(err))
return fmt.Errorf("cannot play card: %w", err)
}
// Validate choice requirements early (before any state mutations)
if choiceIndex != nil {
for _, behavior := range card.Behaviors {
if gamecards.HasAutoTrigger(behavior) && *choiceIndex >= 0 && *choiceIndex < len(behavior.Choices) {
selectedChoice := behavior.Choices[*choiceIndex]
if selectedChoice.Requirements != nil {
if err := validateChoiceRequirements(selectedChoice.Requirements, player, g, a.CardRegistry()); err != nil {
log.Error("Choice requirements not met", zap.Error(err))
return fmt.Errorf("choice %d requirements not met: %w", *choiceIndex, err)
}
}
}
}
log.Debug("Choice requirements validated")
}
discountAmount := calculator.CalculateCardDiscounts(player, card)
effectiveCost := card.Cost - discountAmount
if effectiveCost < 0 {
effectiveCost = 0
}
if discountAmount > 0 {
log.Debug("Discount applied",
zap.Int("base_cost", card.Cost),
zap.Int("discount", discountAmount),
zap.Int("effective_cost", effectiveCost))
}
playerSubstitutes := player.Resources().PaymentSubstitutes()
// Get storage payment substitutes applicable to this card (filtered by selectors)
allStorageSubs := player.Resources().StoragePaymentSubstitutes()
var applicableStorageSubs []shared.StoragePaymentSubstitute
for _, sub := range allStorageSubs {
if len(sub.Selectors) == 0 || gamecards.MatchesAnySelector(card, sub.Selectors) {
applicableStorageSubs = append(applicableStorageSubs, sub)
}
}
allowSteel := gamecards.HasTag(card, shared.TagBuilding)
allowTitanium := gamecards.HasTag(card, shared.TagSpace)
adjustedPayment := adjustPaymentToEffectiveCost(payment, effectiveCost, allowSteel, allowTitanium, playerSubstitutes, applicableStorageSubs, player)
cardPayment := gamecards.CardPayment{
Credits: adjustedPayment.Credits,
Steel: adjustedPayment.Steel,
Titanium: adjustedPayment.Titanium,
Substitutes: adjustedPayment.Substitutes,
StorageSubstitutes: adjustedPayment.StorageSubstitutes,
}
if err := cardPayment.CoversCardCost(effectiveCost, allowSteel, allowTitanium, playerSubstitutes, applicableStorageSubs); err != nil {
log.Error("Payment validation failed", zap.Error(err))
return err
}
totalValue := cardPayment.TotalValue(playerSubstitutes, applicableStorageSubs)
log.Debug("Payment validated",
zap.Int("effective_cost", effectiveCost),
zap.Int("payment_value", totalValue),
zap.Int("credits", adjustedPayment.Credits),
zap.Int("steel", adjustedPayment.Steel),
zap.Int("titanium", adjustedPayment.Titanium),
zap.Any("substitutes", adjustedPayment.Substitutes),
zap.Any("storageSubstitutes", adjustedPayment.StorageSubstitutes))
resources := player.Resources().Get()
storageGetter := func(cardID string) int {
return player.Resources().GetCardStorage(cardID)
}
if err := cardPayment.CanAfford(resources, storageGetter); err != nil {
log.Error("Player can't afford payment", zap.Error(err))
return err
}
if !player.Hand().RemoveCard(cardID) {
log.Error("Failed to remove card from hand - card not found")
return fmt.Errorf("failed to remove card from hand: card not found")
}
log.Debug("Card removed from hand")
cardTags := make([]string, len(card.Tags))
for i, tag := range card.Tags {
cardTags[i] = string(tag)
}
player.PlayedCards().AddCard(cardID, card.Name, string(card.Type), cardTags)
log.Debug("Card added to played cards")
if card.ResourceStorage != nil {
player.Resources().AddToStorage(cardID, card.ResourceStorage.Starting)
log.Debug("Initialized resource storage",
zap.String("card_id", cardID),
zap.String("resource_type", string(card.ResourceStorage.Type)),
zap.Int("starting_amount", card.ResourceStorage.Starting))
}
deductions := map[shared.ResourceType]int{
shared.ResourceCredit: -adjustedPayment.Credits,
shared.ResourceSteel: -adjustedPayment.Steel,
shared.ResourceTitanium: -adjustedPayment.Titanium,
}
for resourceType, amount := range adjustedPayment.Substitutes {
deductions[resourceType] = -amount
}
player.Resources().Add(deductions)
// Deduct storage payment substitutes (e.g., Dirigibles floaters)
for cardID, amount := range adjustedPayment.StorageSubstitutes {
if amount > 0 {
player.Resources().AddToStorage(cardID, -amount)
log.Debug("Deducted storage payment",
zap.String("card_id", cardID),
zap.Int("amount", amount))
}
}
log.Debug("Payment deducted",
zap.Int("credits", adjustedPayment.Credits),
zap.Int("steel", adjustedPayment.Steel),
zap.Int("titanium", adjustedPayment.Titanium),
zap.Any("substitutes", adjustedPayment.Substitutes),
zap.Any("storageSubstitutes", adjustedPayment.StorageSubstitutes))
calculatedOutputs, err := a.applyCardBehaviors(ctx, g, card, player, choiceIndex, cardStorageTargets, targetPlayerID, selectedAmount, log)
if err != nil {
log.Error("Failed to apply card behaviors", zap.Error(err))
return fmt.Errorf("failed to apply card behaviors: %w", err)
}
// Clean up temporary "next-card" effects that existed before this card was played
removePrePlayTemporaryEffects(player, prePlayTemporaryCardIDs, log)
a.ConsumePlayerAction(g, log)
description := fmt.Sprintf("Played %s for %d credits", card.Name, totalValue)
displayData := baseaction.BuildCardDisplayData(card, shared.SourceTypeCardPlay)
a.WriteStateLogFull(ctx, g, card.Name, shared.SourceTypeCardPlay, playerID, description, choiceIndex, calculatedOutputs, displayData)
log.Info("Card played",
zap.String("card_name", card.Name),
zap.Int("card_cost", card.Cost),
zap.Int("payment_value", totalValue))
return nil
}
// validateCardRequirements validates that the player and game state meet all card requirements.
// Uses RequirementModifierCalculator to include global parameter lenience from temporary effects.
func validateCardRequirements(card *gamecards.Card, g *game.Game, player *player.Player, calculator *gamecards.RequirementModifierCalculator, cardRegistry cards.CardRegistry) error {
if card.Requirements == nil || len(card.Requirements.Items) == 0 {
return nil // No requirements to validate
}
if calculator.HasIgnoreGlobalRequirements(player) {
return nil
}
for _, req := range card.Requirements.Items {
switch req.Type {
case gamecards.RequirementTemperature:
lenience := calculator.CalculateGlobalParameterLenience(player, "temperature")
temp := g.GlobalParameters().Temperature()
if req.Min != nil && temp < *req.Min-lenience {
return fmt.Errorf("temperature requirement not met: need %d°C, current %d°C", *req.Min, temp)
}
if req.Max != nil && temp > *req.Max+lenience {
return fmt.Errorf("temperature requirement not met: max %d°C, current %d°C", *req.Max, temp)
}
case gamecards.RequirementOxygen:
lenience := calculator.CalculateGlobalParameterLenience(player, "oxygen")
oxygen := g.GlobalParameters().Oxygen()
if req.Min != nil && oxygen < *req.Min-lenience {
return fmt.Errorf("oxygen requirement not met: need %d%%, current %d%%", *req.Min, oxygen)
}
if req.Max != nil && oxygen > *req.Max+lenience {
return fmt.Errorf("oxygen requirement not met: max %d%%, current %d%%", *req.Max, oxygen)
}
case gamecards.RequirementOceans:
lenience := calculator.CalculateGlobalParameterLenience(player, "ocean")
oceans := g.GlobalParameters().Oceans()
if req.Min != nil && oceans < *req.Min-lenience {
return fmt.Errorf("ocean requirement not met: need %d, current %d", *req.Min, oceans)
}
if req.Max != nil && oceans > *req.Max+lenience {
return fmt.Errorf("ocean requirement not met: max %d, current %d", *req.Max, oceans)
}
case gamecards.RequirementTR:
tr := player.Resources().TerraformRating()
if req.Min != nil && tr < *req.Min {
return fmt.Errorf("terraform rating requirement not met: need %d, current %d", *req.Min, tr)
}
if req.Max != nil && tr > *req.Max {
return fmt.Errorf("terraform rating requirement not met: max %d, current %d", *req.Max, tr)
}
case gamecards.RequirementTags:
if req.Tag == nil {
return fmt.Errorf("tag requirement missing tag specification")
}
// Count the card's own tags toward requirements (per TM rules, the card being played counts)
tagCount := gamecards.CountPlayerTagsByType(player, cardRegistry, *req.Tag, card.Tags)
if req.Min != nil && tagCount < *req.Min {
return fmt.Errorf("tag requirement not met: need %d %s tags, have %d", *req.Min, *req.Tag, tagCount)
}
if req.Max != nil && tagCount > *req.Max {
return fmt.Errorf("tag requirement not met: max %d %s tags, have %d", *req.Max, *req.Tag, tagCount)
}
case gamecards.RequirementProduction:
if req.Resource == nil {
return fmt.Errorf("production requirement missing resource specification")
}
production := player.Resources().Production()
var currentProd int
switch *req.Resource {
case shared.ResourceCredit, shared.ResourceCreditProduction:
currentProd = production.Credits
case shared.ResourceSteel, shared.ResourceSteelProduction:
currentProd = production.Steel
case shared.ResourceTitanium, shared.ResourceTitaniumProduction:
currentProd = production.Titanium
case shared.ResourcePlant, shared.ResourcePlantProduction:
currentProd = production.Plants
case shared.ResourceEnergy, shared.ResourceEnergyProduction:
currentProd = production.Energy
case shared.ResourceHeat, shared.ResourceHeatProduction:
currentProd = production.Heat
}
if req.Min != nil && currentProd < *req.Min {
return fmt.Errorf("production requirement not met: need %d %s production, have %d", *req.Min, *req.Resource, currentProd)
}
if req.Max != nil && currentProd > *req.Max {
return fmt.Errorf("production requirement not met: max %d %s production, have %d", *req.Max, *req.Resource, currentProd)
}
case gamecards.RequirementResource:
if req.Resource == nil {
return fmt.Errorf("resource requirement missing resource specification")
}
resources := player.Resources().Get()
var currentAmount int
switch *req.Resource {
case shared.ResourceCredit:
currentAmount = resources.Credits
case shared.ResourceSteel:
currentAmount = resources.Steel
case shared.ResourceTitanium:
currentAmount = resources.Titanium
case shared.ResourcePlant:
currentAmount = resources.Plants
case shared.ResourceEnergy:
currentAmount = resources.Energy
case shared.ResourceHeat:
currentAmount = resources.Heat
}
if req.Min != nil && currentAmount < *req.Min {
return fmt.Errorf("resource requirement not met: need %d %s, have %d", *req.Min, *req.Resource, currentAmount)
}
if req.Max != nil && currentAmount > *req.Max {
return fmt.Errorf("resource requirement not met: max %d %s, have %d", *req.Max, *req.Resource, currentAmount)
}
case gamecards.RequirementCities, gamecards.RequirementGreeneries:
// TODO: Implement tile-based requirements when Board tile counting is ready
// For now, skip these validations
case gamecards.RequirementVenus:
lenience := calculator.CalculateGlobalParameterLenience(player, "venus")
venus := g.GlobalParameters().Venus()
if req.Min != nil && venus < *req.Min-lenience {
return fmt.Errorf("venus requirement not met: need %d%%, current %d%%", *req.Min, venus)
}
if req.Max != nil && venus > *req.Max+lenience {
return fmt.Errorf("venus requirement not met: max %d%%, current %d%%", *req.Max, venus)
}
}
}
return nil
}
// applyCardBehaviors processes all card behaviors and applies immediate effects or registers actions/effects
// Returns calculated outputs for logging purposes
func (a *PlayCardAction) applyCardBehaviors(
ctx context.Context,
g *game.Game,
card *gamecards.Card,
p *player.Player,
choiceIndex *int,
cardStorageTargets []string,
targetPlayerID *string,
selectedAmount *int,
log *zap.Logger,
) ([]shared.CalculatedOutput, error) {
if len(card.Behaviors) == 0 {
log.Debug("No card behaviors to apply")
return nil, nil
}
log.Debug("Processing card behaviors",
zap.String("card_id", card.ID),
zap.Int("behavior_count", len(card.Behaviors)))
var allCalculatedOutputs []shared.CalculatedOutput
for behaviorIndex, behavior := range card.Behaviors {
log.Debug("Processing behavior",
zap.Int("index", behaviorIndex),
zap.Int("trigger_count", len(behavior.Triggers)))
// Apply auto-trigger behaviors immediately
if gamecards.HasAutoTrigger(behavior) {
// Auto-select choice if behavior has an auto-selection policy
effectiveChoiceIndex := choiceIndex
if behavior.ChoicePolicy != nil && len(behavior.Choices) > 0 {
count := resolveChoicePolicyCount(behavior.ChoicePolicy, p, a.CardRegistry())
autoIdx := shared.AutoSelectChoiceIndex(behavior.ChoicePolicy, count)
if autoIdx >= 0 {
effectiveChoiceIndex = &autoIdx
log.Debug("Auto-selected choice by policy",
zap.String("policy_type", string(behavior.ChoicePolicy.Type)),
zap.Int("choice_index", autoIdx))
}
}
// Extract inputs and outputs, incorporating choice if present
inputs, outputs := behavior.ExtractInputsOutputs(effectiveChoiceIndex)
// Check for card-discard inputs — these defer output application
if gamecards.HasCardDiscardInput(behavior) {
a.createPendingCardDiscard(p, card, inputs, outputs, log)
continue
}
// Check for card-discard outputs — player must choose cards to discard first
if gamecards.HasCardDiscardOutput(behavior) {
a.createPendingCardDiscardFromOutputs(p, card, outputs, log)
continue
}
log.Debug("Found auto-trigger behavior, applying outputs immediately",
zap.Int("output_count", len(outputs)))
// Use BehaviorApplier for consistent output handling
applier := gamecards.NewBehaviorApplier(p, g, card.Name, log).
WithSourceCardID(card.ID).
WithCardRegistry(a.CardRegistry()).
WithSourceType(shared.SourceTypeCardPlay)
if a.colonyBonusLookup != nil {
applier = applier.WithColonyBonusLookup(a.colonyBonusLookup)
}
if len(cardStorageTargets) > 0 {
applier = applier.WithTargetCardIDs(cardStorageTargets)
}
if targetPlayerID != nil {
applier = applier.WithTargetPlayerID(*targetPlayerID)
}
if selectedAmount != nil {
applier = applier.WithSelectedAmount(*selectedAmount)
}
calculatedOutputs, err := applier.ApplyOutputsAndGetCalculated(ctx, outputs)
if err != nil {
return nil, fmt.Errorf("failed to apply auto behavior %d outputs: %w", behaviorIndex, err)
}
allCalculatedOutputs = append(allCalculatedOutputs, calculatedOutputs...)
if deferred := applier.DeferredSteal(); deferred != nil {
callback := &shared.TileCompletionCallback{
Type: "adjacent-steal",
Data: map[string]interface{}{
"resourceType": string(deferred.GetResourceType()),
"amount": deferred.GetAmount(),
"sourceCardID": card.ID,
"source": card.Name,
},
}
g.SetTileQueueOnComplete(ctx, p.ID(), callback)
}
// Also register as effect if it has persistent outputs (discount, payment-substitute)
// These need to show in the effects list for display and for modifier calculations
if gamecards.HasPersistentEffects(behavior) {
log.Debug("Registering auto-trigger behavior with persistent effects",
zap.String("card_name", card.Name))
effect := shared.CardEffect{
CardID: card.ID,
CardName: card.Name,
BehaviorIndex: behaviorIndex,
Behavior: behavior,
}
p.Effects().AddEffect(effect)
events.Publish(g.EventBus(), events.PlayerEffectsChangedEvent{
GameID: g.ID(),
PlayerID: p.ID(),
Timestamp: time.Now(),
})
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: card.Name,
PlayerID: p.ID(),
SourceType: shared.SourceTypeEffectAdded,
Behaviors: []shared.CardBehavior{behavior},
})
}
}
// Register manual-trigger behaviors as player actions
if gamecards.HasManualTrigger(behavior) {
log.Debug("Found manual-trigger behavior, registering as player action")
p.Actions().AddAction(shared.CardAction{
CardID: card.ID,
CardName: card.Name,
BehaviorIndex: behaviorIndex,
Behavior: behavior,
TimesUsedThisTurn: 0,
TimesUsedThisGeneration: 0,
})
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: card.Name,
PlayerID: p.ID(),
SourceType: shared.SourceTypeActionAdded,
Behaviors: []shared.CardBehavior{behavior},
})
}
// Register conditional-trigger behaviors as passive effects
if gamecards.HasConditionalTrigger(behavior) {
log.Debug("Found conditional-trigger behavior, registering as passive effect",
zap.Int("trigger_count", len(behavior.Triggers)))
effect := shared.CardEffect{
CardID: card.ID,
CardName: card.Name,
BehaviorIndex: behaviorIndex,
Behavior: behavior,
}
p.Effects().AddEffect(effect)
events.Publish(g.EventBus(), events.PlayerEffectsChangedEvent{
GameID: g.ID(),
PlayerID: p.ID(),
Timestamp: time.Now(),
})
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: card.Name,
PlayerID: p.ID(),
SourceType: shared.SourceTypeEffectAdded,
Behaviors: []shared.CardBehavior{behavior},
})
// Subscribe passive effects to relevant events
baseaction.SubscribePassiveEffectToEvents(ctx, g, p, effect, log, a.CardRegistry())
}
}
// Add VP notification if card has VP conditions
if len(card.VPConditions) > 0 {
var vpForLog []shared.VPConditionForLog
for _, vp := range card.VPConditions {
vpLog := shared.VPConditionForLog{
Amount: vp.Amount,
Condition: string(vp.Condition),
}
if vp.MaxTrigger != nil {
vpLog.MaxTrigger = vp.MaxTrigger
}
if vp.Per != nil {
vpLog.Per = &shared.PerCondition{
ResourceType: vp.Per.ResourceType,
Amount: vp.Per.Amount,
}
if vp.Per.Location != nil {
loc := string(*vp.Per.Location)
vpLog.Per.Location = &loc
}
if vp.Per.Target != nil {
target := string(*vp.Per.Target)
vpLog.Per.Target = &target
}
if vp.Per.Tag != nil {
vpLog.Per.Tag = vp.Per.Tag
}
}
vpForLog = append(vpForLog, vpLog)
}
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: card.Name,
PlayerID: p.ID(),
SourceType: shared.SourceTypeCardPlay,
VPConditions: vpForLog,
})
}
log.Debug("Card behaviors processed")
return allCalculatedOutputs, nil
}
// createPendingCardDiscard creates a PendingCardDiscardSelection for behaviors with card-discard inputs.
// The player must select cards to discard before outputs are applied.
func (a *PlayCardAction) createPendingCardDiscard(
p *player.Player,
card *gamecards.Card,
inputs []shared.BehaviorCondition,
outputs []shared.BehaviorCondition,
log *zap.Logger,
) {
minCards := 0
maxCards := 0
isOptional := false
for _, input := range inputs {
if input.GetResourceType() == shared.ResourceCardDiscard {
maxCards += input.GetAmount()
if !shared.IsOptional(input) {
minCards += input.GetAmount()
} else {
isOptional = true
}
}
}
// If optional but player has no cards in hand, skip entirely
if isOptional && len(p.Hand().Cards()) == 0 {
log.Debug("Skipping card discard: optional and player has no cards in hand")
return
}
selection := &shared.PendingCardDiscardSelection{
MinCards: minCards,
MaxCards: maxCards,
Source: card.Name,
SourceCardID: card.ID,
PendingOutputs: outputs,
}
p.Selection().SetPendingCardDiscardSelection(selection)
log.Debug("Created pending card discard selection",
zap.String("card_name", card.Name),
zap.Int("min_cards", minCards),
zap.Int("max_cards", maxCards),
zap.Bool("optional", isOptional),
zap.Int("pending_outputs", len(outputs)))
}
// createPendingCardDiscardFromOutputs creates a PendingCardDiscardSelection for behaviors with card-discard outputs.
// The player must select cards to discard before remaining outputs (draws, etc.) are applied.
func (a *PlayCardAction) createPendingCardDiscardFromOutputs(
p *player.Player,
card *gamecards.Card,
outputs []shared.BehaviorCondition,
log *zap.Logger,
) {
minCards := 0
maxCards := 0
var pendingOutputs []shared.BehaviorCondition
for _, output := range outputs {
if output.GetResourceType() == shared.ResourceCardDiscard {
minCards += output.GetAmount()
maxCards += output.GetAmount()
} else {
pendingOutputs = append(pendingOutputs, output)
}
}
selection := &shared.PendingCardDiscardSelection{
MinCards: minCards,
MaxCards: maxCards,
Source: card.Name,
SourceCardID: card.ID,
PendingOutputs: pendingOutputs,
}
p.Selection().SetPendingCardDiscardSelection(selection)
log.Debug("Created pending card discard selection from outputs",
zap.String("card_name", card.Name),
zap.Int("min_cards", minCards),
zap.Int("max_cards", maxCards),
zap.Int("pending_outputs", len(pendingOutputs)))
}
func adjustPaymentToEffectiveCost(
payment PaymentRequest,
effectiveCost int,
allowSteel bool,
allowTitanium bool,
playerSubstitutes []shared.PaymentSubstitute,
storageSubstitutes []shared.StoragePaymentSubstitute,
p *player.Player,
) PaymentRequest {
if effectiveCost <= 0 {
return PaymentRequest{}
}
steelRate := 2
titaniumRate := 3
for _, sub := range playerSubstitutes {
if sub.ResourceType == shared.ResourceSteel {
steelRate = sub.ConversionRate
}
if sub.ResourceType == shared.ResourceTitanium {
titaniumRate = sub.ConversionRate
}
}
nonCreditValue := 0
if allowSteel {
nonCreditValue += payment.Steel * steelRate
}
if allowTitanium {
nonCreditValue += payment.Titanium * titaniumRate
}
for resourceType, amount := range payment.Substitutes {
for _, sub := range playerSubstitutes {
if sub.ResourceType == resourceType {
nonCreditValue += amount * sub.ConversionRate
break
}
}
}
// Clamp storage substitute amounts to what's actually available on the card
clampedStorageSubs := make(map[string]int)
for cardID, amount := range payment.StorageSubstitutes {
available := p.Resources().GetCardStorage(cardID)
clamped := amount
if clamped > available {
clamped = available
}
if clamped > 0 {
clampedStorageSubs[cardID] = clamped
}
}
// Add storage substitute values
storageSubValues := make(map[string]int)
for _, sub := range storageSubstitutes {
storageSubValues[sub.CardID] = sub.ConversionRate
}
for cardID, amount := range clampedStorageSubs {
if rate, ok := storageSubValues[cardID]; ok {
nonCreditValue += amount * rate
}
}
if nonCreditValue >= effectiveCost {
return PaymentRequest{
Credits: 0,
Steel: payment.Steel,
Titanium: payment.Titanium,
Substitutes: payment.Substitutes,
StorageSubstitutes: clampedStorageSubs,
}
}
creditsNeeded := effectiveCost - nonCreditValue
if creditsNeeded > payment.Credits {
creditsNeeded = payment.Credits
}
return PaymentRequest{
Credits: creditsNeeded,
Steel: payment.Steel,
Titanium: payment.Titanium,
Substitutes: payment.Substitutes,
StorageSubstitutes: clampedStorageSubs,
}
}
// collectTemporaryEffectCardIDs returns the card IDs of all effects with the given temporary type.
func collectTemporaryEffectCardIDs(p *player.Player, temporaryType string) []string {
var cardIDs []string
for _, effect := range p.Effects().List() {
for _, output := range effect.Behavior.Outputs {
if shared.GetTemporary(output) == temporaryType {
cardIDs = append(cardIDs, effect.CardID)
break
}
}
}
return cardIDs
}
// removePrePlayTemporaryEffects removes temporary effects by their card IDs (collected before card play).
func removePrePlayTemporaryEffects(p *player.Player, cardIDs []string, log *zap.Logger) {
for _, cardID := range cardIDs {
p.Effects().RemoveEffectsByCardID(cardID)
log.Debug("Removed temporary next-card effect",
zap.String("effect_card_id", cardID))
}
}
// validateChoiceRequirements checks if a choice's requirements are met by the player.
func validateChoiceRequirements(reqs *shared.ChoiceRequirements, p *player.Player, g *game.Game, cardRegistry cards.CardRegistry) error {
if reqs == nil || len(reqs.Items) == 0 {
return nil
}
for _, req := range reqs.Items {
if err := checkChoiceRequirement(req, p, g, cardRegistry); err != nil {
return err
}
}
return nil
}
// checkChoiceRequirement validates a single choice requirement.
func checkChoiceRequirement(req shared.ChoiceRequirement, p *player.Player, g *game.Game, cardRegistry cards.CardRegistry) error {
switch req.Type {
case "tags":
if req.Tag == nil {
return fmt.Errorf("tag requirement missing tag specification")
}
tagCount := gamecards.CountPlayerTagsByType(p, cardRegistry, *req.Tag)
if req.Min != nil && tagCount < *req.Min {
return fmt.Errorf("need %d %s tags, have %d", *req.Min, *req.Tag, tagCount)
}
if req.Max != nil && tagCount > *req.Max {
return fmt.Errorf("max %d %s tags, have %d", *req.Max, *req.Tag, tagCount)
}
case "temperature":
temp := g.GlobalParameters().Temperature()
if req.Min != nil && temp < *req.Min {
return fmt.Errorf("temperature too low: need %d, have %d", *req.Min, temp)
}
if req.Max != nil && temp > *req.Max {
return fmt.Errorf("temperature too high: max %d, have %d", *req.Max, temp)
}
case "oxygen":
oxygen := g.GlobalParameters().Oxygen()
if req.Min != nil && oxygen < *req.Min {
return fmt.Errorf("oxygen too low: need %d, have %d", *req.Min, oxygen)
}
if req.Max != nil && oxygen > *req.Max {
return fmt.Errorf("oxygen too high: max %d, have %d", *req.Max, oxygen)
}
case "ocean":
oceans := g.GlobalParameters().Oceans()
if req.Min != nil && oceans < *req.Min {
return fmt.Errorf("too few oceans: need %d, have %d", *req.Min, oceans)
}
if req.Max != nil && oceans > *req.Max {
return fmt.Errorf("too many oceans: max %d, have %d", *req.Max, oceans)
}
case "venus":
venus := g.GlobalParameters().Venus()
if req.Min != nil && venus < *req.Min {
return fmt.Errorf("venus too low: need %d, have %d", *req.Min, venus)
}
if req.Max != nil && venus > *req.Max {
return fmt.Errorf("venus too high: max %d, have %d", *req.Max, venus)
}
case "tr":
tr := p.Resources().TerraformRating()
if req.Min != nil && tr < *req.Min {
return fmt.Errorf("TR too low: need %d, have %d", *req.Min, tr)
}
if req.Max != nil && tr > *req.Max {
return fmt.Errorf("TR too high: max %d, have %d", *req.Max, tr)
}
case "production":
if req.Resource == nil {
return fmt.Errorf("production requirement missing resource type")
}
production := p.Resources().Production()
amount := production.GetAmount(*req.Resource)
if req.Min != nil && amount < *req.Min {
return fmt.Errorf("%s production too low: need %d, have %d", *req.Resource, *req.Min, amount)
}
case "resource":
if req.Resource == nil {
return fmt.Errorf("resource requirement missing resource type")
}
resources := p.Resources().Get()
amount := resources.GetAmount(*req.Resource)
if req.Min != nil && amount < *req.Min {
return fmt.Errorf("%s too low: need %d, have %d", *req.Resource, *req.Min, amount)
}
}
return nil
}
// validateProductionOutputs checks that playing the card won't bring production below the minimum.
func validateProductionOutputs(card *gamecards.Card, p *player.Player) error {
if len(card.Behaviors) == 0 {
return nil
}
production := p.Resources().Production()
for _, behavior := range card.Behaviors {
if !gamecards.HasAutoTrigger(behavior) {
continue
}
for _, outputBC := range behavior.Outputs {
if shared.IsVariableAmount(outputBC) || outputBC.GetAmount() >= 0 {
continue
}
if outputBC.GetTarget() != "self-player" {
continue
}
var current, minProd int
switch outputBC.GetResourceType() {
case shared.ResourceCreditProduction:
current, minProd = production.Credits, shared.MinCreditProduction
case shared.ResourceSteelProduction:
current, minProd = production.Steel, shared.MinOtherProduction
case shared.ResourceTitaniumProduction:
current, minProd = production.Titanium, shared.MinOtherProduction
case shared.ResourcePlantProduction:
current, minProd = production.Plants, shared.MinOtherProduction
case shared.ResourceEnergyProduction:
current, minProd = production.Energy, shared.MinOtherProduction
case shared.ResourceHeatProduction:
current, minProd = production.Heat, shared.MinOtherProduction
default:
continue
}
if current+outputBC.GetAmount() < minProd {
return fmt.Errorf("insufficient %s: have %d, need at least %d to decrease by %d", outputBC.GetResourceType(), current, -outputBC.GetAmount(), -outputBC.GetAmount())
}
}
}
return nil
}
func validateNegativeResourceOutputs(card *gamecards.Card, p *player.Player) error {
if len(card.Behaviors) == 0 {
return nil
}
resources := p.Resources().Get()
for _, behavior := range card.Behaviors {
if !gamecards.HasAutoTrigger(behavior) {
continue
}
for _, outputBC := range behavior.Outputs {
if shared.IsVariableAmount(outputBC) || outputBC.GetAmount() >= 0 {
continue
}
if outputBC.GetTarget() != "self-player" {
continue
}
var available int
switch outputBC.GetResourceType() {
case shared.ResourceCredit:
available = resources.Credits
case shared.ResourceSteel:
available = resources.Steel
case shared.ResourceTitanium:
available = resources.Titanium
case shared.ResourcePlant:
available = resources.Plants
case shared.ResourceEnergy:
available = resources.Energy
case shared.ResourceHeat:
available = resources.Heat
default:
continue
}
required := -outputBC.GetAmount()
if available < required {
return fmt.Errorf("not enough %s: have %d, need %d", outputBC.GetResourceType(), available, required)
}
}
}
return nil
}
func resolveChoicePolicyCount(policy *shared.ChoicePolicy, p *player.Player, registry gamecards.CardRegistryInterface) int {
if policy == nil || policy.Select == nil {
return 0
}
sel := policy.Select
if sel.ResourceType == "tag" && sel.Tag != nil {
return gamecards.CountPlayerTagsByType(p, registry, *sel.Tag)
}
return 0
}
package card
import (
"context"
"fmt"
baseaction "terraforming-mars-backend/internal/action"
"go.uber.org/zap"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/game"
gamecards "terraforming-mars-backend/internal/game/cards"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
)
// UseCardActionAction handles the business logic for using a card's manual action
// Card actions are repeatable blue card abilities with inputs and outputs
type UseCardActionAction struct {
baseaction.BaseAction
}
// NewUseCardActionAction creates a new use card action action
func NewUseCardActionAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
stateRepo game.GameStateRepository,
logger *zap.Logger,
) *UseCardActionAction {
return &UseCardActionAction{
BaseAction: baseaction.NewBaseActionWithStateRepo(gameRepo, cardRegistry, stateRepo),
}
}
// Execute performs the use card action
func (a *UseCardActionAction) Execute(
ctx context.Context,
gameID string,
playerID string,
cardID string,
behaviorIndex int,
choiceIndex *int,
cardStorageTargets []string,
targetPlayerID *string,
stealSourceCardID *string,
selectedAmount *int,
actionPayment *gamecards.CardPayment,
reuseSourceCardID *string,
) error {
log := a.InitLogger(gameID, playerID).With(
zap.String("card_id", cardID),
zap.Int("behavior_index", behaviorIndex),
zap.String("action", "use_card_action"),
)
if choiceIndex != nil {
log = log.With(zap.Int("choice_index", *choiceIndex))
}
if len(cardStorageTargets) > 0 {
log = log.With(zap.Strings("card_storage_targets", cardStorageTargets))
}
if targetPlayerID != nil {
log = log.With(zap.String("target_player_id", *targetPlayerID))
}
if stealSourceCardID != nil {
log = log.With(zap.String("source_card_for_input", *stealSourceCardID))
}
log.Debug("Player attempting to use card action")
g, err := baseaction.ValidateActiveGame(ctx, a.GameRepository(), gameID, log)
if err != nil {
return err
}
if err := baseaction.ValidateGamePhase(g, shared.GamePhaseAction, log); err != nil {
return err
}
if err := baseaction.ValidateCurrentTurn(g, playerID, log); err != nil {
return err
}
if err := baseaction.ValidateActionsRemaining(g, playerID, log); err != nil {
return err
}
if err := baseaction.ValidateNoPendingSelections(g, playerID, log); err != nil {
return err
}
p, err := a.GetPlayerFromGame(g, playerID, log)
if err != nil {
return err
}
cardAction, err := a.findCardAction(p, cardID, behaviorIndex, log)
if err != nil {
return err
}
if reuseSourceCardID != nil {
return a.executeReuse(ctx, g, p, cardAction, cardID, behaviorIndex, choiceIndex, cardStorageTargets, targetPlayerID, stealSourceCardID, selectedAmount, actionPayment, *reuseSourceCardID, log)
}
if a.hasManualTrigger(cardAction.Behavior) && cardAction.TimesUsedThisGeneration >= 1 {
log.Warn("Action already played this generation",
zap.Int("times_used", cardAction.TimesUsedThisGeneration))
return fmt.Errorf("action already played this generation")
}
log.Debug("Found card action",
zap.String("card_name", cardAction.CardName),
zap.Int("times_used_this_generation", cardAction.TimesUsedThisGeneration))
if choiceIndex != nil && cardAction.Behavior.ChoicePolicy != nil {
production := p.Resources().Production()
if !shared.IsChoiceValidForPolicy(*choiceIndex, cardAction.Behavior.Choices, cardAction.Behavior.ChoicePolicy, production) {
log.Warn("Choice rejected by policy",
zap.String("policy_type", string(cardAction.Behavior.ChoicePolicy.Type)),
zap.Int("choice_index", *choiceIndex))
return fmt.Errorf("choice not valid: policy %q restricts available options", cardAction.Behavior.ChoicePolicy.Type)
}
}
applier := gamecards.NewBehaviorApplier(p, g, cardAction.CardName, log).
WithSourceCardID(cardID).
WithSourceBehaviorIndex(behaviorIndex).
WithCardRegistry(a.CardRegistry()).
WithSourceType(shared.SourceTypeCardAction)
if len(cardStorageTargets) > 0 {
applier = applier.WithTargetCardIDs(cardStorageTargets)
}
if targetPlayerID != nil {
applier = applier.WithTargetPlayerID(*targetPlayerID)
}
if stealSourceCardID != nil {
applier = applier.WithStealSourceCardID(*stealSourceCardID)
}
if selectedAmount != nil {
applier = applier.WithSelectedAmount(*selectedAmount)
}
if actionPayment != nil {
applier = applier.WithActionPayment(actionPayment)
}
inputs, outputs := cardAction.Behavior.ExtractInputsOutputs(choiceIndex)
if choiceIndex != nil {
log.Debug("Using choice-specific behavior",
zap.Int("choice_index", *choiceIndex),
zap.Int("input_count", len(inputs)),
zap.Int("output_count", len(outputs)))
}
if hasVariableAmount(inputs, outputs) && selectedAmount == nil {
log.Warn("Variable-amount action requires selectedAmount")
return fmt.Errorf("must select an amount for this action")
}
if hasStealFromAnyCard(outputs) && stealSourceCardID == nil {
log.Warn("Steal action requires a target card")
return fmt.Errorf("steal action requires a target card; select a card or cancel")
}
if err := validateOutputAffordability(p, outputs); err != nil {
log.Warn("Cannot afford negative resource outputs", zap.Error(err))
return err
}
if err := applier.ApplyInputs(ctx, inputs); err != nil {
log.Error("Failed to apply inputs", zap.Error(err))
return err
}
// Check for card draw outputs (card-peek/take/buy) - these create pending selection
hasPending, err := applier.ApplyCardDrawOutputs(ctx, outputs)
if err != nil {
log.Error("Failed to apply card draw outputs", zap.Error(err))
return err
}
if hasPending {
// Pending selection created - action completion deferred to confirmation
// Don't increment usage counts or consume action here - that happens in ConfirmCardDraw
log.Debug("Card draw selection pending, awaiting player choice")
return nil
}
calculatedOutputs, err := applier.ApplyOutputsAndGetCalculated(ctx, outputs)
if err != nil {
log.Error("Failed to apply outputs", zap.Error(err))
return err
}
a.incrementUsageCounts(p, cardID, behaviorIndex, log)
a.ConsumePlayerAction(g, log)
description := fmt.Sprintf("Used %s action", cardAction.CardName)
var displayData *game.LogDisplayData
if cardFromRegistry, err := a.CardRegistry().GetByID(cardID); err == nil {
displayData = baseaction.BuildCardDisplayData(cardFromRegistry, shared.SourceTypeCardAction)
}
a.WriteStateLogFull(ctx, g, cardAction.CardName, shared.SourceTypeCardAction, playerID, description, choiceIndex, calculatedOutputs, displayData)
log.Info("Card action executed")
return nil
}
// findCardAction finds a card action in the player's available actions
func (a *UseCardActionAction) findCardAction(
p *player.Player,
cardID string,
behaviorIndex int,
log *zap.Logger,
) (*shared.CardAction, error) {
actions := p.Actions().List()
for i := range actions {
if actions[i].CardID == cardID && actions[i].BehaviorIndex == behaviorIndex {
return &actions[i], nil
}
}
log.Error("Card action not found in player's available actions",
zap.String("card_id", cardID),
zap.Int("behavior_index", behaviorIndex))
return nil, fmt.Errorf("card action not found: %s[%d]", cardID, behaviorIndex)
}
// incrementUsageCounts increments the usage counts for a card action
func (a *UseCardActionAction) incrementUsageCounts(
p *player.Player,
cardID string,
behaviorIndex int,
log *zap.Logger,
) {
actions := p.Actions().List()
// Find and increment both turn and generation counts
for i := range actions {
if actions[i].CardID == cardID && actions[i].BehaviorIndex == behaviorIndex {
actions[i].TimesUsedThisTurn++
actions[i].TimesUsedThisGeneration++
log.Debug("Incremented action usage counts",
zap.Int("times_used_this_turn", actions[i].TimesUsedThisTurn),
zap.Int("times_used_this_generation", actions[i].TimesUsedThisGeneration))
break
}
}
// Update player actions
p.Actions().SetActions(actions)
}
func (a *UseCardActionAction) executeReuse(
ctx context.Context,
g *game.Game,
p *player.Player,
targetAction *shared.CardAction,
targetCardID string,
targetBehaviorIndex int,
choiceIndex *int,
cardStorageTargets []string,
targetPlayerID *string,
stealSourceCardID *string,
selectedAmount *int,
actionPayment *gamecards.CardPayment,
reuseSourceCardID string,
log *zap.Logger,
) error {
log = log.With(zap.String("reuse_source_card_id", reuseSourceCardID))
log.Debug("Executing action reuse")
reuseAction, err := a.findActionReuseAction(p, reuseSourceCardID, log)
if err != nil {
return err
}
if reuseAction.TimesUsedThisGeneration >= 1 {
log.Warn("Reuse action already played this generation")
return fmt.Errorf("reuse action already played this generation")
}
if targetCardID == reuseSourceCardID {
log.Warn("Cannot reuse own action-reuse ability")
return fmt.Errorf("cannot reuse own action-reuse ability")
}
if !a.hasManualTrigger(targetAction.Behavior) {
log.Warn("Target action is not a manual action")
return fmt.Errorf("target action is not a manual action")
}
if targetAction.TimesUsedThisGeneration < 1 {
log.Warn("Target action has not been used this generation")
return fmt.Errorf("target action has not been used this generation")
}
if choiceIndex != nil && targetAction.Behavior.ChoicePolicy != nil {
production := p.Resources().Production()
if !shared.IsChoiceValidForPolicy(*choiceIndex, targetAction.Behavior.Choices, targetAction.Behavior.ChoicePolicy, production) {
log.Warn("Choice rejected by policy",
zap.String("policy_type", string(targetAction.Behavior.ChoicePolicy.Type)),
zap.Int("choice_index", *choiceIndex))
return fmt.Errorf("choice not valid: policy %q restricts available options", targetAction.Behavior.ChoicePolicy.Type)
}
}
applier := gamecards.NewBehaviorApplier(p, g, targetAction.CardName, log).
WithSourceCardID(targetCardID).
WithSourceBehaviorIndex(targetBehaviorIndex).
WithCardRegistry(a.CardRegistry()).
WithSourceType(shared.SourceTypeCardAction)
if len(cardStorageTargets) > 0 {
applier = applier.WithTargetCardIDs(cardStorageTargets)
}
if targetPlayerID != nil {
applier = applier.WithTargetPlayerID(*targetPlayerID)
}
if stealSourceCardID != nil {
applier = applier.WithStealSourceCardID(*stealSourceCardID)
}
if selectedAmount != nil {
applier = applier.WithSelectedAmount(*selectedAmount)
}
if actionPayment != nil {
applier = applier.WithActionPayment(actionPayment)
}
inputs, outputs := targetAction.Behavior.ExtractInputsOutputs(choiceIndex)
if hasVariableAmount(inputs, outputs) && selectedAmount == nil {
log.Warn("Variable-amount action requires selectedAmount")
return fmt.Errorf("must select an amount for this action")
}
if hasStealFromAnyCard(outputs) && stealSourceCardID == nil {
log.Warn("Reuse steal action requires a target card")
return fmt.Errorf("steal action requires a target card; select a card or cancel")
}
if err := validateOutputAffordability(p, outputs); err != nil {
log.Warn("Cannot afford negative resource outputs", zap.Error(err))
return err
}
if err := applier.ApplyInputs(ctx, inputs); err != nil {
log.Error("Failed to apply inputs for reused action", zap.Error(err))
return err
}
hasPending, err := applier.ApplyCardDrawOutputs(ctx, outputs)
if err != nil {
log.Error("Failed to apply card draw outputs for reused action", zap.Error(err))
return err
}
if hasPending {
log.Debug("Card draw selection pending for reused action")
return nil
}
calculatedOutputs, err := applier.ApplyOutputsAndGetCalculated(ctx, outputs)
if err != nil {
log.Error("Failed to apply outputs for reused action", zap.Error(err))
return err
}
a.incrementUsageCounts(p, reuseSourceCardID, reuseAction.BehaviorIndex, log)
a.ConsumePlayerAction(g, log)
description := fmt.Sprintf("Used %s to reuse %s action", reuseAction.CardName, targetAction.CardName)
var displayData *game.LogDisplayData
if cardFromRegistry, err := a.CardRegistry().GetByID(targetCardID); err == nil {
displayData = baseaction.BuildCardDisplayData(cardFromRegistry, shared.SourceTypeCardAction)
}
a.WriteStateLogFull(ctx, g, targetAction.CardName, shared.SourceTypeCardAction, p.ID(), description, choiceIndex, calculatedOutputs, displayData)
log.Info("Action reused",
zap.String("reuse_source", reuseAction.CardName),
zap.String("target_action", targetAction.CardName))
return nil
}
func (a *UseCardActionAction) findActionReuseAction(
p *player.Player,
reuseSourceCardID string,
log *zap.Logger,
) (*shared.CardAction, error) {
actions := p.Actions().List()
for i := range actions {
if actions[i].CardID != reuseSourceCardID {
continue
}
for _, output := range actions[i].Behavior.Outputs {
if output.GetResourceType() == shared.ResourceActionReuse {
return &actions[i], nil
}
}
}
log.Error("Action-reuse action not found", zap.String("card_id", reuseSourceCardID))
return nil, fmt.Errorf("action-reuse action not found on card %s", reuseSourceCardID)
}
func (a *UseCardActionAction) hasManualTrigger(behavior shared.CardBehavior) bool {
for _, trigger := range behavior.Triggers {
if trigger.Type == shared.TriggerTypeManual {
return true
}
}
return false
}
func hasVariableAmount(inputs, outputs []shared.BehaviorCondition) bool {
for _, input := range inputs {
if shared.IsVariableAmount(input) {
return true
}
}
for _, output := range outputs {
if shared.IsVariableAmount(output) {
return true
}
}
return false
}
func hasStealFromAnyCard(outputs []shared.BehaviorCondition) bool {
for _, output := range outputs {
if output.GetTarget() == "steal-from-any-card" {
return true
}
}
return false
}
// validateOutputAffordability checks that the player can afford all negative resource outputs
// before they are applied. This is a defense-in-depth check; card costs should be modeled as
// inputs, but this catches any negative resource outputs that slip through.
func validateOutputAffordability(p *player.Player, outputs []shared.BehaviorCondition) error {
resources := p.Resources().Get()
for _, output := range outputs {
if shared.IsVariableAmount(output) || output.GetAmount() >= 0 {
continue
}
var available int
switch output.GetResourceType() {
case shared.ResourceCredit:
available = resources.Credits
case shared.ResourceSteel:
available = resources.Steel
case shared.ResourceTitanium:
available = resources.Titanium
case shared.ResourcePlant:
available = resources.Plants
case shared.ResourceEnergy:
available = resources.Energy
case shared.ResourceHeat:
available = resources.Heat
default:
continue
}
if available < -output.GetAmount() {
return fmt.Errorf("insufficient %s: need %d, have %d", output.GetResourceType(), -output.GetAmount(), available)
}
}
return nil
}
package colony
import (
"context"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
"go.uber.org/zap"
)
// PendingResource represents a card-targeted resource that needs player selection
type PendingResource struct {
PlayerID string
ResourceType string
Amount int
}
// ApplyTradeOutput applies a colony output to a player's resources or production.
func ApplyTradeOutput(ctx context.Context, g *game.Game, p *player.Player, outputType string, amount int, cardRegistry cards.CardRegistry, log *zap.Logger) *PendingResource {
return applyOutput(ctx, g, p, outputType, amount, cardRegistry, log)
}
// applyOutput applies a colony output to a player's resources or production.
// Returns a PendingResource if the output is a card-targeted resource (microbe/animal/floater)
// that requires the player to choose which card to place it on.
func applyOutput(ctx context.Context, g *game.Game, p *player.Player, outputType string, amount int, cardRegistry cards.CardRegistry, log *zap.Logger) *PendingResource {
rt := shared.ResourceType(outputType)
switch rt {
case shared.ResourceCredit, shared.ResourceSteel, shared.ResourceTitanium,
shared.ResourcePlant, shared.ResourceEnergy, shared.ResourceHeat:
p.Resources().Add(map[shared.ResourceType]int{rt: amount})
case shared.ResourceCreditProduction, shared.ResourceSteelProduction,
shared.ResourceTitaniumProduction, shared.ResourcePlantProduction,
shared.ResourceEnergyProduction, shared.ResourceHeatProduction:
p.Resources().AddProduction(map[shared.ResourceType]int{rt: amount})
case shared.ResourceCardDraw:
deck := g.Deck()
if deck != nil {
cardIDs, err := deck.DrawProjectCards(ctx, amount)
if err != nil {
log.Warn("Failed to draw cards for colony output", zap.Error(err))
return nil
}
for _, cardID := range cardIDs {
p.Hand().AddCard(cardID)
}
log.Debug("Drew cards for colony output",
zap.String("player_id", p.ID()),
zap.Int("count", len(cardIDs)))
}
case shared.ResourceOceanPlacement:
items := make([]string, amount)
for i := range items {
items[i] = "ocean"
}
queue := &shared.PendingTileSelectionQueue{
Items: items,
Source: "colony-build",
}
if err := g.SetPendingTileSelectionQueue(ctx, p.ID(), queue); err != nil {
log.Warn("Failed to queue ocean placement for colony output", zap.Error(err))
}
case shared.ResourceMicrobe, shared.ResourceAnimal, shared.ResourceFloater:
return &PendingResource{
PlayerID: p.ID(),
ResourceType: outputType,
Amount: amount,
}
}
return nil
}
// combinePendingResources merges pending resources of the same type by summing amounts.
func combinePendingResources(pendings []*PendingResource) []*PendingResource {
byType := map[string]*PendingResource{}
var order []string
for _, p := range pendings {
if existing, ok := byType[p.ResourceType]; ok {
existing.Amount += p.Amount
} else {
byType[p.ResourceType] = &PendingResource{
PlayerID: p.PlayerID,
ResourceType: p.ResourceType,
Amount: p.Amount,
}
order = append(order, p.ResourceType)
}
}
result := make([]*PendingResource, 0, len(byType))
for _, rt := range order {
result = append(result, byType[rt])
}
return result
}
// combineCalculatedOutputs merges calculated outputs of the same resource type by summing amounts.
func combineCalculatedOutputs(outputs []shared.CalculatedOutput) []shared.CalculatedOutput {
byType := map[string]*shared.CalculatedOutput{}
var order []string
for _, o := range outputs {
if existing, ok := byType[o.ResourceType]; ok {
existing.Amount += o.Amount
} else {
byType[o.ResourceType] = &shared.CalculatedOutput{
ResourceType: o.ResourceType,
Amount: o.Amount,
}
order = append(order, o.ResourceType)
}
}
result := make([]shared.CalculatedOutput, 0, len(byType))
for _, rt := range order {
result = append(result, *byType[rt])
}
return result
}
package colony
import (
"context"
"fmt"
"slices"
"time"
baseaction "terraforming-mars-backend/internal/action"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/colonies"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game"
gamecolony "terraforming-mars-backend/internal/game/colony"
gameplayer "terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
"go.uber.org/zap"
)
const (
BuildColonyCost = 17
)
// BuildColonyAction handles the business logic for building a colony on a colony tile
type BuildColonyAction struct {
baseaction.BaseAction
colonyRegistry colonies.ColonyRegistry
cardRegistry cards.CardRegistry
}
// NewBuildColonyAction creates a new build colony action
func NewBuildColonyAction(
gameRepo game.GameRepository,
colonyRegistry colonies.ColonyRegistry,
cardRegistry cards.CardRegistry,
stateRepo game.GameStateRepository,
logger *zap.Logger,
) *BuildColonyAction {
return &BuildColonyAction{
BaseAction: baseaction.NewBaseActionWithStateRepo(gameRepo, nil, stateRepo),
colonyRegistry: colonyRegistry,
cardRegistry: cardRegistry,
}
}
// Execute performs the build colony action
func (a *BuildColonyAction) Execute(ctx context.Context, gameID string, playerID string, colonyID string) error {
log := a.InitLogger(gameID, playerID).With(
zap.String("action", "build_colony"),
zap.String("colony_id", colonyID),
)
log.Debug("Building colony")
g, err := baseaction.ValidateActiveGame(ctx, a.GameRepository(), gameID, log)
if err != nil {
return err
}
if err := baseaction.ValidateGamePhase(g, shared.GamePhaseAction, log); err != nil {
return err
}
if err := baseaction.ValidateCurrentTurn(g, playerID, log); err != nil {
return err
}
if err := baseaction.ValidateActionsRemaining(g, playerID, log); err != nil {
return err
}
if err := baseaction.ValidateNoPendingSelections(g, playerID, log); err != nil {
return err
}
if !g.HasColonies() {
return fmt.Errorf("colonies expansion is not enabled")
}
player, err := a.GetPlayerFromGame(g, playerID, log)
if err != nil {
return err
}
tileState := g.Colonies().GetState(colonyID)
if tileState == nil {
return fmt.Errorf("colony tile not found: %s", colonyID)
}
definition, err := a.colonyRegistry.GetByID(colonyID)
if err != nil {
return fmt.Errorf("colony definition not found: %w", err)
}
maxColonies := len(definition.Colonies)
if len(tileState.PlayerColonies) >= maxColonies {
return fmt.Errorf("colony tile is full: %d/%d colonies", len(tileState.PlayerColonies), maxColonies)
}
if slices.Contains(tileState.PlayerColonies, playerID) {
return fmt.Errorf("player already has a colony on this tile")
}
resources := player.Resources().Get()
if resources.Credits < BuildColonyCost {
log.Warn("Insufficient credits for colony",
zap.Int("cost", BuildColonyCost),
zap.Int("player_credits", resources.Credits))
return fmt.Errorf("insufficient credits: need %d, have %d", BuildColonyCost, resources.Credits)
}
// Deduct cost
player.Resources().Add(map[shared.ResourceType]int{
shared.ResourceCredit: -BuildColonyCost,
})
// Place colony
slotIndex := len(tileState.PlayerColonies)
tileState.PlayerColonies = append(tileState.PlayerColonies, playerID)
if tileState.MarkerPosition < len(tileState.PlayerColonies) {
tileState.MarkerPosition = len(tileState.PlayerColonies)
}
// Apply placement reward
calculatedOutputs := []shared.CalculatedOutput{
{ResourceType: "colony", Amount: 1},
}
if slotIndex < len(definition.Colonies) {
slot := definition.Colonies[slotIndex]
for _, reward := range slot.Reward {
if reward.Amount > 0 {
pending := applyOutput(ctx, g, player, reward.Type, reward.Amount, a.cardRegistry, log)
if pending != nil {
setPendingColonyResource(player, pending, definition.Name, colonyID, "build", a.cardRegistry, log)
}
calculatedOutputs = append(calculatedOutputs, shared.CalculatedOutput{
ResourceType: reward.Type,
Amount: reward.Amount,
})
}
}
}
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: "Build Colony: " + definition.Name,
PlayerID: playerID,
SourceType: shared.SourceTypeColonyBuild,
CalculatedOutputs: calculatedOutputs,
})
a.WriteStateLogFull(ctx, g, "Build Colony: "+definition.Name, shared.SourceTypeColonyBuild,
playerID, fmt.Sprintf("Built colony on %s", definition.Name), nil, calculatedOutputs, nil)
events.Publish(g.EventBus(), events.ColonyBuiltEvent{
GameID: g.ID(),
PlayerID: playerID,
ColonyID: colonyID,
Timestamp: time.Now(),
})
a.ConsumePlayerAction(g, log)
log.Info("Colony built",
zap.String("colony_id", colonyID),
zap.Int("slot_index", slotIndex))
return nil
}
// PlaceColonyOnTile places a colony on the given tile for free (no cost deduction).
// It applies the placement reward, records the triggered effect, and publishes ColonyBuiltEvent.
// Used by card-triggered colony placements (e.g., Mining Colony, Poseidon first action).
func PlaceColonyOnTile(
ctx context.Context,
g *game.Game,
player *gameplayer.Player,
definition *gamecolony.ColonyDefinition,
tileState *gamecolony.ColonyState,
cardRegistry cards.CardRegistry,
source string,
log *zap.Logger,
) error {
slotIndex := len(tileState.PlayerColonies)
tileState.PlayerColonies = append(tileState.PlayerColonies, player.ID())
if tileState.MarkerPosition < len(tileState.PlayerColonies) {
tileState.MarkerPosition = len(tileState.PlayerColonies)
}
calculatedOutputs := []shared.CalculatedOutput{
{ResourceType: "colony", Amount: 1},
}
if slotIndex < len(definition.Colonies) {
slot := definition.Colonies[slotIndex]
for _, reward := range slot.Reward {
if reward.Amount > 0 {
pending := applyOutput(ctx, g, player, reward.Type, reward.Amount, cardRegistry, log)
if pending != nil {
setPendingColonyResource(player, pending, definition.Name, tileState.DefinitionID, "build", cardRegistry, log)
}
calculatedOutputs = append(calculatedOutputs, shared.CalculatedOutput{
ResourceType: reward.Type,
Amount: reward.Amount,
})
}
}
}
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: source + ": " + definition.Name,
PlayerID: player.ID(),
SourceType: shared.SourceTypeColonyBuild,
CalculatedOutputs: calculatedOutputs,
})
events.Publish(g.EventBus(), events.ColonyBuiltEvent{
GameID: g.ID(),
PlayerID: player.ID(),
ColonyID: tileState.DefinitionID,
Timestamp: time.Now(),
})
log.Debug("Colony placed (free)",
zap.String("colony_id", tileState.DefinitionID),
zap.String("player_id", player.ID()),
zap.Int("slot_index", slotIndex))
return nil
}
package colony
import (
"context"
"fmt"
"time"
baseaction "terraforming-mars-backend/internal/action"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/colonies"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game"
gamecards "terraforming-mars-backend/internal/game/cards"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
"go.uber.org/zap"
)
// TradePaymentType represents the resource used to pay for a colony trade
type TradePaymentType string
const (
TradePaymentCredits TradePaymentType = "credits"
TradePaymentEnergy TradePaymentType = "energy"
TradePaymentTitanium TradePaymentType = "titanium"
)
const (
TradeCreditsCost = 9
TradeEnergyCost = 3
TradeTitaniumCost = 3
)
// TradeAction handles the business logic for trading with a colony tile
type TradeAction struct {
baseaction.BaseAction
colonyRegistry colonies.ColonyRegistry
cardRegistry cards.CardRegistry
}
// NewTradeAction creates a new trade action
func NewTradeAction(
gameRepo game.GameRepository,
colonyRegistry colonies.ColonyRegistry,
cardRegistry cards.CardRegistry,
stateRepo game.GameStateRepository,
logger *zap.Logger,
) *TradeAction {
return &TradeAction{
BaseAction: baseaction.NewBaseActionWithStateRepo(gameRepo, nil, stateRepo),
colonyRegistry: colonyRegistry,
cardRegistry: cardRegistry,
}
}
// Execute performs the trade action
func (a *TradeAction) Execute(ctx context.Context, gameID string, playerID string, colonyID string, paymentType TradePaymentType) error {
log := a.InitLogger(gameID, playerID).With(
zap.String("action", "colony_trade"),
zap.String("colony_id", colonyID),
)
log.Debug("Trading with colony")
g, err := baseaction.ValidateActiveGame(ctx, a.GameRepository(), gameID, log)
if err != nil {
return err
}
if err := baseaction.ValidateGamePhase(g, shared.GamePhaseAction, log); err != nil {
return err
}
if err := baseaction.ValidateCurrentTurn(g, playerID, log); err != nil {
return err
}
if err := baseaction.ValidateActionsRemaining(g, playerID, log); err != nil {
return err
}
if err := baseaction.ValidateNoPendingSelections(g, playerID, log); err != nil {
return err
}
if !g.HasColonies() {
return fmt.Errorf("colonies expansion is not enabled")
}
traderPlayer, err := a.GetPlayerFromGame(g, playerID, log)
if err != nil {
return err
}
if !g.Colonies().GetTradeFleetAvailable(playerID) {
return fmt.Errorf("trade fleet is not available")
}
tileState := g.Colonies().GetState(colonyID)
if tileState == nil {
return fmt.Errorf("colony tile not found: %s", colonyID)
}
if tileState.TradedThisGen {
return fmt.Errorf("colony tile already traded this generation")
}
// Calculate effective trade costs with discounts (e.g., Rim Freighters)
calc := gamecards.NewRequirementModifierCalculator(a.cardRegistry)
tradeDiscounts := calc.CalculateActionDiscounts(traderPlayer, shared.ActionColonyTrade)
effectiveCreditsCost := max(TradeCreditsCost-tradeDiscounts[shared.ResourceCredit], 0)
effectiveEnergyCost := max(TradeEnergyCost-tradeDiscounts[shared.ResourceEnergy], 0)
effectiveTitaniumCost := max(TradeTitaniumCost-tradeDiscounts[shared.ResourceTitanium], 0)
resources := traderPlayer.Resources().Get()
switch paymentType {
case TradePaymentCredits:
if resources.Credits < effectiveCreditsCost {
return fmt.Errorf("insufficient credits: need %d, have %d", effectiveCreditsCost, resources.Credits)
}
case TradePaymentEnergy:
if resources.Energy < effectiveEnergyCost {
return fmt.Errorf("insufficient energy: need %d, have %d", effectiveEnergyCost, resources.Energy)
}
case TradePaymentTitanium:
if resources.Titanium < effectiveTitaniumCost {
return fmt.Errorf("insufficient titanium: need %d, have %d", effectiveTitaniumCost, resources.Titanium)
}
default:
return fmt.Errorf("invalid trade payment type: %s", paymentType)
}
definition, err := a.colonyRegistry.GetByID(colonyID)
if err != nil {
return fmt.Errorf("colony definition not found: %w", err)
}
// Deduct trade cost
switch paymentType {
case TradePaymentCredits:
traderPlayer.Resources().Add(map[shared.ResourceType]int{
shared.ResourceCredit: -effectiveCreditsCost,
})
case TradePaymentEnergy:
traderPlayer.Resources().Add(map[shared.ResourceType]int{
shared.ResourceEnergy: -effectiveEnergyCost,
})
case TradePaymentTitanium:
traderPlayer.Resources().Add(map[shared.ResourceType]int{
shared.ResourceTitanium: -effectiveTitaniumCost,
})
}
// Apply trade step bonus from cards like Trade Envoys (advance marker before calculating income)
tradeStepBonus := CountTradeStepBonus(traderPlayer, a.cardRegistry)
if tradeStepBonus > 0 {
maxStep := len(definition.Steps) - 1
newPosition := tileState.MarkerPosition + tradeStepBonus
if newPosition > maxStep {
newPosition = maxStep
}
tileState.MarkerPosition = newPosition
log.Debug("Applied trade step bonus",
zap.Int("bonus", tradeStepBonus),
zap.Int("new_marker_position", newPosition))
}
// Collect pending card-targeted resources per player, so same-type resources
// from trade income + colony bonus are combined into a single selection.
pendingByPlayer := map[string][]*PendingResource{}
outputsByPlayer := map[string][]shared.CalculatedOutput{}
// Give trade income based on marker position
if tileState.MarkerPosition >= 0 && tileState.MarkerPosition < len(definition.Steps) {
step := definition.Steps[tileState.MarkerPosition]
for _, output := range step.Outputs {
if output.Amount > 0 {
pending := applyOutput(ctx, g, traderPlayer, output.Type, output.Amount, a.cardRegistry, log)
if pending != nil {
pendingByPlayer[playerID] = append(pendingByPlayer[playerID], pending)
}
outputsByPlayer[playerID] = append(outputsByPlayer[playerID], shared.CalculatedOutput{
ResourceType: output.Type,
Amount: output.Amount,
})
}
}
}
// Give colony bonus to all players with colonies on this tile
for _, colonyOwnerID := range tileState.PlayerColonies {
colonyOwner, ownerErr := g.GetPlayer(colonyOwnerID)
if ownerErr != nil {
continue
}
for _, bonus := range definition.ColonyBonus {
if bonus.Amount > 0 {
pending := applyOutput(ctx, g, colonyOwner, bonus.Type, bonus.Amount, a.cardRegistry, log)
if pending != nil {
pendingByPlayer[colonyOwnerID] = append(pendingByPlayer[colonyOwnerID], pending)
}
outputsByPlayer[colonyOwnerID] = append(outputsByPlayer[colonyOwnerID], shared.CalculatedOutput{
ResourceType: bonus.Type,
Amount: bonus.Amount,
})
}
}
}
// Resolve pending resources — combine same-type for each player
for pid, pendings := range pendingByPlayer {
p, pErr := g.GetPlayer(pid)
if pErr != nil {
continue
}
reason := "colony-tax"
if pid == playerID {
reason = "trade"
}
for _, combined := range combinePendingResources(pendings) {
setPendingColonyResource(p, combined, definition.Name, colonyID, reason, a.cardRegistry, log)
}
}
// Add triggered effects for trader and colony bonus recipients
for pid, outputs := range outputsByPlayer {
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: "Trade: " + definition.Name,
PlayerID: pid,
SourceType: shared.SourceTypeColonyTrade,
CalculatedOutputs: combineCalculatedOutputs(outputs),
})
}
a.WriteStateLogFull(ctx, g, "Trade: "+definition.Name, shared.SourceTypeColonyTrade,
playerID, fmt.Sprintf("Traded with %s", definition.Name), nil, combineCalculatedOutputs(outputsByPlayer[playerID]), nil)
// Reset marker to position after last colony
tileState.MarkerPosition = len(tileState.PlayerColonies)
tileState.TradedThisGen = true
tileState.TraderID = playerID
g.Colonies().SetTradeFleetAvailable(playerID, false)
events.Publish(g.EventBus(), events.ColonyTradedEvent{
GameID: g.ID(),
PlayerID: playerID,
ColonyID: colonyID,
Timestamp: time.Now(),
})
a.ConsumePlayerAction(g, log)
log.Info("Colony traded",
zap.String("colony_id", colonyID),
zap.Int("marker_position", tileState.MarkerPosition))
return nil
}
// setPendingColonyResource sets a pending colony resource selection on a player
// if they have at least one card that can store the resource type.
func setPendingColonyResource(p *player.Player, pending *PendingResource, colonyName string, colonyID string, reason string, cardRegistry cards.CardRegistry, log *zap.Logger) {
if !hasEligibleStorageCard(p, pending.ResourceType, cardRegistry) {
log.Debug("No eligible storage card, resources lost",
zap.String("player_id", p.ID()),
zap.String("resource_type", pending.ResourceType),
zap.Int("amount", pending.Amount))
return
}
p.Selection().AppendPendingColonyResource(shared.PendingColonyResourceSelection{
ResourceType: pending.ResourceType,
Amount: pending.Amount,
Source: colonyName,
ColonyID: colonyID,
Reason: reason,
})
log.Debug("Set pending colony resource selection",
zap.String("player_id", p.ID()),
zap.String("resource_type", pending.ResourceType),
zap.Int("amount", pending.Amount))
}
// hasEligibleStorageCard checks if a player has any played card that can store the given resource type.
func hasEligibleStorageCard(p *player.Player, resourceType string, cardRegistry cards.CardRegistry) bool {
if cardRegistry == nil {
return false
}
rt := shared.ResourceType(resourceType)
// Check played cards
for _, cardID := range p.PlayedCards().Cards() {
card, err := cardRegistry.GetByID(cardID)
if err != nil {
continue
}
if card.ResourceStorage != nil && card.ResourceStorage.Type == rt {
return true
}
}
// Check corporation
if corpID := p.CorporationID(); corpID != "" {
corp, err := cardRegistry.GetByID(corpID)
if err == nil {
if corp.ResourceStorage != nil && corp.ResourceStorage.Type == rt {
return true
}
}
}
return false
}
// SetPendingColonyResourceFromTrade handles pending card-targeted resources from trade/colony operations.
func SetPendingColonyResourceFromTrade(p *player.Player, pendings []*PendingResource, colonyName string, colonyID string, reason string, cardRegistry cards.CardRegistry, log *zap.Logger) {
for _, combined := range combinePendingResources(pendings) {
setPendingColonyResource(p, combined, colonyName, colonyID, reason, cardRegistry, log)
}
}
// CountTradeStepBonus counts how many colony track step bonuses a player has from
// played cards with "before-colony-trade" condition triggers (e.g., Trade Envoys, Trading Colony).
func CountTradeStepBonus(p *player.Player, cardRegistry cards.CardRegistry) int {
if cardRegistry == nil {
return 0
}
bonus := 0
for _, cardID := range p.PlayedCards().Cards() {
card, err := cardRegistry.GetByID(cardID)
if err != nil {
continue
}
bonus += countTradeStepBonusFromBehaviors(card.Behaviors)
}
if corpID := p.CorporationID(); corpID != "" {
corp, err := cardRegistry.GetByID(corpID)
if err == nil {
bonus += countTradeStepBonusFromBehaviors(corp.Behaviors)
}
}
return bonus
}
func countTradeStepBonusFromBehaviors(behaviors []shared.CardBehavior) int {
bonus := 0
for _, behavior := range behaviors {
for _, trigger := range behavior.Triggers {
if trigger.Type == shared.TriggerTypeAuto &&
trigger.Condition != nil &&
trigger.Condition.Type == "before-colony-trade" {
for _, output := range behavior.Outputs {
if output.GetResourceType() == "colony-track-step" {
bonus += output.GetAmount()
}
}
}
}
}
return bonus
}
package confirmation
import (
"context"
"fmt"
"slices"
"go.uber.org/zap"
baseaction "terraforming-mars-backend/internal/action"
"terraforming-mars-backend/internal/awards"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/shared"
)
// ConfirmAwardFundAction handles confirming a free award fund selection (e.g., Vitor)
type ConfirmAwardFundAction struct {
baseaction.BaseAction
awardRegistry awards.AwardRegistry
}
// NewConfirmAwardFundAction creates a new confirm award fund action
func NewConfirmAwardFundAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
awardRegistry awards.AwardRegistry,
logger *zap.Logger,
) *ConfirmAwardFundAction {
return &ConfirmAwardFundAction{
BaseAction: baseaction.NewBaseAction(gameRepo, cardRegistry),
awardRegistry: awardRegistry,
}
}
// Execute funds the selected award for free and clears the pending selection
func (a *ConfirmAwardFundAction) Execute(ctx context.Context, gameID string, playerID string, awardType string) error {
log := a.InitLogger(gameID, playerID).With(
zap.String("action", "confirm_award_fund"),
zap.String("award_type", awardType),
)
log.Debug("Confirming award fund selection")
g, err := baseaction.ValidateActiveGame(ctx, a.GameRepository(), gameID, log)
if err != nil {
return err
}
p, err := a.GetPlayerFromGame(g, playerID, log)
if err != nil {
return err
}
pending := p.Selection().GetPendingAwardFundSelection()
if pending == nil {
return fmt.Errorf("no pending award fund selection")
}
if !slices.Contains(pending.AvailableAwards, awardType) {
return fmt.Errorf("award %s is not available for selection", awardType)
}
def, err := a.awardRegistry.GetByID(awardType)
if err != nil {
return fmt.Errorf("invalid award type: %s", awardType)
}
if err := g.Awards().FundAward(ctx, shared.AwardType(awardType), playerID, 0); err != nil {
return fmt.Errorf("failed to fund award: %w", err)
}
p.Selection().SetPendingAwardFundSelection(nil)
if err := g.SetForcedFirstAction(ctx, playerID, nil); err != nil {
return fmt.Errorf("failed to clear forced first action: %w", err)
}
log.Info("Award funded for free",
zap.String("award", def.Name))
return nil
}
package confirmation
import (
"context"
"fmt"
baseaction "terraforming-mars-backend/internal/action"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/game"
gamecards "terraforming-mars-backend/internal/game/cards"
"terraforming-mars-backend/internal/game/shared"
"go.uber.org/zap"
)
// ConfirmBehaviorChoiceAction handles the business logic for confirming a behavior choice selection
type ConfirmBehaviorChoiceAction struct {
baseaction.BaseAction
}
// NewConfirmBehaviorChoiceAction creates a new confirm behavior choice action
func NewConfirmBehaviorChoiceAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
logger *zap.Logger,
) *ConfirmBehaviorChoiceAction {
return &ConfirmBehaviorChoiceAction{
BaseAction: baseaction.NewBaseAction(gameRepo, cardRegistry),
}
}
// Execute performs the confirm behavior choice action
func (a *ConfirmBehaviorChoiceAction) Execute(ctx context.Context, gameID string, playerID string, choiceIndex int, cardStorageTargets []string) error {
log := a.InitLogger(gameID, playerID).With(
zap.String("action", "confirm_behavior_choice"),
zap.Int("choice_index", choiceIndex),
)
log.Debug("Confirming behavior choice selection")
g, err := baseaction.ValidateActiveGame(ctx, a.GameRepository(), gameID, log)
if err != nil {
return err
}
p, err := a.GetPlayerFromGame(g, playerID, log)
if err != nil {
return err
}
selection := p.Selection().GetPendingBehaviorChoiceSelection()
if selection == nil {
log.Warn("No pending behavior choice selection found")
return fmt.Errorf("no pending behavior choice selection found")
}
if choiceIndex < 0 || choiceIndex >= len(selection.Choices) {
log.Warn("Invalid choice index",
zap.Int("choice_index", choiceIndex),
zap.Int("num_choices", len(selection.Choices)))
return fmt.Errorf("invalid choice index %d, must be 0-%d", choiceIndex, len(selection.Choices)-1)
}
selectedChoice := selection.Choices[choiceIndex]
// Validate choice requirements before applying
if choiceErrors := baseaction.CalculateChoiceErrors(selectedChoice, p, g, a.CardRegistry()); len(choiceErrors) > 0 {
log.Warn("Choice requirements not met",
zap.Int("choice_index", choiceIndex),
zap.String("error", choiceErrors[0].Message))
return fmt.Errorf("choice %d requirements not met: %s", choiceIndex, choiceErrors[0].Message)
}
applier := gamecards.NewBehaviorApplier(p, g, selection.Source, log).
WithSourceCardID(selection.SourceCardID).
WithCardRegistry(a.CardRegistry()).
WithSourceType(shared.SourceTypePassiveEffect)
if len(cardStorageTargets) > 0 {
applier = applier.WithTargetCardIDs(cardStorageTargets)
}
// Apply inputs (deduct resources)
if len(selectedChoice.Inputs) > 0 {
if err := applier.ApplyInputs(ctx, selectedChoice.Inputs); err != nil {
log.Error("Failed to apply choice inputs", zap.Error(err))
return fmt.Errorf("failed to apply choice inputs: %w", err)
}
}
// Apply outputs (add resources)
if len(selectedChoice.Outputs) > 0 {
if err := applier.ApplyOutputs(ctx, selectedChoice.Outputs); err != nil {
log.Error("Failed to apply choice outputs", zap.Error(err))
return fmt.Errorf("failed to apply choice outputs: %w", err)
}
}
// Clear the pending selection
p.Selection().SetPendingBehaviorChoiceSelection(nil)
log.Info("Behavior choice confirmation completed",
zap.String("source", selection.Source),
zap.Int("choice_selected", choiceIndex))
return nil
}
package confirmation
import (
"context"
"fmt"
"slices"
baseaction "terraforming-mars-backend/internal/action"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/game"
gamecards "terraforming-mars-backend/internal/game/cards"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
"go.uber.org/zap"
)
// ConfirmCardDiscardAction handles the business logic for confirming card discard selection
type ConfirmCardDiscardAction struct {
baseaction.BaseAction
}
// NewConfirmCardDiscardAction creates a new confirm card discard action
func NewConfirmCardDiscardAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
logger *zap.Logger,
) *ConfirmCardDiscardAction {
return &ConfirmCardDiscardAction{
BaseAction: baseaction.NewBaseAction(gameRepo, cardRegistry),
}
}
// Execute performs the confirm card discard action
// cardsToDiscard: card IDs from hand to discard (empty = skip if optional)
func (a *ConfirmCardDiscardAction) Execute(ctx context.Context, gameID string, playerID string, cardsToDiscard []string) error {
log := a.InitLogger(gameID, playerID).With(
zap.String("action", "confirm_card_discard"),
zap.Int("cards_to_discard", len(cardsToDiscard)),
)
log.Debug("Confirming card discard selection")
g, err := baseaction.ValidateActiveGame(ctx, a.GameRepository(), gameID, log)
if err != nil {
return err
}
p, err := a.GetPlayerFromGame(g, playerID, log)
if err != nil {
return err
}
selection := p.Selection().GetPendingCardDiscardSelection()
if selection == nil {
log.Warn("No pending card discard selection found")
return fmt.Errorf("no pending card discard selection found")
}
// Validate discard count
if len(cardsToDiscard) < selection.MinCards {
log.Warn("Not enough cards to discard",
zap.Int("selected", len(cardsToDiscard)),
zap.Int("min_required", selection.MinCards))
return fmt.Errorf("must discard at least %d card(s), selected %d", selection.MinCards, len(cardsToDiscard))
}
if len(cardsToDiscard) > selection.MaxCards {
log.Warn("Too many cards to discard",
zap.Int("selected", len(cardsToDiscard)),
zap.Int("max_allowed", selection.MaxCards))
return fmt.Errorf("can discard at most %d card(s), selected %d", selection.MaxCards, len(cardsToDiscard))
}
// Validate all cards are in hand
handCards := p.Hand().Cards()
for _, cardID := range cardsToDiscard {
if !slices.Contains(handCards, cardID) {
log.Warn("Card not in hand", zap.String("card_id", cardID))
return fmt.Errorf("card %s not in player's hand", cardID)
}
}
// Remove discarded cards from hand
for _, cardID := range cardsToDiscard {
p.Hand().RemoveCard(cardID)
}
if len(cardsToDiscard) > 0 {
if err := g.Deck().Discard(ctx, cardsToDiscard); err != nil {
log.Error("Failed to discard cards to discard pile", zap.Error(err))
return fmt.Errorf("failed to discard cards: %w", err)
}
log.Debug("Discarded cards from hand to discard pile",
zap.Int("count", len(cardsToDiscard)),
zap.Strings("card_ids", cardsToDiscard))
}
// Apply pending outputs if player actually discarded (or if discard was mandatory with min=0)
if len(cardsToDiscard) > 0 && len(selection.PendingOutputs) > 0 {
selfOutputs, err := a.applyPendingOutputs(ctx, g, p, selection, log)
if err != nil {
log.Error("Failed to apply pending outputs after discard", zap.Error(err))
return fmt.Errorf("failed to apply pending outputs: %w", err)
}
// Add triggered effect for self-player: discard + draws
calculatedOutputs := []shared.CalculatedOutput{
{ResourceType: string(shared.ResourceCardDiscard), Amount: len(cardsToDiscard)},
}
calculatedOutputs = append(calculatedOutputs, selfOutputs...)
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: selection.Source,
PlayerID: p.ID(),
SourceType: shared.SourceTypeCardPlay,
CalculatedOutputs: calculatedOutputs,
})
}
// Clear the pending selection
p.Selection().SetPendingCardDiscardSelection(nil)
log.Info("Card discard confirmation completed",
zap.String("source", selection.Source),
zap.Int("cards_discarded", len(cardsToDiscard)))
return nil
}
// applyPendingOutputs applies the outputs after a successful discard.
// Returns calculated outputs for the self-player (for triggered effect notifications).
func (a *ConfirmCardDiscardAction) applyPendingOutputs(
ctx context.Context,
g *game.Game,
p *player.Player,
selection *shared.PendingCardDiscardSelection,
log *zap.Logger,
) ([]shared.CalculatedOutput, error) {
var selfOutputs []shared.CalculatedOutput
for _, outputBC := range selection.PendingOutputs {
if outputBC.GetResourceType() == shared.ResourceCardDraw {
if outputBC.GetTarget() == "all-opponents" {
for _, opponent := range g.GetAllPlayers() {
if opponent.ID() == p.ID() {
continue
}
drawnCards, err := g.Deck().DrawProjectCards(ctx, outputBC.GetAmount())
if err != nil {
log.Warn("Failed to draw cards for opponent",
zap.String("opponent_id", opponent.ID()),
zap.Error(err))
continue
}
for _, cardID := range drawnCards {
opponent.Hand().AddCard(cardID)
}
log.Debug("Opponent drew cards",
zap.String("opponent_id", opponent.ID()),
zap.Int("count", len(drawnCards)))
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: selection.Source,
PlayerID: opponent.ID(),
SourceType: shared.SourceTypeCardPlay,
CalculatedOutputs: []shared.CalculatedOutput{
{ResourceType: string(shared.ResourceCardDraw), Amount: len(drawnCards)},
},
})
}
continue
}
drawnCards, err := g.Deck().DrawProjectCards(ctx, outputBC.GetAmount())
if err != nil {
return nil, fmt.Errorf("failed to draw cards: %w", err)
}
for _, cardID := range drawnCards {
p.Hand().AddCard(cardID)
}
selfOutputs = append(selfOutputs, shared.CalculatedOutput{
ResourceType: string(shared.ResourceCardDraw),
Amount: len(drawnCards),
})
log.Debug("Drew cards after discard",
zap.Int("count", len(drawnCards)))
continue
}
// For non-card-draw outputs, use the behavior applier
applier := gamecards.NewBehaviorApplier(p, g, selection.Source, log).
WithSourceCardID(selection.SourceCardID).
WithCardRegistry(a.CardRegistry()).
WithSourceType(shared.SourceTypePassiveEffect)
if err := applier.ApplyOutputs(ctx, []shared.BehaviorCondition{outputBC}); err != nil {
return nil, err
}
}
return selfOutputs, nil
}
package confirmation
import (
"context"
"fmt"
"slices"
baseaction "terraforming-mars-backend/internal/action"
"terraforming-mars-backend/internal/action/turn_management"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
"go.uber.org/zap"
)
// ConfirmCardDrawAction handles the business logic for confirming card draw selection
type ConfirmCardDrawAction struct {
baseaction.BaseAction
}
// NewConfirmCardDrawAction creates a new confirm card draw action
func NewConfirmCardDrawAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
logger *zap.Logger,
) *ConfirmCardDrawAction {
return &ConfirmCardDrawAction{
BaseAction: baseaction.NewBaseAction(gameRepo, cardRegistry),
}
}
// Execute performs the confirm card draw action
func (a *ConfirmCardDrawAction) Execute(ctx context.Context, gameID string, playerID string, cardsToTake []string, cardsToBuy []string) error {
log := a.InitLogger(gameID, playerID).With(
zap.String("action", "confirm_card_draw"),
zap.Int("cards_to_take", len(cardsToTake)),
zap.Int("cards_to_buy", len(cardsToBuy)),
)
log.Debug("Confirming card draw selection")
g, err := baseaction.ValidateActiveGame(ctx, a.GameRepository(), gameID, log)
if err != nil {
return err
}
p, err := a.GetPlayerFromGame(g, playerID, log)
if err != nil {
return err
}
selection := p.Selection().GetPendingCardDrawSelection()
if selection == nil {
log.Warn("No pending card draw selection found")
return fmt.Errorf("no pending card draw selection found")
}
totalSelected := len(cardsToTake) + len(cardsToBuy)
maxAllowed := selection.FreeTakeCount + selection.MaxBuyCount
if totalSelected > maxAllowed {
log.Warn("Too many cards selected",
zap.Int("selected", totalSelected),
zap.Int("max_allowed", maxAllowed))
return fmt.Errorf("too many cards selected: selected %d, max allowed %d", totalSelected, maxAllowed)
}
if len(cardsToTake) > selection.FreeTakeCount {
log.Warn("Too many free cards selected",
zap.Int("selected", len(cardsToTake)),
zap.Int("max", selection.FreeTakeCount))
return fmt.Errorf("too many free cards selected: selected %d, max %d", len(cardsToTake), selection.FreeTakeCount)
}
isPureCardDraw := selection.MaxBuyCount == 0 && selection.FreeTakeCount == len(selection.AvailableCards)
if isPureCardDraw && len(cardsToTake) != selection.FreeTakeCount {
log.Warn("Must take all cards for pure card-draw effect",
zap.Int("required", selection.FreeTakeCount),
zap.Int("selected", len(cardsToTake)))
return fmt.Errorf("must take all %d cards for card-draw effect", selection.FreeTakeCount)
}
if len(cardsToBuy) > selection.MaxBuyCount {
log.Warn("Too many cards to buy",
zap.Int("selected", len(cardsToBuy)),
zap.Int("max", selection.MaxBuyCount))
return fmt.Errorf("too many cards to buy: selected %d, max %d", len(cardsToBuy), selection.MaxBuyCount)
}
allSelectedCards := append(cardsToTake, cardsToBuy...)
for _, cardID := range allSelectedCards {
if !slices.Contains(selection.AvailableCards, cardID) {
log.Warn("Card not in available cards", zap.String("card_id", cardID))
return fmt.Errorf("card %s not in available cards", cardID)
}
}
totalCost := len(cardsToBuy) * selection.CardBuyCost
if totalCost > 0 {
resources := p.Resources().Get()
if resources.Credits < totalCost {
log.Warn("Insufficient credits to buy cards",
zap.Int("needed", totalCost),
zap.Int("available", resources.Credits))
return fmt.Errorf("insufficient credits to buy cards: need %d, have %d", totalCost, resources.Credits)
}
p.Resources().Add(map[shared.ResourceType]int{
shared.ResourceCredit: -totalCost,
})
newResources := p.Resources().Get()
log.Debug("Paid for bought cards",
zap.Int("cards_bought", len(cardsToBuy)),
zap.Int("cost", totalCost),
zap.Int("remaining_credits", newResources.Credits))
}
if selection.PlayAsPrelude {
// Play selected prelude cards instead of adding to hand
for _, preludeID := range allSelectedCards {
if err := turn_management.ApplyPreludeCard(ctx, g, p, preludeID, a.CardRegistry(), a.StateRepository(), log); err != nil {
return fmt.Errorf("failed to play prelude card %s: %w", preludeID, err)
}
}
log.Debug("Played selected prelude cards",
zap.Strings("prelude_ids", allSelectedCards))
} else {
for _, cardID := range allSelectedCards {
p.Hand().AddCard(cardID)
}
log.Debug("Added selected cards to hand",
zap.Int("cards_taken", len(cardsToTake)),
zap.Int("cards_bought", len(cardsToBuy)),
zap.Int("total_cards", len(allSelectedCards)))
}
unselectedCards := []string{}
for _, cardID := range selection.AvailableCards {
if !slices.Contains(allSelectedCards, cardID) {
unselectedCards = append(unselectedCards, cardID)
}
}
if len(unselectedCards) > 0 {
if selection.PlayAsPrelude {
// Prelude cards are removed permanently, never discarded
if err := g.Deck().Remove(ctx, unselectedCards); err != nil {
log.Error("Failed to remove unselected prelude cards", zap.Error(err))
return fmt.Errorf("failed to remove unselected prelude cards: %w", err)
}
log.Debug("Removed unselected prelude cards permanently",
zap.Int("count", len(unselectedCards)),
zap.Strings("card_ids", unselectedCards))
} else {
if err := g.Deck().Discard(ctx, unselectedCards); err != nil {
log.Error("Failed to discard unselected cards", zap.Error(err))
return fmt.Errorf("failed to discard unselected cards: %w", err)
}
log.Debug("Discarded unselected cards to discard pile",
zap.Int("count", len(unselectedCards)),
zap.Strings("card_ids", unselectedCards))
}
}
p.Selection().SetPendingCardDrawSelection(nil)
// Clear forced first action if this was a prelude card draw selection
if selection.PlayAsPrelude {
if err := g.SetForcedFirstAction(ctx, playerID, nil); err != nil {
log.Error("Failed to clear forced first action", zap.Error(err))
}
}
// If this selection was triggered by a card action, complete the action now
if selection.SourceCardID != "" && !selection.PlayAsPrelude {
a.completeSourceCardAction(g, p, selection, log)
}
log.Info("Card draw confirmation completed",
zap.String("source", selection.Source),
zap.Int("cards_taken", len(cardsToTake)),
zap.Int("cards_bought", len(cardsToBuy)),
zap.Int("total_cost", totalCost),
zap.Bool("play_as_prelude", selection.PlayAsPrelude))
return nil
}
// completeSourceCardAction increments usage counts and consumes an action
// for the card action that triggered this card draw selection
func (a *ConfirmCardDrawAction) completeSourceCardAction(
g *game.Game,
p *player.Player,
selection *shared.PendingCardDrawSelection,
log *zap.Logger,
) {
// Increment usage counts for the source card action
actions := p.Actions().List()
for i := range actions {
if actions[i].CardID == selection.SourceCardID && actions[i].BehaviorIndex == selection.SourceBehaviorIndex {
actions[i].TimesUsedThisTurn++
actions[i].TimesUsedThisGeneration++
log.Debug("Incremented action usage counts from card draw confirmation",
zap.String("card_id", selection.SourceCardID),
zap.Int("behavior_index", selection.SourceBehaviorIndex),
zap.Int("times_used_this_turn", actions[i].TimesUsedThisTurn),
zap.Int("times_used_this_generation", actions[i].TimesUsedThisGeneration))
break
}
}
p.Actions().SetActions(actions)
// Consume the player action
a.ConsumePlayerAction(g, log)
}
package confirmation
import (
"context"
"fmt"
"slices"
"go.uber.org/zap"
baseaction "terraforming-mars-backend/internal/action"
colonyaction "terraforming-mars-backend/internal/action/colony"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/colonies"
"terraforming-mars-backend/internal/game"
)
// ConfirmColonyPlacementAction handles confirming a colony placement from a card effect
type ConfirmColonyPlacementAction struct {
baseaction.BaseAction
colonyRegistry colonies.ColonyRegistry
}
// NewConfirmColonyPlacementAction creates a new confirm colony placement action
func NewConfirmColonyPlacementAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
colonyRegistry colonies.ColonyRegistry,
logger *zap.Logger,
) *ConfirmColonyPlacementAction {
return &ConfirmColonyPlacementAction{
BaseAction: baseaction.NewBaseAction(gameRepo, cardRegistry),
colonyRegistry: colonyRegistry,
}
}
// Execute places the selected colony for free and clears the pending selection
func (a *ConfirmColonyPlacementAction) Execute(ctx context.Context, gameID string, playerID string, colonyID string) error {
log := a.InitLogger(gameID, playerID).With(
zap.String("action", "confirm_colony_placement"),
zap.String("colony_id", colonyID),
)
log.Debug("Confirming colony placement")
g, err := baseaction.ValidateActiveGame(ctx, a.GameRepository(), gameID, log)
if err != nil {
return err
}
p, err := a.GetPlayerFromGame(g, playerID, log)
if err != nil {
return err
}
pending := p.Selection().GetPendingColonySelection()
if pending == nil {
return fmt.Errorf("no pending colony selection")
}
if !slices.Contains(pending.AvailableColonyIDs, colonyID) {
return fmt.Errorf("colony %s is not available for selection", colonyID)
}
tileState := g.Colonies().GetState(colonyID)
if tileState == nil {
return fmt.Errorf("colony tile not found: %s", colonyID)
}
definition, err := a.colonyRegistry.GetByID(colonyID)
if err != nil {
return fmt.Errorf("colony definition not found: %w", err)
}
maxColonies := len(definition.Colonies)
if len(tileState.PlayerColonies) >= maxColonies {
return fmt.Errorf("colony tile is full: %d/%d colonies", len(tileState.PlayerColonies), maxColonies)
}
if !pending.AllowDuplicatePlayerColony && slices.Contains(tileState.PlayerColonies, playerID) {
return fmt.Errorf("player already has a colony on this tile")
}
source := pending.Source
if source == "" {
source = "Card Effect"
}
if err := colonyaction.PlaceColonyOnTile(ctx, g, p, definition, tileState, a.CardRegistry(), source, log); err != nil {
return fmt.Errorf("failed to place colony: %w", err)
}
p.Selection().SetPendingColonySelection(nil)
log.Info("Colony placed from card effect",
zap.String("colony_id", colonyID))
return nil
}
package confirmation
import (
"context"
"fmt"
baseaction "terraforming-mars-backend/internal/action"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/shared"
"go.uber.org/zap"
)
// ConfirmColonyResourceAction handles confirming a card storage target for colony resource placement
type ConfirmColonyResourceAction struct {
baseaction.BaseAction
}
// NewConfirmColonyResourceAction creates a new confirm colony resource action
func NewConfirmColonyResourceAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
stateRepo game.GameStateRepository,
logger *zap.Logger,
) *ConfirmColonyResourceAction {
return &ConfirmColonyResourceAction{
BaseAction: baseaction.NewBaseActionWithStateRepo(gameRepo, cardRegistry, stateRepo),
}
}
// Execute performs the confirm colony resource action
func (a *ConfirmColonyResourceAction) Execute(ctx context.Context, gameID string, playerID string, targetCardID string) error {
log := a.InitLogger(gameID, playerID).With(
zap.String("action", "confirm_colony_resource"),
zap.String("target_card_id", targetCardID),
)
log.Debug("Confirming colony resource placement")
g, err := baseaction.ValidateActiveGame(ctx, a.GameRepository(), gameID, log)
if err != nil {
return err
}
p, err := a.GetPlayerFromGame(g, playerID, log)
if err != nil {
return err
}
selection := p.Selection().PopPendingColonyResource()
if selection == nil {
log.Warn("No pending colony resource selection found")
return fmt.Errorf("no pending colony resource selection found")
}
// If empty target, player skipped (no eligible card or chose to skip)
if targetCardID == "" {
log.Debug("Player skipped colony resource placement",
zap.String("resource_type", selection.ResourceType),
zap.Int("amount", selection.Amount))
return nil
}
// Validate the target card can store this resource type
if a.CardRegistry() != nil {
card, cardErr := a.CardRegistry().GetByID(targetCardID)
if cardErr != nil {
return fmt.Errorf("target card not found: %s", targetCardID)
}
if card.ResourceStorage == nil || card.ResourceStorage.Type != shared.ResourceType(selection.ResourceType) {
return fmt.Errorf("card %s cannot store %s", targetCardID, selection.ResourceType)
}
}
// Validate the card belongs to the player (played cards or corporation)
cardBelongsToPlayer := false
for _, cardID := range p.PlayedCards().Cards() {
if cardID == targetCardID {
cardBelongsToPlayer = true
break
}
}
if p.CorporationID() == targetCardID {
cardBelongsToPlayer = true
}
if !cardBelongsToPlayer {
return fmt.Errorf("card %s does not belong to player", targetCardID)
}
// Apply the resources to the card's storage
p.Resources().AddToStorage(targetCardID, selection.Amount)
log.Info("Colony resource placed",
zap.String("resource_type", selection.ResourceType),
zap.Int("amount", selection.Amount),
zap.String("target_card", targetCardID),
zap.String("source", selection.Source))
return nil
}
package confirmation
import (
"context"
"fmt"
"slices"
"time"
"go.uber.org/zap"
baseaction "terraforming-mars-backend/internal/action"
colonyaction "terraforming-mars-backend/internal/action/colony"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/colonies"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/shared"
)
// ConfirmFreeTradeAction handles confirming a free trade from a card effect
type ConfirmFreeTradeAction struct {
baseaction.BaseAction
colonyRegistry colonies.ColonyRegistry
}
// NewConfirmFreeTradeAction creates a new confirm free trade action
func NewConfirmFreeTradeAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
colonyRegistry colonies.ColonyRegistry,
stateRepo game.GameStateRepository,
) *ConfirmFreeTradeAction {
return &ConfirmFreeTradeAction{
BaseAction: baseaction.NewBaseActionWithStateRepo(gameRepo, cardRegistry, stateRepo),
colonyRegistry: colonyRegistry,
}
}
// Execute performs the free trade with the selected colony
func (a *ConfirmFreeTradeAction) Execute(ctx context.Context, gameID string, playerID string, colonyID string) error {
log := a.InitLogger(gameID, playerID).With(
zap.String("action", "confirm_free_trade"),
zap.String("colony_id", colonyID),
)
log.Debug("Confirming free trade")
g, err := baseaction.ValidateActiveGame(ctx, a.GameRepository(), gameID, log)
if err != nil {
return err
}
p, err := a.GetPlayerFromGame(g, playerID, log)
if err != nil {
return err
}
pendingSelection := p.Selection().GetPendingFreeTradeSelection()
if pendingSelection == nil {
return fmt.Errorf("no pending free trade selection")
}
if !slices.Contains(pendingSelection.AvailableColonyIDs, colonyID) {
return fmt.Errorf("colony %s is not in the available list", colonyID)
}
tileState := g.Colonies().GetState(colonyID)
if tileState == nil {
return fmt.Errorf("colony tile not found: %s", colonyID)
}
if tileState.TradedThisGen {
return fmt.Errorf("colony tile already traded this generation")
}
if !g.Colonies().GetTradeFleetAvailable(playerID) {
return fmt.Errorf("trade fleet is not available")
}
definition, err := a.colonyRegistry.GetByID(colonyID)
if err != nil {
return fmt.Errorf("colony definition not found: %w", err)
}
// Apply trade step bonus from cards like Trade Envoys
tradeStepBonus := colonyaction.CountTradeStepBonus(p, a.CardRegistry())
if tradeStepBonus > 0 {
maxStep := len(definition.Steps) - 1
newPosition := tileState.MarkerPosition + tradeStepBonus
if newPosition > maxStep {
newPosition = maxStep
}
tileState.MarkerPosition = newPosition
}
// Apply trade income based on marker position (no payment needed - it's free!)
var pendingResources []*colonyaction.PendingResource
if tileState.MarkerPosition >= 0 && tileState.MarkerPosition < len(definition.Steps) {
step := definition.Steps[tileState.MarkerPosition]
for _, output := range step.Outputs {
if output.Amount > 0 {
pending := colonyaction.ApplyTradeOutput(ctx, g, p, output.Type, output.Amount, a.CardRegistry(), log)
if pending != nil {
pendingResources = append(pendingResources, pending)
}
}
}
}
// Handle pending card-targeted resources (floaters, microbes, animals)
if len(pendingResources) > 0 {
colonyaction.SetPendingColonyResourceFromTrade(p, pendingResources, definition.Name, colonyID, "trade", a.CardRegistry(), log)
}
// Apply colony bonus to all players with colonies
for _, colonyOwnerID := range tileState.PlayerColonies {
colonyOwner, ownerErr := g.GetPlayer(colonyOwnerID)
if ownerErr != nil {
continue
}
var ownerPendings []*colonyaction.PendingResource
for _, bonus := range definition.ColonyBonus {
if bonus.Amount > 0 {
pending := colonyaction.ApplyTradeOutput(ctx, g, colonyOwner, bonus.Type, bonus.Amount, a.CardRegistry(), log)
if pending != nil {
ownerPendings = append(ownerPendings, pending)
}
}
}
if len(ownerPendings) > 0 {
reason := "colony-tax"
colonyaction.SetPendingColonyResourceFromTrade(colonyOwner, ownerPendings, definition.Name, colonyID, reason, a.CardRegistry(), log)
}
}
// Reset marker and mark as traded
tileState.MarkerPosition = len(tileState.PlayerColonies)
tileState.TradedThisGen = true
tileState.TraderID = playerID
g.Colonies().SetTradeFleetAvailable(playerID, false)
events.Publish(g.EventBus(), events.ColonyTradedEvent{
GameID: g.ID(),
PlayerID: playerID,
ColonyID: colonyID,
Timestamp: time.Now(),
})
// Clear the pending selection
p.Selection().SetPendingFreeTradeSelection(nil)
a.WriteStateLogFull(ctx, g, "Free Trade: "+definition.Name, shared.SourceTypeColonyTrade,
playerID, fmt.Sprintf("Free traded with %s", definition.Name), nil, nil, nil)
log.Info("Free trade confirmed",
zap.String("colony_id", colonyID))
return nil
}
package confirmation
import (
"context"
"fmt"
baseaction "terraforming-mars-backend/internal/action"
gameaction "terraforming-mars-backend/internal/action/game"
"terraforming-mars-backend/internal/action/resource_conversion"
"go.uber.org/zap"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/game"
gamecards "terraforming-mars-backend/internal/game/cards"
playerPkg "terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
)
// ConfirmProductionCardsAction handles the business logic for confirming production card selection
type ConfirmProductionCardsAction struct {
baseaction.BaseAction
finalScoringAction *gameaction.FinalScoringAction
}
// NewConfirmProductionCardsAction creates a new confirm production cards action
func NewConfirmProductionCardsAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
finalScoringAction *gameaction.FinalScoringAction,
logger *zap.Logger,
) *ConfirmProductionCardsAction {
return &ConfirmProductionCardsAction{
BaseAction: baseaction.NewBaseAction(gameRepo, cardRegistry),
finalScoringAction: finalScoringAction,
}
}
// Execute performs the confirm production cards action
func (a *ConfirmProductionCardsAction) Execute(ctx context.Context, gameID string, playerID string, selectedCardIDs []string) error {
log := a.InitLogger(gameID, playerID).With(
zap.String("action", "confirm_production_cards"),
zap.Strings("selected_card_ids", selectedCardIDs),
)
log.Debug("Player confirming production card selection")
g, err := a.GameRepository().Get(ctx, gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
return fmt.Errorf("game not found: %s", gameID)
}
if g.CurrentPhase() != shared.GamePhaseProductionAndCardDraw {
log.Warn("Game is not in production phase",
zap.String("current_phase", string(g.CurrentPhase())),
zap.String("expected_phase", string(shared.GamePhaseProductionAndCardDraw)))
return fmt.Errorf("game is not in production phase")
}
player, err := g.GetPlayer(playerID)
if err != nil {
log.Error("Player not found in game", zap.Error(err))
return fmt.Errorf("player not found: %s", playerID)
}
productionPhase := g.GetProductionPhase(playerID)
if productionPhase == nil {
log.Error("Player not in production phase")
return fmt.Errorf("player not in production phase")
}
if productionPhase.SelectionComplete {
log.Error("Production selection already complete")
return fmt.Errorf("production selection already complete")
}
availableSet := make(map[string]bool)
for _, id := range productionPhase.AvailableCards {
availableSet[id] = true
}
for _, cardID := range selectedCardIDs {
if !availableSet[cardID] {
log.Error("Selected card not available", zap.String("card_id", cardID))
return fmt.Errorf("card %s not available for selection", cardID)
}
}
calc := gamecards.NewRequirementModifierCalculator(a.CardRegistry())
cardBuyDiscounts := calc.CalculateActionDiscounts(player, shared.ActionCardBuying)
costPerCard := max(3-cardBuyDiscounts[shared.ResourceCredit], 0)
cost := len(selectedCardIDs) * costPerCard
resources := player.Resources().Get()
if resources.Credits < cost {
log.Error("Insufficient credits",
zap.Int("cost", cost),
zap.Int("available", resources.Credits))
return fmt.Errorf("insufficient credits: need %d, have %d", cost, resources.Credits)
}
player.Resources().Add(map[shared.ResourceType]int{
shared.ResourceCredit: -cost,
})
resources = player.Resources().Get()
log.Debug("Resources updated",
zap.Int("cost", cost),
zap.Int("remaining_credits", resources.Credits))
log.Debug("Adding cards to player hand",
zap.Strings("card_ids", selectedCardIDs),
zap.Int("count", len(selectedCardIDs)))
for _, cardID := range selectedCardIDs {
player.Hand().AddCard(cardID)
}
log.Debug("Cards added to hand",
zap.Strings("card_ids_added", selectedCardIDs),
zap.Int("card_count", len(selectedCardIDs)))
productionPhase.SelectionComplete = true
if err := g.SetProductionPhase(ctx, playerID, productionPhase); err != nil {
log.Error("Failed to update production phase", zap.Error(err))
return fmt.Errorf("failed to update production phase: %w", err)
}
log.Debug("Production selection marked complete")
allPlayers := g.GetAllPlayers()
allComplete := true
for _, p := range allPlayers {
if p.HasExited() {
continue
}
pPhase := g.GetProductionPhase(p.ID())
if pPhase == nil || !pPhase.SelectionComplete {
allComplete = false
break
}
}
if allComplete {
for _, p := range allPlayers {
if err := g.SetProductionPhase(ctx, p.ID(), nil); err != nil {
log.Warn("Failed to clear production phase",
zap.String("player_id", p.ID()),
zap.Error(err))
}
}
if g.GlobalParameters().IsMaxed() {
log.Debug("All global parameters maxed after final production - entering final phase")
if err := g.UpdatePhase(ctx, shared.GamePhaseFinalPhase); err != nil {
log.Error("Failed to transition to final phase", zap.Error(err))
return fmt.Errorf("failed to transition to final phase: %w", err)
}
autoPassPlayersForFinalPhase(allPlayers, a.CardRegistry(), log)
turnOrder := g.TurnOrder()
firstPlayerID := ""
activeCount := 0
for _, id := range turnOrder {
p, _ := g.GetPlayer(id)
if p != nil && !p.HasPassed() && !p.HasExited() {
activeCount++
if firstPlayerID == "" {
firstPlayerID = id
}
}
}
if activeCount == 0 {
log.Debug("All players auto-passed in final phase - triggering final scoring")
if err := a.finalScoringAction.Execute(ctx, gameID); err != nil {
log.Error("Failed to execute final scoring", zap.Error(err))
return fmt.Errorf("failed to execute final scoring: %w", err)
}
log.Info("Game ended, final scores calculated")
return nil
}
availableActions := 2
if activeCount == 1 {
availableActions = -1
}
if err := g.SetCurrentTurn(ctx, firstPlayerID, availableActions); err != nil {
log.Error("Failed to set current turn for final phase", zap.Error(err))
return fmt.Errorf("failed to set current turn: %w", err)
}
log.Info("Final phase started")
return nil
}
log.Debug("All players completed production selection, advancing to action phase")
if err := g.UpdatePhase(ctx, shared.GamePhaseAction); err != nil {
log.Error("Failed to transition game phase", zap.Error(err))
return fmt.Errorf("failed to transition game phase: %w", err)
}
turnOrder := g.TurnOrder()
activeCount := 0
firstPlayerID := ""
for _, id := range turnOrder {
p, _ := g.GetPlayer(id)
if p != nil && !p.HasExited() {
activeCount++
if firstPlayerID == "" {
firstPlayerID = id
}
}
}
if firstPlayerID != "" {
availableActions := 2
if activeCount == 1 {
availableActions = -1
}
if err := g.SetCurrentTurn(ctx, firstPlayerID, availableActions); err != nil {
log.Error("Failed to set current turn", zap.Error(err))
return fmt.Errorf("failed to set current turn: %w", err)
}
log.Debug("Set first player turn with actions",
zap.String("player_id", firstPlayerID),
zap.Int("available_actions", availableActions))
}
for _, p := range allPlayers {
if !p.HasExited() {
p.Actions().ResetGenerationCounts()
}
}
}
log.Info("Production cards selected")
return nil
}
// autoPassPlayersForFinalPhase marks players as passed if they cannot perform
// any final phase actions. Currently checks if a player can afford at least
// one greenery conversion (plants → greenery).
func autoPassPlayersForFinalPhase(
players []*playerPkg.Player,
cardRegistry cards.CardRegistry,
log *zap.Logger,
) {
calculator := gamecards.NewRequirementModifierCalculator(cardRegistry)
for _, p := range players {
if p.HasExited() {
continue
}
if !canAffordGreenery(p, calculator) {
p.SetPassed(true)
log.Debug("Auto-passed player in final phase (no available actions)",
zap.String("player_id", p.ID()))
}
}
}
// canAffordGreenery checks whether a player has enough plants to convert to greenery,
// accounting for card discounts (e.g., Ecoline).
func canAffordGreenery(p *playerPkg.Player, calculator *gamecards.RequirementModifierCalculator) bool {
discounts := calculator.CalculateStandardProjectDiscounts(p, shared.StandardProjectConvertPlantsToGreenery)
plantDiscount := discounts[shared.ResourcePlant]
requiredPlants := max(resource_conversion.BasePlantsForGreenery-plantDiscount, 1)
return p.Resources().Get().Plants >= requiredPlants
}
package confirmation
import (
"context"
"fmt"
baseaction "terraforming-mars-backend/internal/action"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/shared"
)
// ConfirmSellPatentsAction handles the business logic for confirming sell patents card selection
// This is Phase 2: processes the selected cards and awards credits
type ConfirmSellPatentsAction struct {
baseaction.BaseAction
}
// NewConfirmSellPatentsAction creates a new confirm sell patents action
func NewConfirmSellPatentsAction(
gameRepo game.GameRepository,
stateRepo game.GameStateRepository,
logger *zap.Logger,
) *ConfirmSellPatentsAction {
return &ConfirmSellPatentsAction{
BaseAction: baseaction.NewBaseActionWithStateRepo(gameRepo, nil, stateRepo),
}
}
// Execute performs the confirm sell patents action (Phase 2: process card selection)
func (a *ConfirmSellPatentsAction) Execute(ctx context.Context, gameID string, playerID string, selectedCardIDs []string) error {
log := a.InitLogger(gameID, playerID).With(
zap.String("action", "confirm_sell_patents"),
zap.Int("cards_selected", len(selectedCardIDs)),
)
log.Debug("Confirming sell patents card selection")
g, err := baseaction.ValidateActiveGame(ctx, a.GameRepository(), gameID, log)
if err != nil {
return err
}
if err := baseaction.ValidateGamePhase(g, shared.GamePhaseAction, log); err != nil {
return err
}
if err := baseaction.ValidateCurrentTurn(g, playerID, log); err != nil {
return err
}
if err := baseaction.ValidateActionsRemaining(g, playerID, log); err != nil {
return err
}
player, err := a.GetPlayerFromGame(g, playerID, log)
if err != nil {
return err
}
pendingCardSelection := player.Selection().GetPendingCardSelection()
if pendingCardSelection == nil {
log.Warn("No pending card selection found")
return fmt.Errorf("no pending card selection found")
}
if pendingCardSelection.Source != "sell-patents" {
log.Warn("Pending card selection is not for sell patents",
zap.String("source", pendingCardSelection.Source))
return fmt.Errorf("pending card selection is not for sell patents")
}
if len(selectedCardIDs) < pendingCardSelection.MinCards {
log.Warn("Too few cards selected",
zap.Int("selected", len(selectedCardIDs)),
zap.Int("min_required", pendingCardSelection.MinCards))
return fmt.Errorf("must select at least %d cards", pendingCardSelection.MinCards)
}
if len(selectedCardIDs) > pendingCardSelection.MaxCards {
log.Warn("Too many cards selected",
zap.Int("selected", len(selectedCardIDs)),
zap.Int("max_allowed", pendingCardSelection.MaxCards))
return fmt.Errorf("cannot select more than %d cards", pendingCardSelection.MaxCards)
}
availableCardsMap := make(map[string]bool)
for _, cardID := range pendingCardSelection.AvailableCards {
availableCardsMap[cardID] = true
}
for _, cardID := range selectedCardIDs {
if !availableCardsMap[cardID] {
log.Warn("Selected card not available", zap.String("card_id", cardID))
return fmt.Errorf("card %s is not available for selection", cardID)
}
}
totalReward := 0
for _, cardID := range selectedCardIDs {
totalReward += pendingCardSelection.CardRewards[cardID]
}
if totalReward > 0 {
player.Resources().Add(map[shared.ResourceType]int{
shared.ResourceCredit: totalReward,
})
resources := player.Resources().Get()
log.Debug("Awarded credits for sold cards",
zap.Int("cards_sold", len(selectedCardIDs)),
zap.Int("credits_earned", totalReward),
zap.Int("new_credits", resources.Credits))
}
for _, cardID := range selectedCardIDs {
removed := player.Hand().RemoveCard(cardID)
if !removed {
log.Warn("Failed to remove card from hand", zap.String("card_id", cardID))
}
}
if err := g.Deck().Discard(ctx, selectedCardIDs); err != nil {
log.Error("Failed to discard sold cards to discard pile", zap.Error(err))
return fmt.Errorf("failed to discard sold cards: %w", err)
}
log.Debug("Sold cards added to discard pile", zap.Int("cards_removed", len(selectedCardIDs)))
player.Selection().SetPendingCardSelection(nil)
if len(selectedCardIDs) > 0 {
a.ConsumePlayerAction(g, log)
cardsSold := len(selectedCardIDs)
creditOutputs := []shared.CalculatedOutput{
{ResourceType: string(shared.ResourceCredit), Amount: totalReward, IsScaled: false},
}
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: "Sell Patents",
PlayerID: playerID,
SourceType: shared.SourceTypeStandardProject,
CalculatedOutputs: creditOutputs,
})
displayData := &game.LogDisplayData{
Behaviors: []shared.CardBehavior{{
Outputs: []shared.BehaviorCondition{&shared.CardOperationCondition{
ConditionBase: shared.ConditionBase{ResourceType: shared.ResourceCardDraw, Amount: cardsSold, Target: "self-player"},
}},
}},
}
a.WriteStateLogFull(ctx, g, "Standard Project: Sell Patents", shared.SourceTypeStandardProject, playerID, "Sold patents", nil, creditOutputs, displayData)
}
log.Info("Sell patents completed",
zap.Int("cards_sold", len(selectedCardIDs)),
zap.Int("credits_earned", totalReward))
return nil
}
package confirmation
import (
"context"
"fmt"
baseaction "terraforming-mars-backend/internal/action"
"go.uber.org/zap"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/shared"
)
// ConfirmStealTargetAction handles confirming a steal target player selection after tile placement
type ConfirmStealTargetAction struct {
baseaction.BaseAction
}
// NewConfirmStealTargetAction creates a new confirm steal target action
func NewConfirmStealTargetAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
stateRepo game.GameStateRepository,
logger *zap.Logger,
) *ConfirmStealTargetAction {
return &ConfirmStealTargetAction{
BaseAction: baseaction.NewBaseActionWithStateRepo(gameRepo, cardRegistry, stateRepo),
}
}
// Execute performs the confirm steal target action
func (a *ConfirmStealTargetAction) Execute(ctx context.Context, gameID string, playerID string, targetPlayerID string) error {
log := a.InitLogger(gameID, playerID).With(
zap.String("action", "confirm_steal_target"),
zap.String("target_player_id", targetPlayerID),
)
log.Debug("Confirming steal target selection")
g, err := baseaction.ValidateActiveGame(ctx, a.GameRepository(), gameID, log)
if err != nil {
return err
}
p, err := a.GetPlayerFromGame(g, playerID, log)
if err != nil {
return err
}
selection := p.Selection().GetPendingStealTargetSelection()
if selection == nil {
log.Warn("No pending steal target selection found")
return fmt.Errorf("no pending steal target selection found")
}
p.Selection().SetPendingStealTargetSelection(nil)
if targetPlayerID == "" {
log.Debug("Player skipped steal")
baseaction.AutoAdvanceTurnIfNeeded(g, playerID, log)
description := fmt.Sprintf("Skipped steal from %s", selection.Source)
a.WriteStateLog(ctx, g, selection.Source, shared.SourceTypeCardPlay, playerID, description)
log.Info("Steal target skipped",
zap.String("source", selection.Source))
return nil
}
eligible := false
for _, id := range selection.EligiblePlayerIDs {
if id == targetPlayerID {
eligible = true
break
}
}
if !eligible {
return fmt.Errorf("player %s is not an eligible steal target", targetPlayerID)
}
targetPlayer, err := g.GetPlayer(targetPlayerID)
if err != nil {
return fmt.Errorf("target player not found: %w", err)
}
resourceType := selection.ResourceType
targetResources := targetPlayer.Resources().Get()
available := getResourceByType(targetResources, resourceType)
stolenAmount := min(selection.Amount, available)
if stolenAmount > 0 {
targetPlayer.Resources().Add(map[shared.ResourceType]int{
resourceType: -stolenAmount,
})
p.Resources().Add(map[shared.ResourceType]int{
resourceType: stolenAmount,
})
}
baseaction.AutoAdvanceTurnIfNeeded(g, playerID, log)
calculatedOutputs := []shared.CalculatedOutput{
{ResourceType: string(resourceType), Amount: stolenAmount},
}
description := fmt.Sprintf("Stole %d %s from %s", stolenAmount, resourceType, targetPlayer.Name())
a.WriteStateLogFull(ctx, g, selection.Source, shared.SourceTypeCardPlay, playerID, description, nil, calculatedOutputs, nil)
log.Info("Steal target confirmed",
zap.String("source", selection.Source),
zap.Int("stolen_amount", stolenAmount))
return nil
}
func getResourceByType(r shared.Resources, rt shared.ResourceType) int {
switch rt {
case shared.ResourceCredit:
return r.Credits
case shared.ResourceSteel:
return r.Steel
case shared.ResourceTitanium:
return r.Titanium
case shared.ResourcePlant:
return r.Plants
case shared.ResourceEnergy:
return r.Energy
case shared.ResourceHeat:
return r.Heat
default:
return 0
}
}
package connection
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/service/bot"
)
// EndGameAction handles ending a game and cleaning up all resources.
type EndGameAction struct {
gameRepo game.GameRepository
botGameStopper bot.BotGameStopper
logger *zap.Logger
}
// NewEndGameAction creates a new EndGameAction.
func NewEndGameAction(
gameRepo game.GameRepository,
botGameStopper bot.BotGameStopper,
logger *zap.Logger,
) *EndGameAction {
return &EndGameAction{
gameRepo: gameRepo,
botGameStopper: botGameStopper,
logger: logger,
}
}
// Execute ends a game. Only the host can end the game.
func (a *EndGameAction) Execute(ctx context.Context, gameID string, requesterID string) error {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("requester_id", requesterID),
zap.String("action", "end_game"),
)
g, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
return fmt.Errorf("game not found: %s", gameID)
}
if g.HostPlayerID() != requesterID {
return fmt.Errorf("cannot end game: only the host can end the game")
}
if a.botGameStopper != nil {
go a.botGameStopper.StopAllBotsForGame(gameID)
}
if err := a.gameRepo.Delete(ctx, gameID); err != nil {
log.Error("Failed to delete game", zap.Error(err))
return fmt.Errorf("failed to delete game: %w", err)
}
log.Info("Game ended and deleted by host")
return nil
}
package connection
import (
"context"
"fmt"
"go.uber.org/zap"
gameaction "terraforming-mars-backend/internal/action/game"
"terraforming-mars-backend/internal/action/turn_management"
"terraforming-mars-backend/internal/game"
playerPkg "terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
"terraforming-mars-backend/internal/service/bot"
)
// KickPlayerAction handles kicking a player from a game.
// In lobby: removes the player entirely (they can rejoin).
// In active game: marks the player as exited (permanently skipped, cannot reconnect).
type KickPlayerAction struct {
gameRepo game.GameRepository
botStopper bot.BotStopper
finalScoringAction *gameaction.FinalScoringAction
logger *zap.Logger
}
func NewKickPlayerAction(
gameRepo game.GameRepository,
botStopper bot.BotStopper,
finalScoringAction *gameaction.FinalScoringAction,
logger *zap.Logger,
) *KickPlayerAction {
return &KickPlayerAction{
gameRepo: gameRepo,
botStopper: botStopper,
finalScoringAction: finalScoringAction,
logger: logger,
}
}
func (a *KickPlayerAction) Execute(ctx context.Context, gameID string, requesterID string, targetPlayerID string) error {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("requester_id", requesterID),
zap.String("target_player_id", targetPlayerID),
zap.String("action", "kick_player"),
)
g, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
return fmt.Errorf("game not found: %s", gameID)
}
if g.HostPlayerID() != requesterID {
return fmt.Errorf("cannot kick player: only host can kick players")
}
if requesterID == targetPlayerID {
return fmt.Errorf("cannot kick player: cannot kick yourself")
}
if g.Status() == shared.GameStatusLobby {
return a.kickFromLobby(ctx, g, targetPlayerID, log)
}
return a.kickFromActiveGame(ctx, g, gameID, targetPlayerID, log)
}
func (a *KickPlayerAction) kickFromLobby(ctx context.Context, g *game.Game, targetPlayerID string, log *zap.Logger) error {
log.Debug("Kicking player from lobby")
if err := g.RemovePlayer(ctx, targetPlayerID); err != nil {
log.Error("Failed to remove player from lobby", zap.Error(err))
return fmt.Errorf("failed to kick player: %w", err)
}
remaining := g.GetAllPlayers()
if len(remaining) == 0 {
if err := a.gameRepo.Delete(ctx, g.ID()); err != nil {
log.Error("Failed to delete empty game", zap.Error(err))
return fmt.Errorf("failed to delete empty game: %w", err)
}
log.Debug("Game deleted (no players remaining)")
return nil
}
log.Info("Player kicked from lobby")
return nil
}
func (a *KickPlayerAction) kickFromActiveGame(ctx context.Context, g *game.Game, gameID string, targetPlayerID string, log *zap.Logger) error {
log.Debug("Kicking player from active game")
target, err := g.GetPlayer(targetPlayerID)
if err != nil {
return fmt.Errorf("player not found: %s", targetPlayerID)
}
if target.HasExited() {
return fmt.Errorf("cannot kick player: player already exited")
}
target.SetExited(true)
target.SetConnected(false)
target.SetPassed(true)
if target.IsBot() && a.botStopper != nil {
a.botStopper.StopBot(g.ID(), targetPlayerID)
target.SetBotStatus(playerPkg.BotStatusNone)
}
a.clearPendingState(ctx, g, targetPlayerID, log)
a.moveToEndOfTurnOrder(ctx, g, targetPlayerID, log)
switch g.CurrentPhase() {
case shared.GamePhaseStartingSelection:
a.handleStartingSelectionKick(ctx, g, log)
case shared.GamePhaseAction:
a.handleActionPhaseKick(ctx, g, gameID, targetPlayerID, log)
case shared.GamePhaseProductionAndCardDraw:
a.handleProductionPhaseKick(ctx, g, log)
}
log.Info("Player kicked from active game",
zap.String("target_player_id", targetPlayerID))
return nil
}
func (a *KickPlayerAction) clearPendingState(ctx context.Context, g *game.Game, playerID string, log *zap.Logger) {
if g.GetSelectCorporationPhase(playerID) != nil {
if err := g.SetSelectCorporationPhase(ctx, playerID, nil); err != nil {
log.Error("Failed to clear corporation phase", zap.Error(err))
}
}
if g.GetSelectStartingCardsPhase(playerID) != nil {
if err := g.SetSelectStartingCardsPhase(ctx, playerID, nil); err != nil {
log.Error("Failed to clear starting cards phase", zap.Error(err))
}
}
if g.GetSelectPreludeCardsPhase(playerID) != nil {
if err := g.SetSelectPreludeCardsPhase(ctx, playerID, nil); err != nil {
log.Error("Failed to clear prelude phase", zap.Error(err))
}
}
if g.GetPendingTileSelection(playerID) != nil {
if err := g.SetPendingTileSelection(ctx, playerID, nil); err != nil {
log.Error("Failed to clear pending tile selection", zap.Error(err))
}
}
if g.GetPendingTileSelectionQueue(playerID) != nil {
if err := g.SetPendingTileSelectionQueue(ctx, playerID, nil); err != nil {
log.Error("Failed to clear pending tile queue", zap.Error(err))
}
}
if g.GetForcedFirstAction(playerID) != nil {
if err := g.SetForcedFirstAction(ctx, playerID, nil); err != nil {
log.Error("Failed to clear forced first action", zap.Error(err))
}
}
if g.GetProductionPhase(playerID) != nil {
if err := g.SetProductionPhase(ctx, playerID, nil); err != nil {
log.Error("Failed to clear production phase", zap.Error(err))
}
}
p, err := g.GetPlayer(playerID)
if err != nil {
return
}
sel := p.Selection()
sel.SetPendingCardSelection(nil)
sel.SetPendingCardDrawSelection(nil)
sel.SetPendingCardDiscardSelection(nil)
sel.SetPendingBehaviorChoiceSelection(nil)
}
func (a *KickPlayerAction) moveToEndOfTurnOrder(ctx context.Context, g *game.Game, playerID string, log *zap.Logger) {
turnOrder := g.TurnOrder()
idx := -1
for i, id := range turnOrder {
if id == playerID {
idx = i
break
}
}
if idx == -1 {
return
}
newOrder := make([]string, 0, len(turnOrder))
newOrder = append(newOrder, turnOrder[:idx]...)
newOrder = append(newOrder, turnOrder[idx+1:]...)
newOrder = append(newOrder, playerID)
if err := g.SetTurnOrder(ctx, newOrder); err != nil {
log.Error("Failed to reorder turn order", zap.Error(err))
}
}
func (a *KickPlayerAction) handleStartingSelectionKick(ctx context.Context, g *game.Game, log *zap.Logger) {
allPlayers := g.GetAllPlayers()
for _, p := range allPlayers {
if p.HasExited() {
continue
}
if g.GetSelectCorporationPhase(p.ID()) != nil ||
g.GetSelectStartingCardsPhase(p.ID()) != nil ||
g.GetSelectPreludeCardsPhase(p.ID()) != nil ||
g.GetPendingTileSelection(p.ID()) != nil ||
g.GetPendingTileSelectionQueue(p.ID()) != nil {
log.Debug("Other players still in starting selection")
return
}
}
log.Debug("All remaining players completed starting selection after kick, advancing to action phase")
var activePlayers []*playerPkg.Player
for _, p := range allPlayers {
if !p.HasExited() {
activePlayers = append(activePlayers, p)
}
}
if err := g.UpdatePhase(ctx, shared.GamePhaseAction); err != nil {
log.Error("Failed to transition game phase", zap.Error(err))
return
}
turnOrder := g.TurnOrder()
if len(turnOrder) > 0 {
firstPlayerID := a.firstActivePlayer(g, turnOrder)
if firstPlayerID == "" {
return
}
availableActions := 2
if len(activePlayers) == 1 {
availableActions = -1
}
if err := g.SetCurrentTurn(ctx, firstPlayerID, availableActions); err != nil {
log.Error("Failed to set current turn", zap.Error(err))
}
}
}
func (a *KickPlayerAction) handleActionPhaseKick(ctx context.Context, g *game.Game, gameID string, targetPlayerID string, log *zap.Logger) {
currentTurn := g.CurrentTurn()
if currentTurn == nil {
return
}
isTheirTurn := currentTurn.PlayerID() == targetPlayerID
turnOrder := g.TurnOrder()
activeCount := 0
for _, id := range turnOrder {
p, _ := g.GetPlayer(id)
if p != nil && !p.HasPassed() && !p.HasExited() {
activeCount++
}
}
if activeCount == 0 {
a.triggerEndOfGeneration(ctx, g, gameID, log)
return
}
if !isTheirTurn {
if activeCount == 1 {
for _, id := range turnOrder {
p, _ := g.GetPlayer(id)
if p != nil && !p.HasPassed() && !p.HasExited() {
if err := g.SetCurrentTurn(ctx, p.ID(), -1); err != nil {
log.Error("Failed to grant unlimited actions", zap.Error(err))
}
log.Debug("Last active player granted unlimited actions after kick",
zap.String("player_id", p.ID()))
break
}
}
}
return
}
a.advanceToNextActivePlayer(ctx, g, targetPlayerID, activeCount, log)
}
func (a *KickPlayerAction) advanceToNextActivePlayer(ctx context.Context, g *game.Game, currentPlayerID string, activeCount int, log *zap.Logger) {
turnOrder := g.TurnOrder()
currentIdx := -1
for i, id := range turnOrder {
if id == currentPlayerID {
currentIdx = i
break
}
}
if currentIdx == -1 {
return
}
nextActions := 2
if activeCount == 1 {
nextActions = -1
}
for i := 1; i < len(turnOrder); i++ {
nextIdx := (currentIdx + i) % len(turnOrder)
nextPlayer, _ := g.GetPlayer(turnOrder[nextIdx])
if nextPlayer != nil && !nextPlayer.HasPassed() && !nextPlayer.HasExited() {
if err := g.SetCurrentTurn(ctx, nextPlayer.ID(), nextActions); err != nil {
log.Error("Failed to advance turn", zap.Error(err))
return
}
log.Debug("Advanced turn after kick",
zap.String("next_player_id", nextPlayer.ID()))
return
}
}
}
func (a *KickPlayerAction) handleProductionPhaseKick(ctx context.Context, g *game.Game, log *zap.Logger) {
allPlayers := g.GetAllPlayers()
allComplete := true
for _, p := range allPlayers {
if p.HasExited() {
continue
}
pPhase := g.GetProductionPhase(p.ID())
if pPhase == nil || !pPhase.SelectionComplete {
allComplete = false
break
}
}
if !allComplete {
log.Debug("Other players still in production phase")
return
}
log.Debug("All remaining players completed production after kick, advancing to action phase")
if err := g.UpdatePhase(ctx, shared.GamePhaseAction); err != nil {
log.Error("Failed to transition game phase", zap.Error(err))
return
}
turnOrder := g.TurnOrder()
if len(turnOrder) > 0 {
firstPlayerID := a.firstActivePlayer(g, turnOrder)
if firstPlayerID == "" {
return
}
activeCount := 0
for _, id := range turnOrder {
p, _ := g.GetPlayer(id)
if p != nil && !p.HasExited() {
activeCount++
}
}
availableActions := 2
if activeCount == 1 {
availableActions = -1
}
if err := g.SetCurrentTurn(ctx, firstPlayerID, availableActions); err != nil {
log.Error("Failed to set current turn", zap.Error(err))
}
}
for _, p := range allPlayers {
if !p.HasExited() {
p.Actions().ResetGenerationCounts()
}
if err := g.SetProductionPhase(ctx, p.ID(), nil); err != nil {
log.Warn("Failed to clear production phase",
zap.String("player_id", p.ID()),
zap.Error(err))
}
}
}
func (a *KickPlayerAction) triggerEndOfGeneration(ctx context.Context, g *game.Game, gameID string, log *zap.Logger) {
if g.GlobalParameters().IsMaxed() {
log.Debug("All global parameters maxed after kick - triggering final scoring")
if err := a.finalScoringAction.Execute(ctx, gameID); err != nil {
log.Error("Failed to execute final scoring", zap.Error(err))
}
return
}
log.Debug("All players finished after kick - triggering production phase")
activePlayers := []*playerPkg.Player{}
for _, p := range g.GetAllPlayers() {
if !p.HasExited() {
activePlayers = append(activePlayers, p)
}
}
if err := turn_management.ExecuteProductionPhase(ctx, g, activePlayers, log); err != nil {
log.Error("Failed to execute production phase after kick", zap.Error(err))
}
}
func (a *KickPlayerAction) firstActivePlayer(g *game.Game, turnOrder []string) string {
for _, id := range turnOrder {
p, _ := g.GetPlayer(id)
if p != nil && !p.HasExited() {
return p.ID()
}
}
return ""
}
package connection
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/shared"
)
// PlayerDisconnectedAction handles the business logic for player disconnection
type PlayerDisconnectedAction struct {
gameRepo game.GameRepository
logger *zap.Logger
}
// NewPlayerDisconnectedAction creates a new player disconnected action
func NewPlayerDisconnectedAction(
gameRepo game.GameRepository,
logger *zap.Logger,
) *PlayerDisconnectedAction {
return &PlayerDisconnectedAction{
gameRepo: gameRepo,
logger: logger,
}
}
// Execute performs the player disconnected action
func (a *PlayerDisconnectedAction) Execute(ctx context.Context, gameID string, playerID string) error {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("player_id", playerID),
zap.String("action", "player_disconnected"),
)
log.Debug("Player disconnecting")
g, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
return fmt.Errorf("game not found: %s", gameID)
}
if g.Status() == shared.GameStatusLobby {
wasHost := g.HostPlayerID() == playerID
if err := g.RemovePlayer(ctx, playerID); err != nil {
log.Error("Failed to remove player from lobby", zap.Error(err))
return fmt.Errorf("failed to remove player: %w", err)
}
remaining := g.GetAllPlayers()
if len(remaining) == 0 {
if err := a.gameRepo.Delete(ctx, gameID); err != nil {
log.Error("Failed to delete empty game", zap.Error(err))
return fmt.Errorf("failed to delete empty game: %w", err)
}
log.Debug("Game deleted (no players remaining)")
return nil
}
if wasHost {
var newHost string
for _, p := range remaining {
if !p.IsBot() {
newHost = p.ID()
break
}
}
if newHost == "" {
if err := a.gameRepo.Delete(ctx, gameID); err != nil {
log.Error("Failed to delete game with only bots", zap.Error(err))
return fmt.Errorf("failed to delete game: %w", err)
}
log.Debug("Game deleted (no human players remaining)")
return nil
}
if err := g.SetHostPlayerID(ctx, newHost); err != nil {
log.Error("Failed to reassign host", zap.Error(err))
return fmt.Errorf("failed to reassign host: %w", err)
}
log.Debug("Host reassigned to human player", zap.String("new_host", newHost))
}
log.Debug("Player removed from lobby")
return nil
}
player, err := g.GetPlayer(playerID)
if err != nil {
log.Error("Player not found in game", zap.Error(err))
return fmt.Errorf("player not found: %s", playerID)
}
if player.IsBot() {
log.Debug("Skipping disconnect for bot player", zap.String("player_id", playerID))
return nil
}
player.SetConnected(false)
log.Info("Player disconnected")
return nil
}
package connection
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/delivery/dto"
"terraforming-mars-backend/internal/game"
)
// PlayerTakeoverAction handles the business logic for taking over a disconnected player
type PlayerTakeoverAction struct {
gameRepo game.GameRepository
cardRegistry cards.CardRegistry
logger *zap.Logger
}
// PlayerTakeoverResult contains the result of a player takeover
type PlayerTakeoverResult struct {
PlayerID string
PlayerName string
GameDto dto.GameDto
}
// NewPlayerTakeoverAction creates a new player takeover action
func NewPlayerTakeoverAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
logger *zap.Logger,
) *PlayerTakeoverAction {
return &PlayerTakeoverAction{
gameRepo: gameRepo,
cardRegistry: cardRegistry,
logger: logger,
}
}
// Execute performs the player takeover action
func (a *PlayerTakeoverAction) Execute(ctx context.Context, gameID string, targetPlayerID string) (*PlayerTakeoverResult, error) {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("target_player_id", targetPlayerID),
zap.String("action", "player_takeover"),
)
log.Debug("Processing player takeover request")
g, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
return nil, fmt.Errorf("game not found: %s", gameID)
}
player, err := g.GetPlayer(targetPlayerID)
if err != nil {
log.Error("Target player not found in game", zap.Error(err))
return nil, fmt.Errorf("player not found: %s", targetPlayerID)
}
if player.HasExited() {
log.Warn("Cannot take over an exited player")
return nil, fmt.Errorf("player has been kicked from the game")
}
if player.IsBot() {
log.Warn("Cannot take over a bot player")
return nil, fmt.Errorf("cannot take over a bot player")
}
if player.IsConnected() {
log.Warn("Cannot take over a connected player")
return nil, fmt.Errorf("player is already connected")
}
player.SetConnected(true)
gameDto := dto.ToGameDto(g, a.cardRegistry, targetPlayerID)
log.Info("Player takeover completed",
zap.String("player_name", player.Name()))
return &PlayerTakeoverResult{
PlayerID: targetPlayerID,
PlayerName: player.Name(),
GameDto: gameDto,
}, nil
}
package connection
import (
"context"
"fmt"
"time"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/shared"
)
// SendChatMessageAction handles sending a chat message in a game.
type SendChatMessageAction struct {
gameRepo game.GameRepository
logger *zap.Logger
}
// NewSendChatMessageAction creates a new SendChatMessageAction.
func NewSendChatMessageAction(
gameRepo game.GameRepository,
logger *zap.Logger,
) *SendChatMessageAction {
return &SendChatMessageAction{
gameRepo: gameRepo,
logger: logger,
}
}
// Execute adds a chat message to the game.
func (a *SendChatMessageAction) Execute(ctx context.Context, gameID, senderID, senderName, senderColor, message string, isSpectator bool) (*shared.ChatMessage, error) {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("sender_name", senderName),
zap.Bool("is_spectator", isSpectator),
zap.String("action", "send_chat_message"),
)
if len(message) == 0 {
return nil, fmt.Errorf("message cannot be empty")
}
if len(message) > shared.MaxChatMessageLength {
return nil, fmt.Errorf("message exceeds maximum length of %d characters", shared.MaxChatMessageLength)
}
g, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
return nil, fmt.Errorf("game not found: %s", gameID)
}
chatMsg := shared.ChatMessage{
SenderID: senderID,
SenderName: senderName,
SenderColor: senderColor,
Message: message,
Timestamp: time.Now(),
IsSpectator: isSpectator,
}
g.AddChatMessage(ctx, chatMsg)
log.Debug("Chat message added")
return &chatMsg, nil
}
package connection
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/shared"
)
// SetPlayerColorAction handles changing a player's color during the lobby phase.
type SetPlayerColorAction struct {
gameRepo game.GameRepository
logger *zap.Logger
}
// NewSetPlayerColorAction creates a new SetPlayerColorAction.
func NewSetPlayerColorAction(
gameRepo game.GameRepository,
logger *zap.Logger,
) *SetPlayerColorAction {
return &SetPlayerColorAction{
gameRepo: gameRepo,
logger: logger,
}
}
// Execute changes a player's color if the game is in lobby and the color is available.
// requesterID is the player making the request, targetPlayerID is the player whose color is being changed.
// A player can change their own color, or the host can change a bot's color.
func (a *SetPlayerColorAction) Execute(ctx context.Context, gameID, requesterID, targetPlayerID, color string) error {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("requester_id", requesterID),
zap.String("target_player_id", targetPlayerID),
zap.String("color", color),
zap.String("action", "set_player_color"),
)
g, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
return fmt.Errorf("game not found: %s", gameID)
}
if g.Status() != shared.GameStatusLobby {
return fmt.Errorf("can only change color during lobby phase")
}
if requesterID != targetPlayerID {
if g.HostPlayerID() != requesterID {
return fmt.Errorf("only the host can change another player's color")
}
target, err := g.GetPlayer(targetPlayerID)
if err != nil {
return fmt.Errorf("player not found: %s", targetPlayerID)
}
if !target.IsBot() {
return fmt.Errorf("can only change bot colors, not other human players")
}
}
if !g.IsPlayerColorAvailable(color, targetPlayerID) {
return fmt.Errorf("color %s is not available", color)
}
p, err := g.GetPlayer(targetPlayerID)
if err != nil {
log.Error("Player not found", zap.Error(err))
return fmt.Errorf("player not found: %s", targetPlayerID)
}
p.SetColor(color)
log.Debug("Player color changed")
return nil
}
package connection
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/shared"
)
// SpectateGameAction handles a spectator joining a game.
type SpectateGameAction struct {
gameRepo game.GameRepository
logger *zap.Logger
}
// NewSpectateGameAction creates a new SpectateGameAction.
func NewSpectateGameAction(
gameRepo game.GameRepository,
logger *zap.Logger,
) *SpectateGameAction {
return &SpectateGameAction{
gameRepo: gameRepo,
logger: logger,
}
}
// SpectateGameResult contains the result of a spectator joining.
type SpectateGameResult struct {
SpectatorID string
}
// Execute adds a spectator to the game.
func (a *SpectateGameAction) Execute(ctx context.Context, gameID, spectatorName, spectatorID string) (*SpectateGameResult, error) {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("spectator_name", spectatorName),
zap.String("spectator_id", spectatorID),
zap.String("action", "spectate_game"),
)
g, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
return nil, fmt.Errorf("game not found: %s", gameID)
}
if g.SpectatorCount() >= shared.MaxSpectators {
return nil, fmt.Errorf("game %s already has the maximum number of spectators (%d)", gameID, shared.MaxSpectators)
}
color := g.NextSpectatorColor()
spectator := game.NewSpectator(spectatorID, spectatorName, color)
if err := g.AddSpectator(ctx, spectator); err != nil {
log.Error("Failed to add spectator", zap.Error(err))
return nil, fmt.Errorf("failed to add spectator: %w", err)
}
log.Info("Spectator joined game")
return &SpectateGameResult{
SpectatorID: spectatorID,
}, nil
}
package connection
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game"
)
// SpectatorDisconnectedAction handles spectator disconnection by removing them from the game.
type SpectatorDisconnectedAction struct {
gameRepo game.GameRepository
logger *zap.Logger
}
// NewSpectatorDisconnectedAction creates a new SpectatorDisconnectedAction.
func NewSpectatorDisconnectedAction(
gameRepo game.GameRepository,
logger *zap.Logger,
) *SpectatorDisconnectedAction {
return &SpectatorDisconnectedAction{
gameRepo: gameRepo,
logger: logger,
}
}
// Execute removes a spectator from the game entirely (spectators are ephemeral).
func (a *SpectatorDisconnectedAction) Execute(ctx context.Context, gameID, spectatorID string) error {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("spectator_id", spectatorID),
zap.String("action", "spectator_disconnected"),
)
g, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Debug("Game not found for spectator disconnect (may be deleted)", zap.Error(err))
return nil
}
if err := g.RemoveSpectator(ctx, spectatorID); err != nil {
log.Debug("Spectator not found during disconnect", zap.Error(err))
return nil
}
log.Debug("Spectator disconnected and removed")
return nil
}
// KickSpectatorAction handles kicking a spectator from a game.
type KickSpectatorAction struct {
gameRepo game.GameRepository
logger *zap.Logger
}
// NewKickSpectatorAction creates a new KickSpectatorAction.
func NewKickSpectatorAction(
gameRepo game.GameRepository,
logger *zap.Logger,
) *KickSpectatorAction {
return &KickSpectatorAction{
gameRepo: gameRepo,
logger: logger,
}
}
// Execute kicks a spectator from the game. Only the host can kick spectators.
func (a *KickSpectatorAction) Execute(ctx context.Context, gameID, requesterID, targetSpectatorID string) error {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("requester_id", requesterID),
zap.String("target_spectator_id", targetSpectatorID),
zap.String("action", "kick_spectator"),
)
g, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
return fmt.Errorf("game not found: %s", gameID)
}
if g.HostPlayerID() != requesterID {
return fmt.Errorf("only the host can kick spectators")
}
if err := g.RemoveSpectator(ctx, targetSpectatorID); err != nil {
log.Error("Failed to remove spectator", zap.Error(err))
return fmt.Errorf("spectator not found: %s", targetSpectatorID)
}
log.Info("Spectator kicked from game")
return nil
}
package game
import (
"context"
"fmt"
"math/rand"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"terraforming-mars-backend/internal/action"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/delivery/dto"
"terraforming-mars-backend/internal/game"
gamecards "terraforming-mars-backend/internal/game/cards"
playerPkg "terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
)
var botNames = []string{
"HAL 9000", "GLaDOS", "SHODAN", "Cortana", "JARVIS",
"Deep Thought", "WOPR", "MU-TH-UR", "Skynet", "Data",
"Bishop", "Ash", "CASE", "TARS", "Marvin",
"R2-D2", "C-3PO", "Wall-E", "Bender", "Sonny",
}
// BotHealthChecker verifies that a Claude API key is valid and generates a greeting.
type BotHealthChecker interface {
CheckHealth(ctx context.Context, apiKey, model, botName, difficulty string) (string, error)
}
// BotBroadcaster broadcasts game state and chat updates.
type BotBroadcaster interface {
BroadcastGameState(gameID string, playerIDs []string)
BroadcastChatMessage(gameID string, chatMsg dto.ChatMessageDto)
}
// AddBotAction handles adding a bot player to a game lobby
type AddBotAction struct {
gameRepo game.GameRepository
cardRegistry cards.CardRegistry
colonyBonusLookup gamecards.ColonyBonusLookup
healthChecker BotHealthChecker
broadcaster BotBroadcaster
logger *zap.Logger
}
// AddBotResult contains the result of adding a bot
type AddBotResult struct {
PlayerID string
GameDto dto.GameDto
}
// NewAddBotAction creates a new add bot action
func NewAddBotAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
healthChecker BotHealthChecker,
broadcaster BotBroadcaster,
logger *zap.Logger,
colonyBonusLookup ...gamecards.ColonyBonusLookup,
) *AddBotAction {
var lookup gamecards.ColonyBonusLookup
if len(colonyBonusLookup) > 0 {
lookup = colonyBonusLookup[0]
}
return &AddBotAction{
gameRepo: gameRepo,
cardRegistry: cardRegistry,
colonyBonusLookup: lookup,
healthChecker: healthChecker,
broadcaster: broadcaster,
logger: logger,
}
}
// Execute adds a bot player to the game lobby
func (a *AddBotAction) Execute(ctx context.Context, gameID string, botName string, difficulty string, speed string) (*AddBotResult, error) {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("bot_name", botName),
zap.String("action", "add_bot"),
)
log.Debug("Adding bot to game")
g, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Game not found", zap.Error(err))
return nil, fmt.Errorf("game not found: %s", gameID)
}
if g.Status() != shared.GameStatusLobby {
log.Warn("Game is not in lobby", zap.String("status", string(g.Status())))
return nil, fmt.Errorf("game is not in lobby: %s", g.Status())
}
if g.Settings().ClaudeAPIKey == "" {
return nil, fmt.Errorf("claude API key is required to add bots (set claudeApiKey in game settings)")
}
existingPlayers := g.GetAllPlayers()
maxPlayers := g.Settings().MaxPlayers
if maxPlayers == 0 {
maxPlayers = game.DefaultMaxPlayers
}
if len(existingPlayers) >= maxPlayers {
return nil, fmt.Errorf("game is full")
}
if botName == "" {
botName = a.generateBotName(existingPlayers)
}
botDifficulty := playerPkg.BotDifficulty(difficulty)
if botDifficulty != playerPkg.BotDifficultyNormal && botDifficulty != playerPkg.BotDifficultyHard && botDifficulty != playerPkg.BotDifficultyExtreme {
botDifficulty = playerPkg.BotDifficultyNormal
}
botSpeed := playerPkg.BotSpeed(speed)
if botSpeed != playerPkg.BotSpeedFast && botSpeed != playerPkg.BotSpeedNormal && botSpeed != playerPkg.BotSpeedThinker {
botSpeed = playerPkg.BotSpeedNormal
}
botID := uuid.New().String()
botPlayer, err := g.AddNewBotPlayer(ctx, botID, botName, botDifficulty, botSpeed)
if err != nil {
log.Error("Failed to add bot to game", zap.Error(err))
return nil, fmt.Errorf("failed to add bot to game: %w", err)
}
action.SetupPlayerCardStore(botPlayer, g, a.cardRegistry, a.colonyBonusLookup)
log.Info("Bot added to game", zap.String("bot_id", botID), zap.String("bot_name", botName))
if a.healthChecker != nil && a.broadcaster != nil {
settings := g.Settings()
go a.runHealthCheck(gameID, botID, botName, difficulty, settings.ClaudeAPIKey, settings.ClaudeModel, log)
}
gameDto := dto.ToGameDto(g, a.cardRegistry, botID)
return &AddBotResult{
PlayerID: botID,
GameDto: gameDto,
}, nil
}
func (a *AddBotAction) runHealthCheck(gameID, botID, botName, difficulty, apiKey, model string, log *zap.Logger) {
log.Debug("Running Claude health check for bot", zap.String("bot_id", botID))
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
greeting, err := a.healthChecker.CheckHealth(ctx, apiKey, model, botName, difficulty)
g, gErr := a.gameRepo.Get(ctx, gameID)
if gErr != nil {
log.Error("Failed to get game after health check", zap.Error(gErr))
return
}
bot, bErr := g.GetPlayer(botID)
if bErr != nil {
log.Error("Bot not found after health check", zap.Error(bErr))
return
}
if err != nil {
log.Error("Health check failed for bot", zap.String("bot_id", botID), zap.Error(err))
bot.SetBotStatus(playerPkg.BotStatusFailed)
a.broadcaster.BroadcastGameState(gameID, nil)
return
}
log.Debug("Bot health check passed", zap.String("bot_id", botID))
bot.SetBotStatus(playerPkg.BotStatusReady)
a.broadcaster.BroadcastGameState(gameID, nil)
if greeting != "" {
chatMsg := shared.ChatMessage{
SenderID: botID,
SenderName: botName,
SenderColor: bot.Color(),
Message: greeting,
Timestamp: time.Now(),
}
g.AddChatMessage(ctx, chatMsg)
a.broadcaster.BroadcastChatMessage(gameID, dto.ChatMessageDto{
SenderID: chatMsg.SenderID,
SenderName: chatMsg.SenderName,
SenderColor: chatMsg.SenderColor,
Message: chatMsg.Message,
Timestamp: chatMsg.Timestamp.Format(time.RFC3339),
})
}
}
func (a *AddBotAction) generateBotName(existingPlayers []*playerPkg.Player) string {
taken := make(map[string]bool, len(existingPlayers))
for _, p := range existingPlayers {
taken[p.Name()] = true
}
// Shuffle and pick the first available name
perm := rand.Perm(len(botNames))
for _, i := range perm {
if !taken[botNames[i]] {
return botNames[i]
}
}
// Fallback if all names taken
for i := 1; ; i++ {
name := fmt.Sprintf("Claude Bot %d", i)
if !taken[name] {
return name
}
}
}
package game
import (
"context"
"fmt"
"go.uber.org/zap"
gamePkg "terraforming-mars-backend/internal/game"
playerPkg "terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
)
// BotStarter starts a bot session for a player.
type BotStarter interface {
StartBot(gameID, playerID, botName, difficulty, speed string, settings shared.GameSettings) error
}
// ConvertToBotAction converts a human player to a bot in an active game.
type ConvertToBotAction struct {
gameRepo gamePkg.GameRepository
botStarter BotStarter
logger *zap.Logger
}
func NewConvertToBotAction(
gameRepo gamePkg.GameRepository,
botStarter BotStarter,
logger *zap.Logger,
) *ConvertToBotAction {
return &ConvertToBotAction{
gameRepo: gameRepo,
botStarter: botStarter,
logger: logger,
}
}
func (a *ConvertToBotAction) Execute(ctx context.Context, gameID string, requesterID string, targetPlayerID string) error {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("requester_id", requesterID),
zap.String("target_player_id", targetPlayerID),
zap.String("action", "convert_to_bot"),
)
g, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
return fmt.Errorf("game not found: %s", gameID)
}
if g.Status() != shared.GameStatusActive {
return fmt.Errorf("game is not active")
}
if g.HostPlayerID() != requesterID {
return fmt.Errorf("only host can convert players to bots")
}
if requesterID == targetPlayerID {
return fmt.Errorf("cannot convert yourself to a bot")
}
target, err := g.GetPlayer(targetPlayerID)
if err != nil {
return fmt.Errorf("player not found: %s", targetPlayerID)
}
if target.IsBot() {
return fmt.Errorf("player is already a bot")
}
if target.HasExited() {
return fmt.Errorf("cannot convert exited player to bot")
}
if g.Settings().ClaudeAPIKey == "" {
return fmt.Errorf("claude API key is required to convert players to bots")
}
log.Debug("Converting player to bot", zap.String("player_name", target.Name()))
target.SetPlayerType(playerPkg.PlayerTypeBot)
target.SetBotDifficulty(playerPkg.BotDifficultyNormal)
target.SetBotSpeed(playerPkg.BotSpeedFast)
target.SetBotStatus(playerPkg.BotStatusLoading)
target.SetConnected(true)
if a.botStarter != nil {
settings := g.Settings()
if err := a.botStarter.StartBot(gameID, targetPlayerID, target.Name(), string(playerPkg.BotDifficultyNormal), string(playerPkg.BotSpeedFast), settings); err != nil {
log.Error("Failed to start bot session", zap.Error(err))
target.SetBotStatus(playerPkg.BotStatusFailed)
}
}
log.Info("Player converted to bot", zap.String("player_name", target.Name()))
return nil
}
package game
import (
"context"
"slices"
"github.com/google/uuid"
"go.uber.org/zap"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/shared"
)
// CreateGameAction handles the business logic for creating new games
type CreateGameAction struct {
gameRepo game.GameRepository
cardRegistry cards.CardRegistry
logger *zap.Logger
}
// NewCreateGameAction creates a new create game action
func NewCreateGameAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
logger *zap.Logger,
) *CreateGameAction {
return &CreateGameAction{
gameRepo: gameRepo,
cardRegistry: cardRegistry,
logger: logger,
}
}
// Execute performs the create game action
func (a *CreateGameAction) Execute(
ctx context.Context,
settings shared.GameSettings,
) (*game.Game, error) {
log := a.logger.With(
zap.Int("max_players", settings.MaxPlayers),
zap.Strings("card_packs", settings.CardPacks),
)
log.Debug("Creating new game")
// 1. Generate game ID
gameID := uuid.New().String()
// 2. Apply default settings
if settings.MaxPlayers == 0 {
settings.MaxPlayers = game.DefaultMaxPlayers
}
if len(settings.CardPacks) == 0 {
settings.CardPacks = shared.DefaultCardPacks()
}
if settings.VenusNextEnabled && !slices.Contains(settings.CardPacks, shared.PackVenus) {
settings.CardPacks = append(settings.CardPacks, shared.PackVenus)
}
// 3. Create game entity
// Note: hostPlayerID is empty initially, will be set when first player joins
// Board is automatically created by NewGame
// EventBus is created per-game for synchronous event handling
newGame := game.NewGame(a.gameRepo.DataStore(), gameID, "", settings)
// 4. Initialize deck with cards from selected packs
projectCardIDs, corpIDs, preludeIDs := cards.GetCardIDsByPacks(a.cardRegistry, settings.CardPacks)
newGame.InitDeck(projectCardIDs, corpIDs, preludeIDs)
newGame.SetVPCardLookup(cards.NewVPCardLookupAdapter(a.cardRegistry))
log.Debug("Deck initialized",
zap.Int("project_cards", len(projectCardIDs)),
zap.Int("corporations", len(corpIDs)),
zap.Int("preludes", len(preludeIDs)),
zap.Strings("first_5_corps", getFirst5(corpIDs)))
// 5. Store game in repository
err := a.gameRepo.Create(ctx, newGame)
if err != nil {
log.Error("Failed to create game", zap.Error(err))
return nil, err
}
log.Info("Game created", zap.String("game_id", gameID))
return newGame, nil
}
// getFirst5 returns up to the first 5 elements of a slice (for logging)
func getFirst5(ids []string) []string {
if len(ids) <= 5 {
return ids
}
return ids[:5]
}
package game
import (
"context"
"sort"
"time"
"go.uber.org/zap"
"terraforming-mars-backend/internal/awards"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game"
gamecards "terraforming-mars-backend/internal/game/cards"
"terraforming-mars-backend/internal/game/shared"
"terraforming-mars-backend/internal/milestones"
)
// FinalScoringAction handles the business logic for calculating final scores and ending the game
type FinalScoringAction struct {
gameRepo game.GameRepository
cardRegistry cards.CardRegistry
awardRegistry awards.AwardRegistry
milestoneRegistry milestones.MilestoneRegistry
logger *zap.Logger
}
// NewFinalScoringAction creates a new final scoring action
func NewFinalScoringAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
awardRegistry awards.AwardRegistry,
milestoneRegistry milestones.MilestoneRegistry,
logger *zap.Logger,
) *FinalScoringAction {
return &FinalScoringAction{
gameRepo: gameRepo,
cardRegistry: cardRegistry,
awardRegistry: awardRegistry,
milestoneRegistry: milestoneRegistry,
logger: logger,
}
}
// PlayerScore holds a player's score with breakdown for sorting
type PlayerScore struct {
PlayerID string
PlayerName string
Breakdown gamecards.VPBreakdown
Credits int // For tiebreaker
}
// Execute performs the final scoring action
func (a *FinalScoringAction) Execute(ctx context.Context, gameID string) error {
log := a.logger.With(zap.String("game_id", gameID))
log.Debug("Starting final scoring")
// 1. Fetch game
g, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
return err
}
// 2. Validate game is active
if g.Status() != shared.GameStatusActive {
log.Warn("Game is not active, skipping final scoring", zap.String("status", string(g.Status())))
return nil
}
// 3. Get all players
allPlayers := g.GetAllPlayers()
if len(allPlayers) == 0 {
log.Warn("No players in game")
return nil
}
// 4. Prepare milestone and award data for VP calculation
claimedMilestones := convertToClaimedMilestoneInfo(g.Milestones().ClaimedMilestones())
fundedAwards := convertToFundedAwardInfo(g.Awards().FundedAwards())
// 5. Calculate VP for each player
scores := make([]PlayerScore, len(allPlayers))
for i, p := range allPlayers {
breakdown := gamecards.CalculatePlayerVP(
p,
g,
claimedMilestones,
fundedAwards,
allPlayers,
a.cardRegistry,
a.awardRegistry,
a.milestoneRegistry,
)
scores[i] = PlayerScore{
PlayerID: p.ID(),
PlayerName: p.Name(),
Breakdown: breakdown,
Credits: p.Resources().Get().Credits,
}
log.Debug("Player VP calculated",
zap.String("player_id", p.ID()),
zap.String("player_name", p.Name()),
zap.Int("total_vp", breakdown.TotalVP),
zap.Int("tr", breakdown.TerraformRating),
zap.Int("card_vp", breakdown.CardVP),
zap.Int("milestone_vp", breakdown.MilestoneVP),
zap.Int("award_vp", breakdown.AwardVP),
zap.Int("greenery_vp", breakdown.GreeneryVP),
zap.Int("city_vp", breakdown.CityVP),
)
}
// 6. Sort by total VP (descending), then credits (descending) for tiebreaker
sort.Slice(scores, func(i, j int) bool {
if scores[i].Breakdown.TotalVP != scores[j].Breakdown.TotalVP {
return scores[i].Breakdown.TotalVP > scores[j].Breakdown.TotalVP
}
return scores[i].Credits > scores[j].Credits
})
// 7. Determine winner and check for ties
winnerID := scores[0].PlayerID
isTie := false
if len(scores) > 1 {
// Check if top players have same VP and credits (true tie)
if scores[0].Breakdown.TotalVP == scores[1].Breakdown.TotalVP &&
scores[0].Credits == scores[1].Credits {
isTie = true
}
}
log.Debug("Winner determined",
zap.String("winner_id", winnerID),
zap.String("winner_name", scores[0].PlayerName),
zap.Int("winning_vp", scores[0].Breakdown.TotalVP),
zap.Bool("is_tie", isTie),
)
// 8. Convert to shared.FinalScore and store in game
finalScores := make([]shared.FinalScore, len(scores))
for i, s := range scores {
finalScores[i] = shared.FinalScore{
PlayerID: s.PlayerID,
PlayerName: s.PlayerName,
Breakdown: shared.VPBreakdown{
TerraformRating: s.Breakdown.TerraformRating,
CardVP: s.Breakdown.CardVP,
CardVPDetails: convertCardVPDetails(s.Breakdown.CardVPDetails),
MilestoneVP: s.Breakdown.MilestoneVP,
AwardVP: s.Breakdown.AwardVP,
GreeneryVP: s.Breakdown.GreeneryVP,
GreeneryVPDetails: convertGreeneryVPDetails(s.Breakdown.GreeneryVPDetails),
CityVP: s.Breakdown.CityVP,
CityVPDetails: convertCityVPDetails(s.Breakdown.CityVPDetails),
TotalVP: s.Breakdown.TotalVP,
},
Credits: s.Credits,
Placement: i + 1, // 1-indexed placement
IsWinner: s.PlayerID == winnerID,
}
}
err = g.SetFinalScores(ctx, finalScores, winnerID, isTie)
if err != nil {
log.Error("Failed to set final scores", zap.Error(err))
return err
}
// 9. Update game status to completed
err = g.UpdateStatus(ctx, shared.GameStatusCompleted)
if err != nil {
log.Error("Failed to update game status", zap.Error(err))
return err
}
// 10. Update game phase to complete
err = g.UpdatePhase(ctx, shared.GamePhaseComplete)
if err != nil {
log.Error("Failed to update game phase", zap.Error(err))
return err
}
// 11. Publish GameEndedEvent
events.Publish(g.EventBus(), events.GameEndedEvent{
GameID: gameID,
WinnerID: winnerID,
IsTie: isTie,
Timestamp: time.Now(),
})
log.Info("Final scoring complete, game ended")
return nil
}
// convertToClaimedMilestoneInfo converts game milestones to the format expected by VP calculator
func convertToClaimedMilestoneInfo(claimed []shared.ClaimedMilestone) []gamecards.ClaimedMilestoneInfo {
result := make([]gamecards.ClaimedMilestoneInfo, len(claimed))
for i, m := range claimed {
result[i] = gamecards.ClaimedMilestoneInfo{
Type: string(m.Type),
PlayerID: m.PlayerID,
}
}
return result
}
// convertToFundedAwardInfo converts game awards to the format expected by VP calculator
func convertToFundedAwardInfo(funded []shared.FundedAward) []gamecards.FundedAwardInfo {
result := make([]gamecards.FundedAwardInfo, len(funded))
for i, a := range funded {
result[i] = gamecards.FundedAwardInfo{
Type: string(a.Type),
}
}
return result
}
// convertCardVPDetails converts gamecards.CardVPDetail to shared.CardVPDetail
func convertCardVPDetails(details []gamecards.CardVPDetail) []shared.CardVPDetail {
result := make([]shared.CardVPDetail, len(details))
for i, d := range details {
conditions := make([]shared.CardVPConditionDetail, len(d.Conditions))
for j, c := range d.Conditions {
conditions[j] = shared.CardVPConditionDetail{
ConditionType: c.ConditionType,
Amount: c.Amount,
Count: c.Count,
MaxTrigger: c.MaxTrigger,
ActualTriggers: c.ActualTriggers,
TotalVP: c.TotalVP,
Explanation: c.Explanation,
}
}
result[i] = shared.CardVPDetail{
CardID: d.CardID,
CardName: d.CardName,
Conditions: conditions,
TotalVP: d.TotalVP,
}
}
return result
}
// convertGreeneryVPDetails converts gamecards.GreeneryVPDetail to shared.GreeneryVPDetail
func convertGreeneryVPDetails(details []gamecards.GreeneryVPDetail) []shared.GreeneryVPDetail {
result := make([]shared.GreeneryVPDetail, len(details))
for i, d := range details {
result[i] = shared.GreeneryVPDetail{
Coordinate: d.Coordinate,
VP: d.VP,
}
}
return result
}
// convertCityVPDetails converts gamecards.CityVPDetail to shared.CityVPDetail
func convertCityVPDetails(details []gamecards.CityVPDetail) []shared.CityVPDetail {
result := make([]shared.CityVPDetail, len(details))
for i, d := range details {
result[i] = shared.CityVPDetail{
CityCoordinate: d.CityCoordinate,
AdjacentGreeneries: d.AdjacentGreeneries,
VP: d.VP,
}
}
return result
}
package game
import (
"context"
"fmt"
"terraforming-mars-backend/internal/action"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/delivery/dto"
"terraforming-mars-backend/internal/game"
gamecards "terraforming-mars-backend/internal/game/cards"
"terraforming-mars-backend/internal/game/shared"
"go.uber.org/zap"
)
// JoinGameAction handles players joining games
// New architecture: Uses only GameRepository + logger, events handle broadcasting
type JoinGameAction struct {
gameRepo game.GameRepository
cardRegistry cards.CardRegistry
colonyBonusLookup gamecards.ColonyBonusLookup
logger *zap.Logger
}
// JoinGameResult contains the result of joining a game
type JoinGameResult struct {
PlayerID string
GameDto dto.GameDto
}
// NewJoinGameAction creates a new join game action
func NewJoinGameAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
logger *zap.Logger,
colonyBonusLookup ...gamecards.ColonyBonusLookup,
) *JoinGameAction {
var lookup gamecards.ColonyBonusLookup
if len(colonyBonusLookup) > 0 {
lookup = colonyBonusLookup[0]
}
return &JoinGameAction{
gameRepo: gameRepo,
cardRegistry: cardRegistry,
colonyBonusLookup: lookup,
logger: logger,
}
}
// Execute performs the join game action
// playerID is required and must be generated at handler level for proper connection registration
func (a *JoinGameAction) Execute(
ctx context.Context,
gameID string,
playerName string,
playerID string,
) (*JoinGameResult, error) {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("player_name", playerName),
)
log.Debug("Player joining game")
// 1. Fetch game from repository
g, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Game not found", zap.Error(err))
return nil, fmt.Errorf("game not found: %w", err)
}
// 2. Check for reconnection (playerID provided and player exists in game)
existingPlayer, err := g.GetPlayer(playerID)
if err == nil && existingPlayer != nil {
// Reconnection case - skip lobby check, just update connection status
log.Debug("Player reconnecting", zap.String("player_id", playerID))
existingPlayer.SetConnected(true)
gameDto := dto.ToGameDto(g, a.cardRegistry, playerID)
return &JoinGameResult{
PlayerID: playerID,
GameDto: gameDto,
}, nil
}
// 3. Validate game is in lobby status (only for new joins)
if g.Status() != shared.GameStatusLobby {
log.Warn("Game is not in lobby", zap.String("status", string(g.Status())))
return nil, fmt.Errorf("game is not in lobby: %s", g.Status())
}
// 4. Check if player with same name already exists (idempotent join)
existingPlayers := g.GetAllPlayers()
for _, p := range existingPlayers {
if p.Name() == playerName {
log.Debug("Player already exists, returning existing ID",
zap.String("player_id", p.ID()))
// Return the existing game state with personalized view
gameDto := dto.ToGameDto(g, a.cardRegistry, p.ID())
return &JoinGameResult{
PlayerID: p.ID(),
GameDto: gameDto,
}, nil
}
}
// 5. Check max players only for new players
maxPlayers := g.Settings().MaxPlayers
if maxPlayers == 0 {
maxPlayers = game.DefaultMaxPlayers
}
if len(existingPlayers) >= maxPlayers {
log.Error("Game is full", zap.Int("max_players", maxPlayers))
return nil, fmt.Errorf("game is full")
}
// 6. Check if this will be the first player (before adding)
isFirstPlayer := len(existingPlayers) == 0
// 7. If first player, set as host BEFORE adding (so auto-broadcast includes hostPlayerID)
if isFirstPlayer {
err = g.SetHostPlayerID(ctx, playerID)
if err != nil {
log.Error("Failed to set host player", zap.Error(err))
return nil, fmt.Errorf("failed to set host player: %w", err)
}
log.Debug("Player set as host")
}
// 8. Create and add player to game (publishes PlayerJoinedEvent which auto-broadcasts)
newPlayer, err := g.AddNewPlayer(ctx, playerID, playerName)
if err != nil {
log.Error("Failed to add player to game", zap.Error(err))
return nil, fmt.Errorf("failed to add player to game: %w", err)
}
action.SetupPlayerCardStore(newPlayer, g, a.cardRegistry, a.colonyBonusLookup)
log.Debug("Player added to game")
// 10. Convert to DTO with personalized view for the joining player
gameDto := dto.ToGameDto(g, a.cardRegistry, newPlayer.ID())
// Note: Broadcasting handled automatically via PlayerJoinedEvent
// g.AddPlayer() publishes event → SessionManager subscribes → broadcasts
log.Info("Player joined game")
return &JoinGameResult{
PlayerID: newPlayer.ID(),
GameDto: gameDto,
}, nil
}
package game
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/delivery/dto"
internalgame "terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/shared"
)
// SelectDemoChoicesAction handles a player selecting cards during the demo lobby phase
type SelectDemoChoicesAction struct {
gameRepo internalgame.GameRepository
cardRegistry cards.CardRegistry
logger *zap.Logger
}
// NewSelectDemoChoicesAction creates a new select demo choices action
func NewSelectDemoChoicesAction(
gameRepo internalgame.GameRepository,
cardRegistry cards.CardRegistry,
logger *zap.Logger,
) *SelectDemoChoicesAction {
return &SelectDemoChoicesAction{
gameRepo: gameRepo,
cardRegistry: cardRegistry,
logger: logger,
}
}
// Execute validates and stores a player's demo lobby card selections
func (a *SelectDemoChoicesAction) Execute(
ctx context.Context,
gameID string,
playerID string,
request *dto.SelectDemoChoicesRequest,
) error {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("player_id", playerID),
zap.String("action", "select_demo_choices"),
)
log.Debug("Player selecting demo choices")
g, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
return fmt.Errorf("game not found: %s", gameID)
}
if g.Status() != shared.GameStatusLobby {
return fmt.Errorf("game is not in lobby: %s", g.Status())
}
if !g.Settings().DemoGame {
return fmt.Errorf("game is not a demo game")
}
p, err := g.GetPlayer(playerID)
if err != nil {
return fmt.Errorf("player not found: %s", playerID)
}
if request.CorporationID == "" {
return fmt.Errorf("corporation ID is required")
}
corpCard, err := a.cardRegistry.GetByID(request.CorporationID)
if err != nil {
return fmt.Errorf("corporation not found: %s", request.CorporationID)
}
if corpCard.Type != "corporation" {
return fmt.Errorf("card %s is not a corporation", request.CorporationID)
}
settings := g.Settings()
if settings.HasPrelude() {
if len(request.PreludeIDs) != 2 {
return fmt.Errorf("must select exactly 2 prelude cards, got %d", len(request.PreludeIDs))
}
for _, id := range request.PreludeIDs {
card, err := a.cardRegistry.GetByID(id)
if err != nil {
return fmt.Errorf("prelude card not found: %s", id)
}
if card.Type != "prelude" {
return fmt.Errorf("card %s is not a prelude", id)
}
}
} else if len(request.PreludeIDs) > 0 {
return fmt.Errorf("prelude cards not enabled for this game")
}
for _, id := range request.CardIDs {
card, err := a.cardRegistry.GetByID(id)
if err != nil {
return fmt.Errorf("card not found: %s", id)
}
if card.Type == "corporation" || card.Type == "prelude" {
return fmt.Errorf("card %s is a %s, not a project card", id, card.Type)
}
}
p.SetPendingDemoChoices(&shared.PendingDemoChoices{
CorporationID: request.CorporationID,
PreludeIDs: request.PreludeIDs,
CardIDs: request.CardIDs,
Resources: shared.Resources{
Credits: request.Resources.Credits,
Steel: request.Resources.Steel,
Titanium: request.Resources.Titanium,
Plants: request.Resources.Plants,
Energy: request.Resources.Energy,
Heat: request.Resources.Heat,
},
Production: shared.Production{
Credits: request.Production.Credits,
Steel: request.Production.Steel,
Titanium: request.Production.Titanium,
Plants: request.Production.Plants,
Energy: request.Production.Energy,
Heat: request.Production.Heat,
},
TerraformRating: request.TerraformRating,
})
isHost := g.HostPlayerID() == playerID
if isHost && request.GlobalParameters != nil {
settings.Temperature = &request.GlobalParameters.Temperature
settings.Oxygen = &request.GlobalParameters.Oxygen
settings.Oceans = &request.GlobalParameters.Oceans
}
if isHost && request.Generation != nil {
settings.Generation = request.Generation
}
if isHost && (request.GlobalParameters != nil || request.Generation != nil) {
g.UpdateSettings(ctx, settings)
}
log.Info("Demo choices selected",
zap.String("corporation", corpCard.Name),
zap.Int("prelude_count", len(request.PreludeIDs)),
zap.Int("card_count", len(request.CardIDs)))
return nil
}
package milestone
import (
"context"
"fmt"
"slices"
"go.uber.org/zap"
baseaction "terraforming-mars-backend/internal/action"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/game"
gamecards "terraforming-mars-backend/internal/game/cards"
"terraforming-mars-backend/internal/game/shared"
"terraforming-mars-backend/internal/milestones"
)
// ClaimMilestoneAction handles the business logic for claiming a milestone
type ClaimMilestoneAction struct {
baseaction.BaseAction
milestoneRegistry milestones.MilestoneRegistry
}
// NewClaimMilestoneAction creates a new claim milestone action
func NewClaimMilestoneAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
stateRepo game.GameStateRepository,
milestoneRegistry milestones.MilestoneRegistry,
logger *zap.Logger,
) *ClaimMilestoneAction {
return &ClaimMilestoneAction{
BaseAction: baseaction.NewBaseActionWithStateRepo(gameRepo, cardRegistry, stateRepo),
milestoneRegistry: milestoneRegistry,
}
}
// Execute claims a milestone for the player
func (a *ClaimMilestoneAction) Execute(ctx context.Context, gameID string, playerID string, milestoneType string) error {
log := a.InitLogger(gameID, playerID).With(zap.String("action", "claim_milestone"), zap.String("milestone", milestoneType))
log.Debug("Claiming milestone")
def, err := a.milestoneRegistry.GetByID(milestoneType)
if err != nil {
log.Warn("Invalid milestone type", zap.String("milestone_type", milestoneType))
return fmt.Errorf("invalid milestone type: %s", milestoneType)
}
g, err := baseaction.ValidateActiveGame(ctx, a.GameRepository(), gameID, log)
if err != nil {
return err
}
if err := baseaction.ValidateGamePhase(g, shared.GamePhaseAction, log); err != nil {
return err
}
if err := baseaction.ValidateCurrentTurn(g, playerID, log); err != nil {
return err
}
if err := baseaction.ValidateActionsRemaining(g, playerID, log); err != nil {
return err
}
if err := baseaction.ValidateNoPendingSelections(g, playerID, log); err != nil {
return err
}
player, err := a.GetPlayerFromGame(g, playerID, log)
if err != nil {
return err
}
// Validate milestone is in the selected set for this game
if selected := g.SelectedMilestones(); len(selected) > 0 && !slices.Contains(selected, milestoneType) {
log.Warn("Milestone not available in this game", zap.String("milestone", milestoneType))
return fmt.Errorf("milestone %s is not available in this game", milestoneType)
}
ms := g.Milestones()
mt := shared.MilestoneType(milestoneType)
if ms.IsClaimed(mt) {
log.Warn("Milestone already claimed", zap.String("milestone", milestoneType))
return fmt.Errorf("milestone %s is already claimed", milestoneType)
}
if !ms.CanClaimMore() {
log.Warn("Maximum milestones already claimed", zap.Int("max", game.MaxClaimedMilestones))
return fmt.Errorf("maximum milestones (%d) already claimed", game.MaxClaimedMilestones)
}
resources := player.Resources().Get()
if resources.Credits < def.ClaimCost {
log.Warn("Insufficient credits for milestone",
zap.Int("cost", def.ClaimCost),
zap.Int("player_credits", resources.Credits))
return fmt.Errorf("insufficient credits: need %d, have %d", def.ClaimCost, resources.Credits)
}
if !gamecards.CanClaimMilestone(def, player, g.Board(), a.CardRegistry()) {
progress := gamecards.CalculateMilestoneProgress(def, player, g.Board(), a.CardRegistry())
required := def.GetRequired()
log.Warn("Player does not meet milestone requirements",
zap.String("requirement", def.Description),
zap.Int("required", required),
zap.Int("current", progress))
return fmt.Errorf("requirements not met: %s (have %d, need %d)", def.Description, progress, required)
}
player.Resources().Add(map[shared.ResourceType]int{
shared.ResourceCredit: -def.ClaimCost,
})
log.Debug("Deducted milestone cost",
zap.Int("cost", def.ClaimCost),
zap.Int("remaining_credits", player.Resources().Get().Credits))
if err := ms.ClaimMilestone(ctx, mt, playerID, g.Generation()); err != nil {
log.Error("Failed to claim milestone", zap.Error(err))
return fmt.Errorf("failed to claim milestone: %w", err)
}
a.ConsumePlayerAction(g, log)
a.WriteStateLog(ctx, g, def.Name, shared.SourceTypeMilestone, playerID, fmt.Sprintf("Claimed %s milestone", def.Name))
log.Info("Milestone claimed",
zap.String("milestone", milestoneType),
zap.Int("total_claimed", ms.ClaimedCount()))
return nil
}
package action
import (
"context"
"slices"
"go.uber.org/zap"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game"
gamecards "terraforming-mars-backend/internal/game/cards"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
)
// SubscribePassiveEffectToEvents subscribes passive effects to relevant domain events
// This function is called when cards with passive effects are played or corporations are selected
func SubscribePassiveEffectToEvents(
ctx context.Context,
g *game.Game,
p *player.Player,
effect shared.CardEffect,
log *zap.Logger,
cardRegistry ...cards.CardRegistry,
) {
var cr cards.CardRegistry
if len(cardRegistry) > 0 {
cr = cardRegistry[0]
}
for _, trigger := range effect.Behavior.Triggers {
if trigger.Condition == nil || (trigger.Type != string(gamecards.ResourceTriggerAuto) && trigger.Type != string(gamecards.ResourceTriggerAutoCorporationStart)) {
continue
}
var subID events.SubscriptionID
// Handle placement-bonus-gained trigger
if trigger.Condition.Type == "placement-bonus-gained" {
subID = subscribePlacementBonusEffect(ctx, g, p, effect, trigger, log, cr)
}
// Handle city-placed trigger
if trigger.Condition.Type == "city-placed" {
subID = subscribeCityPlacedEffect(ctx, g, p, effect, trigger, log, cr)
}
// Handle ocean-placed trigger
if trigger.Condition.Type == "ocean-placed" {
subID = subscribeOceanPlacedEffect(ctx, g, p, effect, trigger, log, cr)
}
// Handle tag-played trigger
if trigger.Condition.Type == "tag-played" {
subID = subscribeTagPlayedEffect(ctx, g, p, effect, trigger, log, cr)
}
// Handle card-played trigger with selectors
if trigger.Condition.Type == "card-played" {
subID = subscribeCardPlayedEffect(ctx, g, p, effect, trigger, log, cr)
}
// Handle standard-project-played trigger
if trigger.Condition.Type == "standard-project-played" {
subID = subscribeStandardProjectPlayedEffect(ctx, g, p, effect, trigger, log, cr)
}
// Handle tile-placed trigger (production based on placement bonus type)
if trigger.Condition.Type == "tile-placed" {
subID = subscribeTilePlacedEffect(ctx, g, p, effect, trigger, log, cr)
}
// Handle global-parameter-raised trigger
if trigger.Condition.Type == "global-parameter-raised" {
subID = subscribeGlobalParameterRaisedEffect(ctx, g, p, effect, trigger, log, cr)
}
// Handle production-increased trigger
if trigger.Condition.Type == "production-increased" {
subID = subscribeProductionIncreasedEffect(ctx, g, p, effect, trigger, log, cr)
}
// Handle colony-placed trigger
if trigger.Condition.Type == "colony-placed" {
subID = subscribeColonyPlacedEffect(ctx, g, p, effect, trigger, log, cr)
}
// Register subscription for cleanup when effect is removed
if subID != "" {
p.Effects().RegisterSubscription(effect.CardID, subID)
}
}
}
// subscribePlacementBonusEffect subscribes to PlacementBonusGainedEvent
func subscribePlacementBonusEffect(
_ context.Context,
g *game.Game,
p *player.Player,
effect shared.CardEffect,
trigger shared.Trigger,
log *zap.Logger,
cr cards.CardRegistry,
) events.SubscriptionID {
subID := events.Subscribe(g.EventBus(), func(event events.PlacementBonusGainedEvent) {
// Only process if event is for this game and player
if event.GameID != g.ID() {
return
}
// Check target condition (self-player, any-player, etc.)
target := "self-player" // Default
if trigger.Condition.Target != nil {
target = *trigger.Condition.Target
}
if target == "self-player" && event.PlayerID != p.ID() {
return // Effect only applies to self
}
// Check if selectors match the bonus resources
if len(trigger.Condition.Selectors) > 0 {
matchFound := false
for _, sel := range trigger.Condition.Selectors {
for _, resource := range sel.Resources {
if _, exists := event.Resources[resource]; exists {
matchFound = true
break
}
}
if matchFound {
break
}
}
if !matchFound {
return // No matching resources in the bonus
}
}
// Condition matched! Apply the effect outputs using BehaviorApplier
log.Debug("Passive effect triggered",
zap.String("card_name", effect.CardName),
zap.String("trigger_type", trigger.Condition.Type),
zap.Any("resources_gained", event.Resources))
applier := gamecards.NewBehaviorApplier(p, g, effect.CardName, log).
WithSourceCardID(effect.CardID).
WithCardRegistry(cr).
WithSourceType(shared.SourceTypePassiveEffect)
if err := applier.ApplyOutputs(context.Background(), effect.Behavior.Outputs); err != nil {
log.Error("Failed to apply passive effect outputs",
zap.String("card_name", effect.CardName),
zap.Error(err))
}
})
log.Debug("Subscribed passive effect to PlacementBonusGainedEvent",
zap.String("card_name", effect.CardName))
return subID
}
// subscribeCityPlacedEffect subscribes to TilePlacedEvent for city placements
func subscribeCityPlacedEffect(
_ context.Context,
g *game.Game,
p *player.Player,
effect shared.CardEffect,
trigger shared.Trigger,
log *zap.Logger,
cr cards.CardRegistry,
) events.SubscriptionID {
subID := events.Subscribe(g.EventBus(), func(event events.TilePlacedEvent) {
// Only process if event is for this game
if event.GameID != g.ID() {
return
}
// Only process city tile placements
if event.TileType != string(shared.ResourceCityTile) {
return
}
// Check target condition (self-player, any-player, etc.)
target := "self-player" // Default
if trigger.Condition.Target != nil {
target = *trigger.Condition.Target
}
if target == "self-player" && event.PlayerID != p.ID() {
return // Effect only applies to self
}
// Check location condition
location := "anywhere" // Default
if trigger.Condition.Location != nil {
location = *trigger.Condition.Location
}
// For now, we treat all tile placements as "mars" or "anywhere"
// Future: implement Phobos/colony distinction if needed
if location != "anywhere" && location != "mars" {
return // Location doesn't match
}
// Condition matched! Apply the effect outputs using BehaviorApplier
log.Debug("Passive effect triggered (city placement)",
zap.String("card_name", effect.CardName),
zap.String("player_id", p.ID()),
zap.String("placed_by", event.PlayerID),
zap.String("tile_type", event.TileType))
applier := gamecards.NewBehaviorApplier(p, g, effect.CardName, log).
WithSourceCardID(effect.CardID).
WithCardRegistry(cr).
WithSourceType(shared.SourceTypePassiveEffect)
if err := applier.ApplyOutputs(context.Background(), effect.Behavior.Outputs); err != nil {
log.Error("Failed to apply passive effect outputs",
zap.String("card_name", effect.CardName),
zap.Error(err))
}
})
log.Debug("Subscribed passive effect to TilePlacedEvent (city)",
zap.String("card_name", effect.CardName))
return subID
}
// subscribeOceanPlacedEffect subscribes to TilePlacedEvent for ocean placements
func subscribeOceanPlacedEffect(
_ context.Context,
g *game.Game,
p *player.Player,
effect shared.CardEffect,
trigger shared.Trigger,
log *zap.Logger,
cr gamecards.CardRegistryInterface,
) events.SubscriptionID {
subID := events.Subscribe(g.EventBus(), func(event events.TilePlacedEvent) {
if event.GameID != g.ID() {
return
}
if event.TileType != string(shared.ResourceOceanTile) {
return
}
target := "self-player"
if trigger.Condition.Target != nil {
target = *trigger.Condition.Target
}
if target == "self-player" && event.PlayerID != p.ID() {
return
}
location := "anywhere"
if trigger.Condition.Location != nil {
location = *trigger.Condition.Location
}
if location != "anywhere" && location != "mars" {
return
}
log.Debug("Passive effect triggered (ocean placement)",
zap.String("card_name", effect.CardName),
zap.String("player_id", p.ID()),
zap.String("placed_by", event.PlayerID),
zap.String("tile_type", event.TileType))
applier := gamecards.NewBehaviorApplier(p, g, effect.CardName, log).
WithSourceCardID(effect.CardID).
WithCardRegistry(cr).
WithSourceType(shared.SourceTypePassiveEffect)
if err := applier.ApplyOutputs(context.Background(), effect.Behavior.Outputs); err != nil {
log.Error("Failed to apply passive effect outputs",
zap.String("card_name", effect.CardName),
zap.Error(err))
}
})
log.Debug("Subscribed passive effect to TilePlacedEvent (ocean)",
zap.String("card_name", effect.CardName))
return subID
}
func subscribeTagPlayedEffect(
_ context.Context,
g *game.Game,
p *player.Player,
effect shared.CardEffect,
trigger shared.Trigger,
log *zap.Logger,
cr cards.CardRegistry,
) events.SubscriptionID {
subID := events.Subscribe(g.EventBus(), func(event events.TagPlayedEvent) {
if event.GameID != g.ID() {
return
}
target := "self-player"
if trigger.Condition.Target != nil {
target = *trigger.Condition.Target
}
if target == "self-player" && event.PlayerID != p.ID() {
return
}
// Check if selectors match the played tag
if len(trigger.Condition.Selectors) > 0 {
matchFound := false
for _, sel := range trigger.Condition.Selectors {
for _, tag := range sel.Tags {
if string(tag) == event.Tag {
matchFound = true
break
}
}
if matchFound {
break
}
}
if !matchFound {
return
}
}
if trigger.Condition.Unique && cr != nil {
tag := shared.CardTag(event.Tag)
if tag == shared.TagWild {
return
}
count := gamecards.CountPlayerTagsByType(p, cr, tag)
if count != 1 {
return
}
}
log.Debug("Passive effect triggered (tag played)",
zap.String("card_name", effect.CardName),
zap.String("effect_owner", p.ID()),
zap.String("tag_played_by", event.PlayerID),
zap.String("tag", event.Tag))
// Check if this effect requires card-discard input (e.g., Mars University)
if gamecards.HasCardDiscardInput(effect.Behavior) {
createPassiveCardDiscard(p, effect, log)
return
}
// Check if this effect has choices requiring player selection (e.g., Olympus Conference, Viral Enhancers)
if gamecards.HasChoices(effect.Behavior) {
createPassiveBehaviorChoice(p, effect, log)
return
}
applier := gamecards.NewBehaviorApplier(p, g, effect.CardName, log).
WithSourceCardID(effect.CardID).
WithCardRegistry(cr).
WithSourceType(shared.SourceTypePassiveEffect)
if err := applier.ApplyOutputs(context.Background(), effect.Behavior.Outputs); err != nil {
log.Error("Failed to apply passive effect outputs",
zap.String("card_name", effect.CardName),
zap.Error(err))
}
})
log.Debug("Subscribed passive effect to TagPlayedEvent",
zap.String("card_name", effect.CardName))
return subID
}
func subscribeCardPlayedEffect(
_ context.Context,
g *game.Game,
p *player.Player,
effect shared.CardEffect,
trigger shared.Trigger,
log *zap.Logger,
cr cards.CardRegistry,
) events.SubscriptionID {
subID := events.Subscribe(g.EventBus(), func(event events.CardPlayedEvent) {
if event.GameID != g.ID() {
return
}
target := "self-player"
if trigger.Condition.Target != nil {
target = *trigger.Condition.Target
}
if target == "self-player" && event.PlayerID != p.ID() {
return
}
if cr == nil {
return
}
card, err := cr.GetByID(event.CardID)
if err != nil {
return
}
// Check if the card matches any selector
if len(trigger.Condition.Selectors) > 0 {
if !gamecards.MatchesAnySelector(card, trigger.Condition.Selectors) {
return
}
}
log.Debug("Passive effect triggered (card played)",
zap.String("card_name", effect.CardName),
zap.String("effect_owner", p.ID()),
zap.String("card_played_by", event.PlayerID),
zap.String("card_played", event.CardName))
if gamecards.HasChoices(effect.Behavior) {
createPassiveBehaviorChoice(p, effect, log)
return
}
applier := gamecards.NewBehaviorApplier(p, g, effect.CardName, log).
WithSourceCardID(effect.CardID).
WithCardRegistry(cr).
WithSourceType(shared.SourceTypePassiveEffect)
if err := applier.ApplyOutputs(context.Background(), effect.Behavior.Outputs); err != nil {
log.Error("Failed to apply passive effect outputs",
zap.String("card_name", effect.CardName),
zap.Error(err))
}
})
log.Debug("Subscribed passive effect to CardPlayedEvent",
zap.String("card_name", effect.CardName))
return subID
}
func subscribeStandardProjectPlayedEffect(
_ context.Context,
g *game.Game,
p *player.Player,
effect shared.CardEffect,
trigger shared.Trigger,
log *zap.Logger,
cr cards.CardRegistry,
) events.SubscriptionID {
subID := events.Subscribe(g.EventBus(), func(event events.StandardProjectPlayedEvent) {
if event.GameID != g.ID() {
return
}
target := "self-player"
if trigger.Condition.Target != nil {
target = *trigger.Condition.Target
}
if target == "self-player" && event.PlayerID != p.ID() {
return
}
// Check selectors for cost and project type matching
if len(trigger.Condition.Selectors) > 0 {
matched := false
for _, sel := range trigger.Condition.Selectors {
// Check cost requirement
if sel.RequiredOriginalCost != nil {
if sel.RequiredOriginalCost.Min != nil && event.ProjectCost < *sel.RequiredOriginalCost.Min {
continue
}
if sel.RequiredOriginalCost.Max != nil && event.ProjectCost > *sel.RequiredOriginalCost.Max {
continue
}
}
// Check project type match (if specified)
if len(sel.StandardProjects) > 0 {
if !gamecards.MatchesStandardProjectSelector(shared.StandardProject(event.ProjectType), sel) {
continue
}
}
matched = true
break
}
if !matched {
return
}
}
log.Debug("Passive effect triggered (standard project played)",
zap.String("card_name", effect.CardName),
zap.String("effect_owner", p.ID()),
zap.String("project_type", event.ProjectType),
zap.Int("project_cost", event.ProjectCost))
applier := gamecards.NewBehaviorApplier(p, g, effect.CardName, log).
WithSourceCardID(effect.CardID).
WithCardRegistry(cr).
WithSourceType(shared.SourceTypePassiveEffect)
if err := applier.ApplyOutputs(context.Background(), effect.Behavior.Outputs); err != nil {
log.Error("Failed to apply passive effect outputs",
zap.String("card_name", effect.CardName),
zap.Error(err))
}
})
log.Debug("Subscribed passive effect to StandardProjectPlayedEvent",
zap.String("card_name", effect.CardName))
return subID
}
func subscribeTilePlacedEffect(
_ context.Context,
g *game.Game,
p *player.Player,
effect shared.CardEffect,
trigger shared.Trigger,
log *zap.Logger,
cr cards.CardRegistry,
) events.SubscriptionID {
subID := events.Subscribe(g.EventBus(), func(event events.PlacementBonusGainedEvent) {
if event.GameID != g.ID() {
return
}
target := "self-player"
if trigger.Condition.Target != nil {
target = *trigger.Condition.Target
}
if target == "self-card" {
if event.SourceCardID != effect.CardID {
return
}
} else if target == "self-player" && event.PlayerID != p.ID() {
return
}
if len(trigger.Condition.OnBonusType) > 0 {
matchFound := false
for _, requiredBonus := range trigger.Condition.OnBonusType {
if _, exists := event.Resources[requiredBonus]; exists {
matchFound = true
break
}
}
if !matchFound {
return
}
}
log.Debug("Passive effect triggered (tile placed on bonus)",
zap.String("card_name", effect.CardName),
zap.String("trigger_type", trigger.Condition.Type),
zap.Any("bonus_resources", event.Resources))
applier := gamecards.NewBehaviorApplier(p, g, effect.CardName, log).
WithSourceCardID(effect.CardID).
WithCardRegistry(cr).
WithSourceType(shared.SourceTypePassiveEffect)
if err := applier.ApplyOutputs(context.Background(), effect.Behavior.Outputs); err != nil {
log.Error("Failed to apply passive effect outputs",
zap.String("card_name", effect.CardName),
zap.Error(err))
}
})
log.Debug("Subscribed passive effect to PlacementBonusGainedEvent (tile-placed)",
zap.String("card_name", effect.CardName))
return subID
}
func subscribeGlobalParameterRaisedEffect(
_ context.Context,
g *game.Game,
p *player.Player,
effect shared.CardEffect,
_ shared.Trigger,
log *zap.Logger,
cr cards.CardRegistry,
) events.SubscriptionID {
globalParams := getGlobalParametersFromSelectors(effect.Behavior.Triggers)
applyPerStep := func(steps int, paramName string) {
if steps <= 0 {
return
}
log.Debug("Passive effect triggered (global parameter raised)",
zap.String("card_name", effect.CardName),
zap.String("player_id", p.ID()),
zap.String("parameter", paramName),
zap.Int("steps", steps))
for i := 0; i < steps; i++ {
applier := gamecards.NewBehaviorApplier(p, g, effect.CardName, log).
WithSourceCardID(effect.CardID).
WithCardRegistry(cr).
WithSourceType(shared.SourceTypePassiveEffect)
if err := applier.ApplyOutputs(context.Background(), effect.Behavior.Outputs); err != nil {
log.Error("Failed to apply passive effect outputs",
zap.String("card_name", effect.CardName),
zap.Error(err))
}
}
}
if slices.Contains(globalParams, "venus") {
subID := events.Subscribe(g.EventBus(), func(event events.VenusChangedEvent) {
if event.GameID != g.ID() {
return
}
steps := (event.NewValue - event.OldValue) / 2
applyPerStep(steps, "venus")
})
p.Effects().RegisterSubscription(effect.CardID, subID)
}
if slices.Contains(globalParams, "temperature") {
subID := events.Subscribe(g.EventBus(), func(event events.TemperatureChangedEvent) {
if event.GameID != g.ID() {
return
}
steps := (event.NewValue - event.OldValue) / 2
applyPerStep(steps, "temperature")
})
p.Effects().RegisterSubscription(effect.CardID, subID)
}
if slices.Contains(globalParams, "oxygen") {
subID := events.Subscribe(g.EventBus(), func(event events.OxygenChangedEvent) {
if event.GameID != g.ID() {
return
}
steps := event.NewValue - event.OldValue
applyPerStep(steps, "oxygen")
})
p.Effects().RegisterSubscription(effect.CardID, subID)
}
log.Debug("Subscribed passive effect to global parameter raised events",
zap.String("card_name", effect.CardName),
zap.Strings("parameters", globalParams))
// Subscriptions are registered internally per parameter, return empty to avoid duplicate registration by caller
return ""
}
func getGlobalParametersFromSelectors(triggers []shared.Trigger) []string {
for _, trigger := range triggers {
if trigger.Condition == nil {
continue
}
for _, sel := range trigger.Condition.Selectors {
if len(sel.GlobalParameters) > 0 {
return sel.GlobalParameters
}
}
}
return nil
}
// createPassiveCardDiscard creates a pending card discard selection from a passive effect
// Used for effects like Mars University that require player to optionally discard before gaining outputs
func createPassiveCardDiscard(p *player.Player, effect shared.CardEffect, log *zap.Logger) {
// Find card-discard inputs to determine min/max
minCards := 0
maxCards := 0
for _, input := range effect.Behavior.Inputs {
if input.GetResourceType() == shared.ResourceCardDiscard {
if !shared.IsOptional(input) {
minCards = input.GetAmount()
}
maxCards = input.GetAmount()
break
}
}
// Skip if player has no cards to discard
if len(p.Hand().Cards()) == 0 {
log.Debug("Skipping card discard - player has no cards in hand",
zap.String("card_name", effect.CardName))
return
}
p.Selection().SetPendingCardDiscardSelection(&shared.PendingCardDiscardSelection{
MinCards: minCards,
MaxCards: maxCards,
Source: effect.CardName,
SourceCardID: effect.CardID,
PendingOutputs: effect.Behavior.Outputs,
})
log.Debug("Created pending card discard selection from passive effect",
zap.String("card_name", effect.CardName),
zap.Int("min_cards", minCards),
zap.Int("max_cards", maxCards))
}
// resourceNameToProductionType maps event resource names to production resource types
var resourceNameToProductionType = map[string]shared.ResourceType{
"credits": shared.ResourceCreditProduction,
"steel": shared.ResourceSteelProduction,
"titanium": shared.ResourceTitaniumProduction,
"plants": shared.ResourcePlantProduction,
"energy": shared.ResourceEnergyProduction,
"heat": shared.ResourceHeatProduction,
}
// subscribeProductionIncreasedEffect subscribes to ProductionChangedEvent for production increase triggers
func subscribeProductionIncreasedEffect(
_ context.Context,
g *game.Game,
p *player.Player,
effect shared.CardEffect,
trigger shared.Trigger,
log *zap.Logger,
cr cards.CardRegistry,
) events.SubscriptionID {
subID := events.Subscribe(g.EventBus(), func(event events.ProductionChangedEvent) {
if event.GameID != g.ID() {
return
}
target := "self-player"
if trigger.Condition.Target != nil {
target = *trigger.Condition.Target
}
if target == "self-player" && event.PlayerID != p.ID() {
return
}
increase := event.NewProduction - event.OldProduction
if increase <= 0 {
return
}
// Check if this production type matches the trigger's resource type filter
if len(trigger.Condition.ResourceTypes) > 0 {
productionType, exists := resourceNameToProductionType[event.ResourceType]
if !exists {
return
}
if !slices.Contains(trigger.Condition.ResourceTypes, productionType) {
return
}
}
// Scale outputs by the production increase amount
scaledOutputs := make([]shared.BehaviorCondition, len(effect.Behavior.Outputs))
for i, output := range effect.Behavior.Outputs {
scaled := shared.CopyCondition(output)
scaled.SetAmount(output.GetAmount() * increase)
scaledOutputs[i] = scaled
}
log.Debug("Passive effect triggered",
zap.String("card_name", effect.CardName),
zap.String("trigger_type", trigger.Condition.Type),
zap.String("resource_type", event.ResourceType),
zap.Int("increase", increase))
applier := gamecards.NewBehaviorApplier(p, g, effect.CardName, log).
WithSourceCardID(effect.CardID).
WithCardRegistry(cr).
WithSourceType(shared.SourceTypePassiveEffect)
if err := applier.ApplyOutputs(context.Background(), scaledOutputs); err != nil {
log.Error("Failed to apply passive effect outputs",
zap.String("card_name", effect.CardName),
zap.Error(err))
}
})
log.Debug("Subscribed passive effect to ProductionChangedEvent",
zap.String("card_name", effect.CardName))
return subID
}
// createPassiveBehaviorChoice creates a pending behavior choice selection from a passive effect
// Used for effects like Viral Enhancers and Olympus Conference that require player to choose between options
func createPassiveBehaviorChoice(p *player.Player, effect shared.CardEffect, log *zap.Logger) {
p.Selection().SetPendingBehaviorChoiceSelection(&shared.PendingBehaviorChoiceSelection{
Choices: effect.Behavior.Choices,
Source: effect.CardName,
SourceCardID: effect.CardID,
})
log.Debug("Created pending behavior choice selection from passive effect",
zap.String("card_name", effect.CardName),
zap.Int("num_choices", len(effect.Behavior.Choices)))
}
// subscribeColonyPlacedEffect subscribes to ColonyBuiltEvent for colony-placed triggers
func subscribeColonyPlacedEffect(
_ context.Context,
g *game.Game,
p *player.Player,
effect shared.CardEffect,
trigger shared.Trigger,
log *zap.Logger,
cr cards.CardRegistry,
) events.SubscriptionID {
subID := events.Subscribe(g.EventBus(), func(event events.ColonyBuiltEvent) {
if event.GameID != g.ID() {
return
}
target := "self-player"
if trigger.Condition.Target != nil {
target = *trigger.Condition.Target
}
if target == "self-player" && event.PlayerID != p.ID() {
return
}
log.Debug("Passive effect triggered (colony placed)",
zap.String("card_name", effect.CardName),
zap.String("player_id", p.ID()),
zap.String("placed_by", event.PlayerID),
zap.String("colony_id", event.ColonyID))
applier := gamecards.NewBehaviorApplier(p, g, effect.CardName, log).
WithSourceCardID(effect.CardID).
WithCardRegistry(cr).
WithSourceType(shared.SourceTypePassiveEffect)
if err := applier.ApplyOutputs(context.Background(), effect.Behavior.Outputs); err != nil {
log.Error("Failed to apply passive effect outputs",
zap.String("card_name", effect.CardName),
zap.Error(err))
}
})
log.Debug("Subscribed passive effect to ColonyBuiltEvent",
zap.String("card_name", effect.CardName))
return subID
}
package action
import (
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game"
gamecards "terraforming-mars-backend/internal/game/cards"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/logger"
"go.uber.org/zap"
)
// SetupPlayerCardStore wires event-driven state calculation for a player's hand cards.
// Subscribes to game events so that EntityState for each hand card is automatically
// computed and kept in sync. Must be called once per player after creation.
func SetupPlayerCardStore(p *player.Player, g *game.Game, cardRegistry cards.CardRegistry, colonyBonusLookup ...gamecards.ColonyBonusLookup) {
eventBus := g.EventBus()
store := p.CardStateStore()
var lookup gamecards.ColonyBonusLookup
if len(colonyBonusLookup) > 0 {
lookup = colonyBonusLookup[0]
}
recalculate := func(cardID string) player.EntityState {
card, err := cardRegistry.GetByID(cardID)
if err != nil {
logger.Get().Warn("Card not found in registry during recalculation",
zap.String("card_id", cardID), zap.Error(err))
return player.EntityState{}
}
return CalculatePlayerCardState(card, p, g, cardRegistry, lookup)
}
recalculateAll := func() {
store.RecalculateAll(recalculate)
}
// When a card is added to hand, calculate its initial state
events.Subscribe(eventBus, func(e events.CardAddedToHandEvent) {
if e.PlayerID != p.ID() {
return
}
card, err := cardRegistry.GetByID(e.CardID)
if err != nil {
logger.Get().Warn("Card not found in registry",
zap.String("card_id", e.CardID), zap.Error(err))
return
}
state := CalculatePlayerCardState(card, p, g, cardRegistry, lookup)
store.SetState(e.CardID, state)
})
// When hand changes, remove stale entries
events.Subscribe(eventBus, func(e events.CardHandUpdatedEvent) {
if e.PlayerID != p.ID() {
return
}
store.SyncWithHand(e.CardIDs)
})
// Recalculate all cards when game state changes
events.Subscribe(eventBus, func(e events.ResourcesChangedEvent) {
if e.PlayerID == p.ID() {
recalculateAll()
}
})
events.Subscribe(eventBus, func(_ events.TemperatureChangedEvent) {
recalculateAll()
})
events.Subscribe(eventBus, func(_ events.OxygenChangedEvent) {
recalculateAll()
})
events.Subscribe(eventBus, func(_ events.OceansChangedEvent) {
recalculateAll()
})
events.Subscribe(eventBus, func(_ events.VenusChangedEvent) {
recalculateAll()
})
events.Subscribe(eventBus, func(_ events.ResourceStorageChangedEvent) {
recalculateAll()
})
events.Subscribe(eventBus, func(e events.PlayerEffectsChangedEvent) {
if e.PlayerID == p.ID() {
recalculateAll()
}
})
events.Subscribe(eventBus, func(e events.GamePhaseChangedEvent) {
if e.GameID == g.ID() {
recalculateAll()
}
})
events.Subscribe(eventBus, func(_ events.GameStateChangedEvent) {
recalculateAll()
})
events.Subscribe(eventBus, func(e events.CardPlayedEvent) {
if e.GameID == g.ID() && e.PlayerID == p.ID() {
recalculateAll()
}
})
events.Subscribe(eventBus, func(e events.ProductionChangedEvent) {
if e.PlayerID == p.ID() {
recalculateAll()
}
})
events.Subscribe(eventBus, func(e events.TilePlacedEvent) {
if e.GameID == g.ID() {
recalculateAll()
}
})
events.Subscribe(eventBus, func(e events.ColonyBuiltEvent) {
if e.GameID == g.ID() {
recalculateAll()
}
})
events.Subscribe(eventBus, func(e events.PlayerSelectionChangedEvent) {
if e.PlayerID == p.ID() {
recalculateAll()
}
})
}
package projectfunding
import (
"context"
"fmt"
"time"
baseaction "terraforming-mars-backend/internal/action"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/player"
pf "terraforming-mars-backend/internal/game/projectfunding"
"terraforming-mars-backend/internal/game/shared"
pfRegistry "terraforming-mars-backend/internal/projectfunding"
"go.uber.org/zap"
)
// FundSeatPayment describes how the player pays for a seat
type FundSeatPayment struct {
Credits int
Steel int
Titanium int
}
// FundSeatAction handles the business logic for purchasing a project funding seat
type FundSeatAction struct {
baseaction.BaseAction
pfRegistry pfRegistry.ProjectFundingRegistry
}
// NewFundSeatAction creates a new fund seat action
func NewFundSeatAction(
gameRepo game.GameRepository,
pfReg pfRegistry.ProjectFundingRegistry,
stateRepo game.GameStateRepository,
) *FundSeatAction {
return &FundSeatAction{
BaseAction: baseaction.NewBaseActionWithStateRepo(gameRepo, nil, stateRepo),
pfRegistry: pfReg,
}
}
// Execute performs the fund seat action
func (a *FundSeatAction) Execute(ctx context.Context, gameID string, playerID string, projectID string, payment FundSeatPayment) error {
log := a.InitLogger(gameID, playerID).With(
zap.String("action", "fund_project_seat"),
zap.String("project_id", projectID),
)
log.Debug("Purchasing project seat")
g, err := baseaction.ValidateActiveGame(ctx, a.GameRepository(), gameID, log)
if err != nil {
return err
}
if err := baseaction.ValidateGamePhase(g, shared.GamePhaseAction, log); err != nil {
return err
}
if err := baseaction.ValidateCurrentTurn(g, playerID, log); err != nil {
return err
}
if err := baseaction.ValidateActionsRemaining(g, playerID, log); err != nil {
return err
}
if err := baseaction.ValidateNoPendingSelections(g, playerID, log); err != nil {
return err
}
if !g.HasProjectFunding() {
return fmt.Errorf("project funding expansion is not enabled")
}
player, err := a.GetPlayerFromGame(g, playerID, log)
if err != nil {
return err
}
projectState := g.GetProjectFundingState(projectID)
if projectState == nil {
return fmt.Errorf("project not found: %s", projectID)
}
if projectState.IsCompleted {
return fmt.Errorf("project is already completed: %s", projectID)
}
definition, err := a.pfRegistry.GetByID(projectID)
if err != nil {
return fmt.Errorf("project definition not found: %w", err)
}
seatIndex := len(projectState.SeatOwners)
if seatIndex >= len(definition.Seats) {
return fmt.Errorf("all seats are filled for project: %s", projectID)
}
seat := definition.Seats[seatIndex]
cost := seat.Cost
if payment.Credits < 0 || payment.Steel < 0 || payment.Titanium < 0 {
return fmt.Errorf("payment amounts must be non-negative")
}
// Validate payment covers cost
totalPayment := payment.Credits
steelValue := 0
titaniumValue := 0
for _, sub := range seat.PaymentSubstitutes {
switch sub.ResourceType {
case "steel":
steelValue = sub.ConversionRate
case "titanium":
titaniumValue = sub.ConversionRate
}
}
if payment.Steel > 0 {
if steelValue == 0 {
return fmt.Errorf("steel cannot be used to pay for this seat")
}
totalPayment += payment.Steel * steelValue
}
if payment.Titanium > 0 {
if titaniumValue == 0 {
return fmt.Errorf("titanium cannot be used to pay for this seat")
}
totalPayment += payment.Titanium * titaniumValue
}
if totalPayment < cost {
return fmt.Errorf("insufficient payment: need %d, provided %d", cost, totalPayment)
}
// Validate player has the resources
resources := player.Resources().Get()
if resources.Credits < payment.Credits {
return fmt.Errorf("insufficient credits: need %d, have %d", payment.Credits, resources.Credits)
}
if resources.Steel < payment.Steel {
return fmt.Errorf("insufficient steel: need %d, have %d", payment.Steel, resources.Steel)
}
if resources.Titanium < payment.Titanium {
return fmt.Errorf("insufficient titanium: need %d, have %d", payment.Titanium, resources.Titanium)
}
// Deduct resources
deductions := map[shared.ResourceType]int{}
if payment.Credits > 0 {
deductions[shared.ResourceCredit] = -payment.Credits
}
if payment.Steel > 0 {
deductions[shared.ResourceSteel] = -payment.Steel
}
if payment.Titanium > 0 {
deductions[shared.ResourceTitanium] = -payment.Titanium
}
player.Resources().Add(deductions)
// Add seat owner
projectState.SeatOwners = append(projectState.SeatOwners, playerID)
calculatedOutputs := []shared.CalculatedOutput{
{ResourceType: "project-seat", Amount: 1},
}
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: definition.Name,
PlayerID: playerID,
SourceType: shared.SourceTypeProjectFundingSeat,
CalculatedOutputs: calculatedOutputs,
})
a.WriteStateLogFull(ctx, g, definition.Name, shared.SourceTypeProjectFundingSeat,
playerID, fmt.Sprintf("Funded %s project", definition.Name), nil, calculatedOutputs, nil)
events.Publish(g.EventBus(), events.ProjectSeatPurchasedEvent{
GameID: g.ID(),
PlayerID: playerID,
ProjectID: projectID,
SeatIndex: seatIndex,
Timestamp: time.Now(),
})
a.ConsumePlayerAction(g, log)
// Check for completion
if len(projectState.SeatOwners) >= len(definition.Seats) {
a.completeProject(ctx, g, projectState, definition, log)
}
log.Info("Project seat purchased",
zap.String("project_id", projectID),
zap.Int("seat_index", seatIndex))
return nil
}
func (a *FundSeatAction) completeProject(ctx context.Context, g *game.Game, state *pf.ProjectState, def *pf.ProjectDefinition, log *zap.Logger) {
state.IsCompleted = true
allPlayers := g.GetAllPlayers()
// Count seats per player
seatCounts := map[string]int{}
for _, ownerID := range state.SeatOwners {
seatCounts[ownerID]++
}
// Apply tier rewards to each funder
for playerID, count := range seatCounts {
tier := pf.FindBestTier(def.RewardTiers, count)
if tier == nil {
continue
}
p, err := g.GetPlayer(playerID)
if err != nil {
log.Error("Failed to get player for tier rewards", zap.String("player_id", playerID), zap.Error(err))
continue
}
tierOutputs := applyRewards(p, tier.Rewards)
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: "Project Tier Reward: " + def.Name,
PlayerID: playerID,
SourceType: shared.SourceTypeProjectFundingCompletion,
CalculatedOutputs: tierOutputs,
})
}
// Apply static completion rewards to ALL players
for _, p := range allPlayers {
completionOutputs := applyRewards(p, def.CompletionEffect.Rewards)
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: "Project Completed: " + def.Name,
PlayerID: p.ID(),
SourceType: shared.SourceTypeProjectFundingCompletion,
CalculatedOutputs: completionOutputs,
})
}
// Apply global completion effects
if len(def.CompletionEffect.GlobalEffects) > 0 {
completingPlayerID := state.SeatOwners[len(state.SeatOwners)-1]
a.applyGlobalEffects(ctx, g, def, allPlayers, completingPlayerID, log)
}
a.WriteStateLogFull(ctx, g, "Project Completed: "+def.Name, shared.SourceTypeProjectFundingCompletion,
"", fmt.Sprintf("Project %s completed", def.Name), nil, nil, nil)
events.Publish(g.EventBus(), events.ProjectCompletedEvent{
GameID: g.ID(),
ProjectID: def.ID,
Timestamp: time.Now(),
})
log.Debug("Project completed",
zap.String("project_id", def.ID),
zap.Int("total_seats", len(state.SeatOwners)))
}
func (a *FundSeatAction) applyGlobalEffects(ctx context.Context, g *game.Game, def *pf.ProjectDefinition, allPlayers []*player.Player, completingPlayerID string, log *zap.Logger) {
for _, effect := range def.CompletionEffect.GlobalEffects {
switch effect.Type {
case "temperature":
if _, err := g.GlobalParameters().IncreaseTemperature(ctx, effect.Amount, completingPlayerID); err != nil {
log.Error("Failed to increase temperature for project completion", zap.Error(err))
}
case "oxygen":
if _, err := g.GlobalParameters().IncreaseOxygen(ctx, effect.Amount, completingPlayerID); err != nil {
log.Error("Failed to increase oxygen for project completion", zap.Error(err))
}
case "freeze-turn-order":
g.SetNextGenTurnOrderFrozen(true)
log.Debug("Turn order frozen by project completion", zap.String("project", def.Name))
case "production-choice":
amount := effect.Amount
if amount <= 0 {
amount = 1
}
choices := buildProductionChoices(amount)
for _, p := range allPlayers {
p.Selection().SetPendingBehaviorChoiceSelection(&shared.PendingBehaviorChoiceSelection{
Choices: choices,
Source: "project-funding-completion",
SourceCardID: def.ID,
})
}
log.Debug("Production choice set for all players", zap.String("project", def.Name))
case "card-draw":
n := effect.Amount
if n <= 0 {
n = 1
}
for _, p := range allPlayers {
cardIDs, err := g.Deck().DrawProjectCards(ctx, n)
if err != nil {
log.Error("Failed to draw cards for project completion", zap.Error(err))
break
}
for _, cardID := range cardIDs {
p.Hand().AddCard(cardID)
}
log.Debug("Cards drawn for player",
zap.String("player_id", p.ID()),
zap.Int("count", len(cardIDs)))
}
}
}
}
func buildProductionChoices(amount int) []shared.Choice {
productionTypes := []shared.ResourceType{
shared.ResourceCreditProduction,
shared.ResourceSteelProduction,
shared.ResourceTitaniumProduction,
shared.ResourcePlantProduction,
shared.ResourceEnergyProduction,
shared.ResourceHeatProduction,
}
choices := make([]shared.Choice, len(productionTypes))
for i, rt := range productionTypes {
choices[i] = shared.Choice{
Outputs: []shared.BehaviorCondition{
shared.NewProductionCondition(rt, amount, "self-player"),
},
}
}
return choices
}
func applyRewards(p *player.Player, rewards []pf.Output) []shared.CalculatedOutput {
var outputs []shared.CalculatedOutput
resourceAdds := map[shared.ResourceType]int{}
productionAdds := map[shared.ResourceType]int{}
for _, reward := range rewards {
outputs = append(outputs, shared.CalculatedOutput{
ResourceType: reward.Type,
Amount: reward.Amount,
})
switch reward.Type {
case "credit":
resourceAdds[shared.ResourceCredit] += reward.Amount
case "steel":
resourceAdds[shared.ResourceSteel] += reward.Amount
case "titanium":
resourceAdds[shared.ResourceTitanium] += reward.Amount
case "plant":
resourceAdds[shared.ResourcePlant] += reward.Amount
case "energy":
resourceAdds[shared.ResourceEnergy] += reward.Amount
case "heat":
resourceAdds[shared.ResourceHeat] += reward.Amount
case "tr":
p.Resources().UpdateTerraformRating(reward.Amount)
case "vp":
p.VPGranters().Add(shared.VPGranter{
CardID: "pf_" + p.ID(),
CardName: "Project Funding",
Description: fmt.Sprintf("%d VP from project funding", reward.Amount),
VPConditions: []shared.VPCondition{
{Amount: reward.Amount, Condition: shared.VPConditionFixed},
},
ComputedValue: reward.Amount,
})
case "credit-production":
productionAdds[shared.ResourceCreditProduction] += reward.Amount
case "steel-production":
productionAdds[shared.ResourceSteelProduction] += reward.Amount
case "titanium-production":
productionAdds[shared.ResourceTitaniumProduction] += reward.Amount
case "plant-production":
productionAdds[shared.ResourcePlantProduction] += reward.Amount
case "energy-production":
productionAdds[shared.ResourceEnergyProduction] += reward.Amount
case "heat-production":
productionAdds[shared.ResourceHeatProduction] += reward.Amount
}
}
if len(resourceAdds) > 0 {
p.Resources().Add(resourceAdds)
}
if len(productionAdds) > 0 {
p.Resources().AddProduction(productionAdds)
}
return outputs
}
package resource_conversion
import (
"context"
"fmt"
baseaction "terraforming-mars-backend/internal/action"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/game"
gamecards "terraforming-mars-backend/internal/game/cards"
"terraforming-mars-backend/internal/game/global_parameters"
"terraforming-mars-backend/internal/game/shared"
"go.uber.org/zap"
)
const (
// BaseHeatForTemperature is the base cost in heat to raise temperature (before card discounts)
BaseHeatForTemperature = 8
)
// ConvertHeatToTemperatureAction handles converting heat to raise temperature
// New architecture: Uses only GameRepository + logger, events handle broadcasting
type ConvertHeatToTemperatureAction struct {
baseaction.BaseAction
cardRegistry cards.CardRegistry
}
// NewConvertHeatToTemperatureAction creates a new convert heat action
func NewConvertHeatToTemperatureAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
stateRepo game.GameStateRepository,
logger *zap.Logger,
) *ConvertHeatToTemperatureAction {
return &ConvertHeatToTemperatureAction{
BaseAction: baseaction.NewBaseActionWithStateRepo(gameRepo, nil, stateRepo),
cardRegistry: cardRegistry,
}
}
// Execute performs the convert heat to temperature action
func (a *ConvertHeatToTemperatureAction) Execute(
ctx context.Context,
gameID string,
playerID string,
storageSubstitutes map[string]int,
) error {
log := a.InitLogger(gameID, playerID)
log.Debug("Converting heat to temperature")
g, err := baseaction.ValidateActiveGame(ctx, a.GameRepository(), gameID, log)
if err != nil {
return err
}
if err := baseaction.ValidateGamePhase(g, shared.GamePhaseAction, log); err != nil {
return err
}
if err := baseaction.ValidateCurrentTurn(g, playerID, log); err != nil {
return err
}
if err := baseaction.ValidateActionsRemaining(g, playerID, log); err != nil {
return err
}
if err := baseaction.ValidateNoPendingSelections(g, playerID, log); err != nil {
return err
}
player, err := a.GetPlayerFromGame(g, playerID, log)
if err != nil {
return err
}
calculator := gamecards.NewRequirementModifierCalculator(a.cardRegistry)
discounts := calculator.CalculateStandardProjectDiscounts(player, shared.StandardProjectConvertHeatToTemperature)
heatDiscount := discounts[shared.ResourceHeat]
requiredHeat := BaseHeatForTemperature - heatDiscount
if requiredHeat < 1 {
requiredHeat = 1
}
log.Debug("Calculated heat cost",
zap.Int("base_cost", BaseHeatForTemperature),
zap.Int("discount", heatDiscount),
zap.Int("final_cost", requiredHeat))
storageValue, err := ValidateAndDeductStorageSubstitutes(player, storageSubstitutes, shared.ResourceHeat, log)
if err != nil {
return fmt.Errorf("storage substitute error: %w", err)
}
remainingCost := requiredHeat - storageValue
if remainingCost < 0 {
remainingCost = 0
}
resources := player.Resources().Get()
if resources.Heat < remainingCost {
log.Warn("Player cannot afford heat conversion",
zap.Int("required", requiredHeat),
zap.Int("storage_value", storageValue),
zap.Int("remaining_cost", remainingCost),
zap.Int("available_heat", resources.Heat))
return fmt.Errorf("insufficient heat: need %d (after %d from storage), have %d", remainingCost, storageValue, resources.Heat)
}
resources.Heat -= remainingCost
player.Resources().Set(resources)
log.Debug("Deducted heat",
zap.Int("heat_spent", remainingCost),
zap.Int("storage_value", storageValue),
zap.Int("remaining_heat", resources.Heat))
var stepsRaised int
currentTemp := g.GlobalParameters().Temperature()
if currentTemp < global_parameters.MaxTemperature {
var err error
stepsRaised, err = g.GlobalParameters().IncreaseTemperature(ctx, 1, playerID)
if err != nil {
log.Error("Failed to raise temperature", zap.Error(err))
return fmt.Errorf("failed to raise temperature: %w", err)
}
if stepsRaised > 0 {
newTemp := g.GlobalParameters().Temperature()
log.Debug("Temperature raised",
zap.Int("old_temperature", currentTemp),
zap.Int("new_temperature", newTemp),
zap.Int("steps_raised", stepsRaised))
oldTR := player.Resources().TerraformRating()
player.Resources().UpdateTerraformRating(1)
newTR := player.Resources().TerraformRating()
log.Debug("Increased terraform rating",
zap.Int("old_tr", oldTR),
zap.Int("new_tr", newTR))
}
} else {
log.Debug("Temperature already at maximum, no TR awarded")
}
a.ConsumePlayerAction(g, log)
calculatedOutputs := []shared.CalculatedOutput{
{ResourceType: string(shared.ResourceTemperature), Amount: stepsRaised, IsScaled: false},
}
if stepsRaised > 0 {
calculatedOutputs = append(calculatedOutputs, shared.CalculatedOutput{
ResourceType: string(shared.ResourceTR), Amount: 1, IsScaled: false,
})
}
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: "Convert Heat",
PlayerID: playerID,
SourceType: shared.SourceTypeResourceConvert,
CalculatedOutputs: calculatedOutputs,
})
displayData := baseaction.GetStandardProjectDisplayData("Convert Heat")
a.WriteStateLogFull(ctx, g, "Convert Heat", shared.SourceTypeResourceConvert, playerID, "Converted heat to raise temperature", nil, calculatedOutputs, displayData)
log.Info("Heat converted",
zap.Int("heat_spent", requiredHeat))
return nil
}
package resource_conversion
import (
"context"
"fmt"
baseaction "terraforming-mars-backend/internal/action"
"go.uber.org/zap"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/game"
gamecards "terraforming-mars-backend/internal/game/cards"
"terraforming-mars-backend/internal/game/shared"
)
const (
// BasePlantsForGreenery is the base cost in plants to convert to greenery (before card discounts)
BasePlantsForGreenery = 8
)
// ConvertPlantsToGreeneryAction handles the business logic for converting plants to greenery tile
// Uses RequirementModifierCalculator to apply card discounts (e.g., Ecoline: 7 plants instead of 8)
type ConvertPlantsToGreeneryAction struct {
baseaction.BaseAction
cardRegistry cards.CardRegistry
}
// NewConvertPlantsToGreeneryAction creates a new convert plants to greenery action
func NewConvertPlantsToGreeneryAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
stateRepo game.GameStateRepository,
logger *zap.Logger,
) *ConvertPlantsToGreeneryAction {
return &ConvertPlantsToGreeneryAction{
BaseAction: baseaction.NewBaseActionWithStateRepo(gameRepo, nil, stateRepo),
cardRegistry: cardRegistry,
}
}
// Execute performs the convert plants to greenery action
func (a *ConvertPlantsToGreeneryAction) Execute(ctx context.Context, gameID string, playerID string, storageSubstitutes map[string]int) error {
log := a.InitLogger(gameID, playerID).With(zap.String("action", "convert_plants_to_greenery"))
log.Debug("Converting plants to greenery")
g, err := baseaction.ValidateActiveGame(ctx, a.GameRepository(), gameID, log)
if err != nil {
return err
}
phase := g.CurrentPhase()
if phase != shared.GamePhaseAction && phase != shared.GamePhaseFinalPhase {
log.Error("Game not in valid phase for greenery conversion",
zap.String("actual", string(phase)))
return fmt.Errorf("game not in action or final phase")
}
if err := baseaction.ValidateCurrentTurn(g, playerID, log); err != nil {
return err
}
if err := baseaction.ValidateActionsRemaining(g, playerID, log); err != nil {
return err
}
if err := baseaction.ValidateNoPendingSelections(g, playerID, log); err != nil {
return err
}
player, err := a.GetPlayerFromGame(g, playerID, log)
if err != nil {
return err
}
calculator := gamecards.NewRequirementModifierCalculator(a.cardRegistry)
discounts := calculator.CalculateStandardProjectDiscounts(player, shared.StandardProjectConvertPlantsToGreenery)
plantDiscount := discounts[shared.ResourcePlant]
requiredPlants := BasePlantsForGreenery - plantDiscount
if requiredPlants < 1 {
requiredPlants = 1
}
log.Debug("Calculated plants cost",
zap.Int("base_cost", BasePlantsForGreenery),
zap.Int("discount", plantDiscount),
zap.Int("final_cost", requiredPlants))
storageValue, err := ValidateAndDeductStorageSubstitutes(player, storageSubstitutes, shared.ResourcePlant, log)
if err != nil {
return fmt.Errorf("storage substitute error: %w", err)
}
remainingCost := requiredPlants - storageValue
if remainingCost < 0 {
remainingCost = 0
}
resources := player.Resources().Get()
if resources.Plants < remainingCost {
log.Warn("Player cannot afford plants conversion",
zap.Int("required", requiredPlants),
zap.Int("storage_value", storageValue),
zap.Int("remaining_cost", remainingCost),
zap.Int("available_plants", resources.Plants))
return fmt.Errorf("insufficient plants: need %d (after %d from storage), have %d", remainingCost, storageValue, resources.Plants)
}
player.Resources().Add(map[shared.ResourceType]int{
shared.ResourcePlant: -remainingCost,
})
resources = player.Resources().Get()
log.Debug("Deducted plants",
zap.Int("plants_spent", remainingCost),
zap.Int("storage_value", storageValue),
zap.Int("remaining_plants", resources.Plants))
queue := &shared.PendingTileSelectionQueue{
Items: []string{"greenery"},
Source: "convert-plants-to-greenery",
OnComplete: &shared.TileCompletionCallback{
Type: "convert-plants-to-greenery",
},
TileRestrictions: &shared.TileRestrictions{
AdjacentToOwned: true,
},
}
if err := g.SetPendingTileSelectionQueue(ctx, playerID, queue); err != nil {
return fmt.Errorf("failed to queue tile placement: %w", err)
}
log.Debug("Created tile queue for greenery placement (auto-processed by SetPendingTileSelectionQueue)")
a.ConsumePlayerAction(g, log)
log.Info("Plants converted, greenery queued",
zap.Int("plants_spent", requiredPlants))
return nil
}
package resource_conversion
import (
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
)
// ValidateAndDeductStorageSubstitutes validates that the requested storage substitutes are valid
// for the given target resource, deducts them from card storage, and returns the total resource
// value contributed by the substitutes.
func ValidateAndDeductStorageSubstitutes(
p *player.Player,
storageSubstitutes map[string]int,
targetResource shared.ResourceType,
log *zap.Logger,
) (int, error) {
if len(storageSubstitutes) == 0 {
return 0, nil
}
substituteLookup := make(map[string]shared.StoragePaymentSubstitute)
for _, sub := range p.Resources().StoragePaymentSubstitutes() {
if sub.TargetResource == targetResource {
substituteLookup[sub.CardID] = sub
}
}
totalValue := 0
for cardID, count := range storageSubstitutes {
if count <= 0 {
continue
}
sub, exists := substituteLookup[cardID]
if !exists {
return 0, fmt.Errorf("no storage payment substitute targeting %s found for card %s", targetResource, cardID)
}
available := p.Resources().GetCardStorage(cardID)
if available < count {
return 0, fmt.Errorf("insufficient storage on card %s: need %d, have %d", cardID, count, available)
}
totalValue += count * sub.ConversionRate
p.Resources().AddToStorage(cardID, -count)
log.Debug("Deducted storage substitute",
zap.String("card_id", cardID), zap.Int("count", count),
zap.Int("value", count*sub.ConversionRate), zap.String("target", string(targetResource)))
}
return totalValue, nil
}
// CalculateStorageSubstituteValue returns the total available resource value from storage
// substitutes targeting the given resource type, without deducting anything.
func CalculateStorageSubstituteValue(
p *player.Player,
targetResource shared.ResourceType,
) int {
totalValue := 0
for _, sub := range p.Resources().StoragePaymentSubstitutes() {
if sub.TargetResource == targetResource {
stored := p.Resources().GetCardStorage(sub.CardID)
totalValue += stored * sub.ConversionRate
}
}
return totalValue
}
package standard_project
import (
"context"
"fmt"
"time"
baseaction "terraforming-mars-backend/internal/action"
"go.uber.org/zap"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game"
gamecards "terraforming-mars-backend/internal/game/cards"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
"terraforming-mars-backend/internal/standardprojects"
)
// ExecuteStandardProjectAction handles all standard projects via a single unified action
type ExecuteStandardProjectAction struct {
baseaction.BaseAction
standardProjectRegistry standardprojects.StandardProjectRegistry
}
// NewExecuteStandardProjectAction creates a new unified standard project action
func NewExecuteStandardProjectAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
standardProjectRegistry standardprojects.StandardProjectRegistry,
stateRepo game.GameStateRepository,
logger *zap.Logger,
) *ExecuteStandardProjectAction {
return &ExecuteStandardProjectAction{
BaseAction: baseaction.NewBaseActionWithStateRepo(gameRepo, cardRegistry, stateRepo),
standardProjectRegistry: standardProjectRegistry,
}
}
// Execute performs the standard project action
func (a *ExecuteStandardProjectAction) Execute(
ctx context.Context,
gameID string,
playerID string,
projectID string,
) error {
log := a.InitLogger(gameID, playerID).With(zap.String("project_id", projectID))
log.Debug("Executing standard project")
definition, err := a.standardProjectRegistry.GetByID(projectID)
if err != nil {
log.Warn("Unknown standard project", zap.String("project_id", projectID))
return fmt.Errorf("unknown standard project: %s", projectID)
}
g, err := baseaction.ValidateActiveGame(ctx, a.GameRepository(), gameID, log)
if err != nil {
return err
}
if err := baseaction.ValidateGamePhase(g, shared.GamePhaseAction, log); err != nil {
return err
}
if err := baseaction.ValidateCurrentTurn(g, playerID, log); err != nil {
return err
}
if err := baseaction.ValidateActionsRemaining(g, playerID, log); err != nil {
return err
}
if err := baseaction.ValidateNoPendingSelections(g, playerID, log); err != nil {
return err
}
player, err := a.GetPlayerFromGame(g, playerID, log)
if err != nil {
return err
}
effectiveCost := definition.CreditCost()
if a.CardRegistry() != nil && effectiveCost > 0 {
calculator := gamecards.NewRequirementModifierCalculator(a.CardRegistry())
discounts := calculator.CalculateStandardProjectDiscounts(player, shared.StandardProject(projectID))
creditDiscount := discounts[shared.ResourceCredit]
effectiveCost = definition.CreditCost() - creditDiscount
if effectiveCost < 0 {
effectiveCost = 0
}
if creditDiscount > 0 {
log.Debug("Applied standard project discount",
zap.Int("base_cost", definition.CreditCost()),
zap.Int("discount", creditDiscount),
zap.Int("effective_cost", effectiveCost))
}
}
if effectiveCost > 0 {
resources := player.Resources().Get()
if resources.Credits < effectiveCost {
log.Warn("Insufficient credits",
zap.Int("cost", effectiveCost),
zap.Int("player_credits", resources.Credits))
return fmt.Errorf("insufficient credits: need %d, have %d", effectiveCost, resources.Credits)
}
player.Resources().Add(map[shared.ResourceType]int{
shared.ResourceCredit: -effectiveCost,
})
}
events.Publish(g.EventBus(), events.StandardProjectPlayedEvent{
GameID: g.ID(),
PlayerID: playerID,
ProjectType: projectID,
ProjectCost: definition.CreditCost(),
Timestamp: time.Now(),
})
// Check for sell-patents pattern: card-discard input with variableAmount
if hasSellPatentsBehavior(definition.Behaviors) {
return a.executeSellPatents(g, player, log)
}
// Apply behavior outputs via BehaviorApplier
applier := gamecards.NewBehaviorApplier(player, g, definition.Name, log).
WithSourceType(shared.SourceTypeStandardProject)
if a.CardRegistry() != nil {
applier = applier.WithCardRegistry(a.CardRegistry())
}
var allCalculatedOutputs []shared.CalculatedOutput
for _, behavior := range definition.Behaviors {
calculatedOutputs, err := applier.ApplyOutputsAndGetCalculated(ctx, behavior.Outputs)
if err != nil {
return fmt.Errorf("failed to apply standard project outputs: %w", err)
}
allCalculatedOutputs = append(allCalculatedOutputs, calculatedOutputs...)
}
a.ConsumePlayerAction(g, log)
displayData := &game.LogDisplayData{}
source := "Standard Project: " + definition.Name
a.WriteStateLogFull(ctx, g, source, shared.SourceTypeStandardProject, playerID, definition.Name, nil, allCalculatedOutputs, displayData)
log.Info("Standard project executed", zap.String("project", definition.Name))
return nil
}
// executeSellPatents handles the sell patents special case by creating a pending card selection
func (a *ExecuteStandardProjectAction) executeSellPatents(
g *game.Game,
p *player.Player,
log *zap.Logger,
) error {
_ = g
playerCards := p.Hand().Cards()
if len(playerCards) == 0 {
log.Warn("No cards to sell")
return fmt.Errorf("no cards available to sell")
}
cardCosts := make(map[string]int)
cardRewards := make(map[string]int)
for _, cardID := range playerCards {
cardCosts[cardID] = 0
cardRewards[cardID] = 1
}
p.Selection().SetPendingCardSelection(&shared.PendingCardSelection{
Source: "sell-patents",
AvailableCards: playerCards,
CardCosts: cardCosts,
CardRewards: cardRewards,
MinCards: 0,
MaxCards: len(playerCards),
})
log.Debug("Created pending card selection for sell patents",
zap.Int("available_cards", len(playerCards)))
log.Info("Sell patents initiated")
return nil
}
// hasSellPatentsBehavior checks if any behavior has a card-discard input with variableAmount
func hasSellPatentsBehavior(behaviors []shared.CardBehavior) bool {
for _, b := range behaviors {
for _, input := range b.Inputs {
if input.GetResourceType() == shared.ResourceCardDiscard {
if co, ok := input.(*shared.CardOperationCondition); ok && co.VariableAmount {
return true
}
}
}
}
return false
}
package action
import (
"terraforming-mars-backend/internal/game"
gamecards "terraforming-mars-backend/internal/game/cards"
"terraforming-mars-backend/internal/game/shared"
)
// standardProjectDisplayData contains pre-built display data for resource conversion projects
var standardProjectDisplayData = map[string]*game.LogDisplayData{
"Convert Heat": {
Behaviors: []shared.CardBehavior{{
Outputs: []shared.BehaviorCondition{&shared.GlobalParameterCondition{
ConditionBase: shared.ConditionBase{ResourceType: shared.ResourceTemperature, Amount: 1, Target: "global"},
}},
}},
},
"Convert Plants": {
Behaviors: []shared.CardBehavior{{
Outputs: []shared.BehaviorCondition{&shared.TilePlacementCondition{
ConditionBase: shared.ConditionBase{ResourceType: shared.ResourceGreeneryPlacement, Amount: 1, Target: "global"},
}},
}},
},
}
// GetStandardProjectDisplayData returns display data for a standard project
func GetStandardProjectDisplayData(source string) *game.LogDisplayData {
return standardProjectDisplayData[source]
}
// BuildCardDisplayData builds display data for a card log entry
func BuildCardDisplayData(card *gamecards.Card, sourceType shared.SourceType) *game.LogDisplayData {
if card == nil {
return nil
}
data := &game.LogDisplayData{
Tags: card.Tags,
}
// Convert VP conditions
for _, vp := range card.VPConditions {
vpForLog := shared.VPConditionForLog{
Amount: vp.Amount,
Condition: string(vp.Condition),
}
if vp.MaxTrigger != nil {
vpForLog.MaxTrigger = vp.MaxTrigger
}
if vp.Per != nil {
vpForLog.Per = &shared.PerCondition{
ResourceType: vp.Per.ResourceType,
Amount: vp.Per.Amount,
}
if vp.Per.Location != nil {
loc := string(*vp.Per.Location)
vpForLog.Per.Location = &loc
}
if vp.Per.Target != nil {
target := string(*vp.Per.Target)
vpForLog.Per.Target = &target
}
if vp.Per.Tag != nil {
vpForLog.Per.Tag = vp.Per.Tag
}
}
data.VPConditions = append(data.VPConditions, vpForLog)
}
// Select appropriate behaviors based on source type
switch sourceType {
case shared.SourceTypeCardPlay:
data.Behaviors = card.Behaviors
case shared.SourceTypeCardAction:
// Only include manual trigger behaviors for card actions
for _, b := range card.Behaviors {
if gamecards.HasManualTrigger(b) {
data.Behaviors = append(data.Behaviors, b)
}
}
}
return data
}
package action
import (
"fmt"
"strings"
"time"
"terraforming-mars-backend/internal/awards"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/game"
gamecards "terraforming-mars-backend/internal/game/cards"
"terraforming-mars-backend/internal/game/global_parameters"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
"terraforming-mars-backend/internal/milestones"
)
// CalculatePlayerCardState computes playability state for a card.
// This function can access both Game and Player without circular dependencies.
// card parameter must be *gamecards.Card
func CalculatePlayerCardState(
card *gamecards.Card,
p *player.Player,
g *game.Game,
cardRegistry cards.CardRegistry,
colonyBonusLookup ...gamecards.ColonyBonusLookup,
) player.EntityState {
var errors []player.StateError
var warnings []player.StateWarning
metadata := make(map[string]interface{})
errors = append(errors, validatePhase(g)...)
errors = append(errors, validateActionsRemaining(p, g)...)
errors = append(errors, validateNoPendingSelection(p, g)...)
costMap, discounts := calculateEffectiveCost(card, p, cardRegistry)
if len(discounts) > 0 {
metadata["discounts"] = discounts
}
errors = append(errors, validateAffordabilityWithSubstitutes(p, card, costMap)...)
errors = append(errors, validateRequirements(card, p, g, cardRegistry)...)
errors = append(errors, validateProductionOutputs(card, p)...)
errors = append(errors, validateCardResourceOutputs(card, p, cardRegistry)...)
errors = append(errors, validateCardDiscardOutputs(card, p)...)
errors = append(errors, validateNegativeResourceOutputsForCard(card, p)...)
errors = append(errors, ValidateTileOutputs(card, p, g)...)
for _, behavior := range card.Behaviors {
if !gamecards.HasAutoTrigger(behavior) {
continue
}
warnings = append(warnings, validateGlobalParamWarnings(behavior.Outputs, g)...)
for _, choice := range behavior.Choices {
warnings = append(warnings, validateGlobalParamWarnings(choice.Outputs, g)...)
}
}
var lookup gamecards.ColonyBonusLookup
if len(colonyBonusLookup) > 0 {
lookup = colonyBonusLookup[0]
}
warnings = append(warnings, validateColonyBonusStorageTargets(card, p, g, cardRegistry, lookup)...)
computedValues := computeBehaviorValues(card.Behaviors, "", p, g, cardRegistry, lookup)
return player.EntityState{
Errors: errors,
Warnings: warnings,
Cost: costMap,
Metadata: metadata,
ComputedValues: computedValues,
LastCalculated: time.Now(),
}
}
// CalculatePendingCardPlayability computes playability state for a pending card
// (during card selection/buying). Skips phase, turn, and tile-selection checks
// since those are irrelevant during card selection.
func CalculatePendingCardPlayability(
card *gamecards.Card,
p *player.Player,
g *game.Game,
cardRegistry cards.CardRegistry,
) player.EntityState {
var errors []player.StateError
var warnings []player.StateWarning
metadata := make(map[string]interface{})
costMap, discounts := calculateEffectiveCost(card, p, cardRegistry)
if len(discounts) > 0 {
metadata["discounts"] = discounts
}
errors = append(errors, validateAffordabilityWithSubstitutes(p, card, costMap)...)
errors = append(errors, validateRequirements(card, p, g, cardRegistry)...)
errors = append(errors, validateProductionOutputs(card, p)...)
errors = append(errors, validateCardResourceOutputs(card, p, cardRegistry)...)
errors = append(errors, validateCardDiscardOutputs(card, p)...)
errors = append(errors, validateNegativeResourceOutputsForCard(card, p)...)
errors = append(errors, ValidateTileOutputs(card, p, g)...)
for _, behavior := range card.Behaviors {
if !gamecards.HasAutoTrigger(behavior) {
continue
}
warnings = append(warnings, validateGlobalParamWarnings(behavior.Outputs, g)...)
for _, choice := range behavior.Choices {
warnings = append(warnings, validateGlobalParamWarnings(choice.Outputs, g)...)
}
}
return player.EntityState{
Errors: errors,
Warnings: warnings,
Cost: costMap,
Metadata: metadata,
LastCalculated: time.Now(),
}
}
// CalculatePlayerCardActionState computes usability state for a card action.
func CalculatePlayerCardActionState(
cardID string,
behavior shared.CardBehavior,
timesUsedThisGeneration int,
p *player.Player,
g *game.Game,
cardRegistry ...cards.CardRegistry,
) player.EntityState {
var errors []player.StateError
errors = append(errors, validatePhase(g)...)
currentTurn := g.CurrentTurn()
if currentTurn != nil && currentTurn.PlayerID() != p.ID() {
errors = append(errors, player.StateError{
Code: player.ErrorCodeNotYourTurn,
Category: player.ErrorCategoryTurn,
Message: "Not your turn",
})
}
errors = append(errors, validateActionsRemaining(p, g)...)
errors = append(errors, validateNoPendingSelection(p, g)...)
resources := p.Resources().Get()
for _, inputBC := range behavior.Inputs {
// Skip variable-amount inputs — the player selects how much to spend (can be 0)
if shared.IsVariableAmount(inputBC) {
continue
}
rt := inputBC.GetResourceType()
amt := inputBC.GetAmount()
target := inputBC.GetTarget()
// Storage resource inputs (target: "self-card") check card storage instead of player resources
if target == "self-card" && gamecards.IsStorageResourceType(rt) {
storage := p.Resources().GetCardStorage(cardID)
if storage < amt {
errors = append(errors, player.StateError{
Code: player.ErrorCodeInsufficientResources,
Category: player.ErrorCategoryInput,
Message: fmt.Sprintf("Not enough %s on card", rt),
})
}
continue
}
// Credit inputs with paymentAllowed consider alternative resources (e.g., titanium)
paymentAllowed := shared.GetPaymentAllowed(inputBC)
if rt == shared.ResourceCredit && len(paymentAllowed) > 0 {
effectiveCredits := resources.Credits
substitutes := p.Resources().PaymentSubstitutes()
for _, allowed := range paymentAllowed {
for _, sub := range substitutes {
if sub.ResourceType == allowed {
available := resources.GetAmount(allowed)
effectiveCredits += available * sub.ConversionRate
}
}
}
if effectiveCredits < amt {
errors = append(errors, player.StateError{
Code: player.ErrorCodeInsufficientResources,
Category: player.ErrorCategoryInput,
Message: fmt.Sprintf("Not enough %s", rt),
})
}
continue
}
// Production inputs check player production instead of basic resources
if shared.IsProductionResourceType(rt) {
available := p.Resources().Production().GetAmount(rt)
if available < amt {
errors = append(errors, player.StateError{
Code: player.ErrorCodeInsufficientResources,
Category: player.ErrorCategoryInput,
Message: fmt.Sprintf("Not enough %s", rt),
})
}
continue
}
available := resources.GetAmount(rt)
if available < amt {
errors = append(errors, player.StateError{
Code: player.ErrorCodeInsufficientResources,
Category: player.ErrorCategoryInput,
Message: fmt.Sprintf("Not enough %s", rt),
})
}
}
for _, outputBC := range behavior.Outputs {
if outputBC.GetTarget() == "steal-from-any-card" {
totalAvailable := 0
var reg cards.CardRegistry
if len(cardRegistry) > 0 {
reg = cardRegistry[0]
}
for _, anyPlayer := range g.GetAllPlayers() {
for _, playerCardID := range anyPlayer.PlayedCards().Cards() {
if playerCardID == cardID && anyPlayer.ID() == p.ID() {
continue
}
storage := anyPlayer.Resources().GetCardStorage(playerCardID)
if storage <= 0 {
continue
}
if reg != nil {
registryCard, err := reg.GetByID(playerCardID)
if err != nil || registryCard.ResourceStorage == nil {
continue
}
if registryCard.ResourceStorage.Type != outputBC.GetResourceType() {
continue
}
}
totalAvailable += storage
}
}
if totalAvailable < outputBC.GetAmount() {
errors = append(errors, player.StateError{
Code: player.ErrorCodeInsufficientResources,
Category: player.ErrorCategoryInput,
Message: fmt.Sprintf("No %s available on any card", outputBC.GetResourceType()),
})
}
}
}
errors = append(errors, validateActionUsageLimit(behavior, timesUsedThisGeneration)...)
errors = append(errors, validateActionReuseAvailability(cardID, behavior, p)...)
errors = append(errors, validateBehaviorTileOutputs(behavior, p, g)...)
errors = append(errors, validateGenerationalEventRequirements(behavior, p)...)
errors = append(errors, validateNegativeResourceOutputs(behavior, p)...)
var warnings []player.StateWarning
warnings = append(warnings, validateGlobalParamWarnings(behavior.Outputs, g)...)
for _, choice := range behavior.Choices {
warnings = append(warnings, validateGlobalParamWarnings(choice.Outputs, g)...)
}
var reg cards.CardRegistry
if len(cardRegistry) > 0 {
reg = cardRegistry[0]
}
computedValues := computeBehaviorValues([]shared.CardBehavior{behavior}, cardID, p, g, reg, nil)
return player.EntityState{
Errors: errors,
Warnings: warnings,
Cost: make(map[string]int),
Metadata: make(map[string]any),
ComputedValues: computedValues,
LastCalculated: time.Now(),
}
}
// CalculatePlayerStandardProjectState computes availability state for a standard project.
func CalculatePlayerStandardProjectState(
projectType shared.StandardProject,
p *player.Player,
g *game.Game,
cardRegistry cards.CardRegistry,
) player.EntityState {
var errors []player.StateError
var warnings []player.StateWarning
metadata := make(map[string]interface{})
if g.CurrentPhase() == shared.GamePhaseFinalPhase && projectType != shared.StandardProjectConvertPlantsToGreenery {
errors = append(errors, player.StateError{
Code: player.ErrorCodeWrongPhase,
Category: player.ErrorCategoryPhase,
Message: "Only greenery conversion allowed in final phase",
})
}
errors = append(errors, validateActionsRemaining(p, g)...)
errors = append(errors, validateNoPendingSelection(p, g)...)
baseCosts := getStandardProjectBaseCosts(projectType)
if baseCosts == nil {
errors = append(errors, player.StateError{
Code: player.ErrorCodeInvalidProjectType,
Category: player.ErrorCategoryConfiguration,
Message: fmt.Sprintf("Unknown standard project type: %s", projectType),
})
return player.EntityState{
Errors: errors,
Cost: make(map[string]int),
Metadata: metadata,
LastCalculated: time.Now(),
}
}
calculator := gamecards.NewRequirementModifierCalculator(cardRegistry)
projectDiscounts := calculator.CalculateStandardProjectDiscounts(p, projectType)
effectiveCosts := make(map[string]int)
discounts := make(map[string]int)
for resourceType, amount := range baseCosts {
discount := projectDiscounts[shared.ResourceType(resourceType)]
effectiveCost := amount - discount
if effectiveCost < 0 {
effectiveCost = 0
}
effectiveCosts[resourceType] = effectiveCost
if discount > 0 {
discounts[resourceType] = discount
}
}
if len(discounts) > 0 {
metadata["discounts"] = discounts
}
storageSubstituteValue := calculateStorageSubstituteValueForProject(p, projectType)
if storageSubstituteValue > 0 {
metadata["storageSubstituteValue"] = storageSubstituteValue
}
errors = append(errors, validateAffordabilityMapWithExtraResources(p, effectiveCosts, storageSubstituteValue, projectType)...)
switch projectType {
case shared.StandardProjectSellPatents:
cardCount := p.Hand().CardCount()
if cardCount == 0 {
errors = append(errors, player.StateError{
Code: player.ErrorCodeNoCardsInHand,
Category: player.ErrorCategoryAvailability,
Message: "No cards in hand to sell",
})
}
case shared.StandardProjectAquifer:
currentOceans := g.GlobalParameters().Oceans()
oceansRemaining := 9 - currentOceans
metadata["oceansRemaining"] = oceansRemaining
if oceansRemaining <= 0 {
errors = append(errors, player.StateError{
Code: player.ErrorCodeNoOceanTiles,
Category: player.ErrorCategoryAvailability,
Message: "No ocean tiles remaining",
})
}
case shared.StandardProjectAsteroid:
if g.GlobalParameters().Temperature() >= global_parameters.MaxTemperature {
warnings = append(warnings, player.StateWarning{
Code: player.WarningCodeGlobalParamMaxed,
Message: "Temperature is already at maximum",
})
}
case shared.StandardProjectCity:
cityPlacements := g.CountAvailableHexesForTile("city", p.ID(), nil)
if cityPlacements == 0 {
errors = append(errors, player.StateError{
Code: player.ErrorCodeNoCityPlacements,
Category: player.ErrorCategoryAvailability,
Message: "No valid city placements",
})
}
case shared.StandardProjectGreenery:
greeneryPlacements := g.CountAvailableHexesForTile("greenery", p.ID(), nil)
if greeneryPlacements == 0 {
errors = append(errors, player.StateError{
Code: player.ErrorCodeNoGreeneryPlacements,
Category: player.ErrorCategoryAvailability,
Message: "No valid greenery placements",
})
}
if g.GlobalParameters().Oxygen() >= global_parameters.MaxOxygen {
warnings = append(warnings, player.StateWarning{
Code: player.WarningCodeGlobalParamMaxed,
Message: "Oxygen is already at maximum",
})
}
case shared.StandardProjectPowerPlant:
case shared.StandardProjectAirScrapping:
if g.GlobalParameters().Venus() >= global_parameters.MaxVenus {
warnings = append(warnings, player.StateWarning{
Code: player.WarningCodeGlobalParamMaxed,
Message: "Venus is already at maximum",
})
}
case shared.StandardProjectConvertHeatToTemperature:
if g.GlobalParameters().Temperature() >= global_parameters.MaxTemperature {
warnings = append(warnings, player.StateWarning{
Code: player.WarningCodeGlobalParamMaxed,
Message: "Temperature already at maximum",
})
}
case shared.StandardProjectConvertPlantsToGreenery:
if g.GlobalParameters().Oxygen() >= global_parameters.MaxOxygen {
warnings = append(warnings, player.StateWarning{
Code: player.WarningCodeGlobalParamMaxed,
Message: "Oxygen already at maximum",
})
}
default:
}
return player.EntityState{
Errors: errors,
Warnings: warnings,
Cost: effectiveCosts,
Metadata: metadata,
LastCalculated: time.Now(),
}
}
// validateActionsRemaining checks if the player has actions remaining in their turn.
func validateActionsRemaining(p *player.Player, g *game.Game) []player.StateError {
currentTurn := g.CurrentTurn()
if currentTurn == nil {
return nil
}
if currentTurn.PlayerID() != p.ID() {
return nil
}
remaining := currentTurn.ActionsRemaining()
if remaining == 0 {
return []player.StateError{{
Code: player.ErrorCodeNoActionsRemaining,
Category: player.ErrorCategoryTurn,
Message: "No actions remaining",
}}
}
return nil
}
// validatePhase checks if action is allowed in current phase.
func validatePhase(g *game.Game) []player.StateError {
if g.CurrentPhase() != shared.GamePhaseAction {
return []player.StateError{{
Code: player.ErrorCodeWrongPhase,
Category: player.ErrorCategoryPhase,
Message: "Not in action phase",
}}
}
return nil
}
// validateNoPendingSelection checks if player has any pending selection that blocks actions.
func validateNoPendingSelection(p *player.Player, g *game.Game) []player.StateError {
if g.HasAnyPendingSelection(p.ID()) {
return []player.StateError{{
Code: player.ErrorCodeActiveTileSelection,
Category: player.ErrorCategoryPhase,
Message: "Pending selection",
}}
}
return nil
}
// validateActionUsageLimit checks if the action has already been used this generation.
// Manual trigger actions can only be used once per generation by default.
func validateActionUsageLimit(
behavior shared.CardBehavior,
timesUsedThisGeneration int,
) []player.StateError {
hasManualTrigger := false
for _, trigger := range behavior.Triggers {
if trigger.Type == shared.TriggerTypeManual {
hasManualTrigger = true
break
}
}
if !hasManualTrigger {
return nil
}
if timesUsedThisGeneration >= 1 {
return []player.StateError{{
Code: player.ErrorCodeActionAlreadyPlayed,
Category: player.ErrorCategoryAvailability,
Message: "Already played",
}}
}
return nil
}
func validateActionReuseAvailability(
cardID string,
behavior shared.CardBehavior,
p *player.Player,
) []player.StateError {
hasActionReuse := false
for _, output := range behavior.Outputs {
if output.GetResourceType() == shared.ResourceActionReuse {
hasActionReuse = true
break
}
}
if !hasActionReuse {
return nil
}
for _, act := range p.Actions().List() {
if act.CardID == cardID {
continue
}
hasManual := false
for _, trigger := range act.Behavior.Triggers {
if trigger.Type == shared.TriggerTypeManual {
hasManual = true
break
}
}
if hasManual && act.TimesUsedThisGeneration >= 1 {
return nil
}
}
return []player.StateError{{
Code: player.ErrorCodeNoUsedActions,
Category: player.ErrorCategoryAvailability,
Message: "No used actions to reuse",
}}
}
// validateRequirements checks all card requirements.
// Includes global parameter lenience from temporary effects (e.g., Special Design).
func validateRequirements(
card *gamecards.Card,
p *player.Player,
g *game.Game,
cardRegistry cards.CardRegistry,
) []player.StateError {
if card.Requirements == nil || len(card.Requirements.Items) == 0 {
return nil
}
calculator := gamecards.NewRequirementModifierCalculator(cardRegistry)
if calculator.HasIgnoreGlobalRequirements(p) {
return nil
}
var errors []player.StateError
for _, req := range card.Requirements.Items {
err := checkRequirement(req, p, g, cardRegistry, calculator)
if err != nil {
errors = append(errors, *err)
}
}
return errors
}
// validateProductionOutputs checks that player has enough production for negative production outputs.
// Cards like "Urbanized Area" have negative production outputs (e.g., -1 energy production).
// The player must have at least that much production to play the card.
func validateProductionOutputs(
card *gamecards.Card,
p *player.Player,
) []player.StateError {
if len(card.Behaviors) == 0 {
return nil
}
var errors []player.StateError
production := p.Resources().Production()
// Check all behaviors for auto-triggers with negative production outputs
for _, behavior := range card.Behaviors {
// Only check auto-trigger behaviors (immediate effects when card is played)
if !gamecards.HasAutoTrigger(behavior) {
continue
}
// Check outputs for negative production
for _, outputBC := range behavior.Outputs {
// Skip variable-amount outputs — the player controls the amount
if shared.IsVariableAmount(outputBC) {
continue
}
// Only check production resource types with negative amounts
if outputBC.GetAmount() >= 0 {
continue
}
// Map production resource types to base resource types for checking
var baseResourceType shared.ResourceType
switch outputBC.GetResourceType() {
case shared.ResourceCreditProduction:
baseResourceType = shared.ResourceCredit
case shared.ResourceSteelProduction:
baseResourceType = shared.ResourceSteel
case shared.ResourceTitaniumProduction:
baseResourceType = shared.ResourceTitanium
case shared.ResourcePlantProduction:
baseResourceType = shared.ResourcePlant
case shared.ResourceEnergyProduction:
baseResourceType = shared.ResourceEnergy
case shared.ResourceHeatProduction:
baseResourceType = shared.ResourceHeat
default:
// Not a production type, skip
continue
}
// Check if player has enough production
currentProduction := production.GetAmount(baseResourceType)
resultingProduction := currentProduction + outputBC.GetAmount()
// MC production can go to -5, others cannot go below 0
var minProduction int
if baseResourceType == shared.ResourceCredit {
minProduction = shared.MinCreditProduction
} else {
minProduction = shared.MinOtherProduction
}
if resultingProduction < minProduction {
errors = append(errors, player.StateError{
Code: player.ErrorCodeInsufficientProduction,
Category: player.ErrorCategoryRequirement,
Message: formatInsufficientProductionMessage(baseResourceType),
})
}
}
}
return errors
}
// isBasicPlayerResource returns true for the 6 basic player resource types.
func isBasicPlayerResource(rt shared.ResourceType) bool {
switch rt {
case shared.ResourceCredit, shared.ResourceSteel, shared.ResourceTitanium,
shared.ResourcePlant, shared.ResourceEnergy, shared.ResourceHeat:
return true
}
return false
}
// validateNegativeResourceOutputsForCard checks all auto-trigger behaviors on a card
// for negative resource outputs (e.g., "spend 5 heat" modeled as heat: -5).
func validateNegativeResourceOutputsForCard(
card *gamecards.Card,
p *player.Player,
) []player.StateError {
var errors []player.StateError
for _, behavior := range card.Behaviors {
if !gamecards.HasAutoTrigger(behavior) {
continue
}
errors = append(errors, validateNegativeResourceOutputs(behavior, p)...)
}
return errors
}
// validateNegativeResourceOutputs checks that the player has enough resources for negative
// resource outputs in a card action behavior's base outputs.
// This is a defense-in-depth check: card costs should normally be modeled as inputs,
// but this catches any negative resource outputs that slip through.
func validateNegativeResourceOutputs(
behavior shared.CardBehavior,
p *player.Player,
) []player.StateError {
var errors []player.StateError
resources := p.Resources().Get()
for _, outputBC := range behavior.Outputs {
if shared.IsVariableAmount(outputBC) || outputBC.GetAmount() >= 0 {
continue
}
if outputBC.GetTarget() != "" && outputBC.GetTarget() != "self-player" {
continue
}
if !isBasicPlayerResource(outputBC.GetResourceType()) {
continue
}
available := resources.GetAmount(outputBC.GetResourceType())
required := -outputBC.GetAmount()
if available < required {
errors = append(errors, player.StateError{
Code: player.ErrorCodeInsufficientResources,
Category: player.ErrorCategoryInput,
Message: fmt.Sprintf("Not enough %s", outputBC.GetResourceType()),
})
}
}
return errors
}
// validateCardResourceOutputs checks that the player has at least one valid target card
// with resource storage for card-resource outputs with any-card target.
func validateCardResourceOutputs(
card *gamecards.Card,
p *player.Player,
cardRegistry cards.CardRegistry,
) []player.StateError {
if len(card.Behaviors) == 0 || cardRegistry == nil {
return nil
}
for _, behavior := range card.Behaviors {
if !gamecards.HasAutoTrigger(behavior) {
continue
}
// Check both direct outputs and choice outputs
allOutputs := behavior.Outputs
for _, choice := range behavior.Choices {
allOutputs = append(allOutputs, choice.Outputs...)
}
for _, outputBC := range allOutputs {
if outputBC.GetResourceType() != shared.ResourceCardResource || outputBC.GetTarget() != "any-card" {
continue
}
selectors := shared.GetSelectors(outputBC)
// Check if player has any played card with resource storage matching selectors
hasValidTarget := false
for _, playedCardID := range p.PlayedCards().Cards() {
playedCard, err := cardRegistry.GetByID(playedCardID)
if err != nil {
continue
}
if playedCard.ResourceStorage == nil {
continue
}
// If selectors specified, card must match
if len(selectors) > 0 && !gamecards.MatchesAnySelector(playedCard, selectors) {
continue
}
hasValidTarget = true
break
}
if !hasValidTarget {
return []player.StateError{{
Code: player.ErrorCodeInsufficientResources,
Category: player.ErrorCategoryAvailability,
Message: "No valid card with resource storage",
}}
}
}
}
return nil
}
// validateColonyBonusStorageTargets warns when colony bonuses include card-targeted resources
// but the player has no valid card to store them on.
func validateColonyBonusStorageTargets(
card *gamecards.Card,
p *player.Player,
g *game.Game,
cardRegistry cards.CardRegistry,
colonyBonusLookup gamecards.ColonyBonusLookup,
) []player.StateWarning {
if colonyBonusLookup == nil || g == nil || !g.HasColonies() {
return nil
}
hasColonyBonusOutput := false
for _, behavior := range card.Behaviors {
if !gamecards.HasAutoTrigger(behavior) {
continue
}
for _, output := range behavior.Outputs {
if output.GetResourceType() == shared.ResourceColonyBonus {
hasColonyBonusOutput = true
break
}
}
}
if !hasColonyBonusOutput {
return nil
}
bonuses := gamecards.CollectColonyBonuses(p.ID(), g.Colonies().States(), colonyBonusLookup)
hasReward := false
for _, b := range bonuses {
if b.Amount > 0 {
hasReward = true
break
}
}
if !hasReward {
return []player.StateWarning{{
Code: "no-colony-bonus-reward",
Message: "No reward",
}}
}
var warnings []player.StateWarning
seen := map[string]bool{}
for _, b := range bonuses {
rt := shared.ResourceType(b.ResourceType)
if !gamecards.IsStorageResourceType(rt) || seen[b.ResourceType] {
continue
}
seen[b.ResourceType] = true
if !gamecards.HasEligibleStorageCard(p, rt, cardRegistry) {
warnings = append(warnings, player.StateWarning{
Code: "no-storage-for-colony-bonus",
Message: fmt.Sprintf("No %s storage", b.ResourceType),
})
}
}
return warnings
}
// validateCardDiscardOutputs checks that the player has enough cards in hand to satisfy
// card-discard outputs when playing a card.
func validateCardDiscardOutputs(
card *gamecards.Card,
p *player.Player,
) []player.StateError {
if len(card.Behaviors) == 0 {
return nil
}
totalDiscard := 0
for _, behavior := range card.Behaviors {
if !gamecards.HasAutoTrigger(behavior) {
continue
}
for _, output := range behavior.Outputs {
if output.GetResourceType() == shared.ResourceCardDiscard && output.GetTarget() == "self-player" {
totalDiscard += output.GetAmount()
}
}
for _, choice := range behavior.Choices {
for _, output := range choice.Outputs {
if output.GetResourceType() == shared.ResourceCardDiscard && output.GetTarget() == "self-player" {
totalDiscard += output.GetAmount()
}
}
}
}
if totalDiscard == 0 {
return nil
}
// The card being played leaves the hand first, so available cards = hand - 1
availableCards := p.Hand().CardCount() - 1
if availableCards < totalDiscard {
return []player.StateError{{
Code: player.ErrorCodeNoCardsInHand,
Category: player.ErrorCategoryAvailability,
Message: "Not enough cards to discard",
}}
}
return nil
}
// ValidateTileOutputs checks that the board has available placements for any tile outputs.
// If a card outputs city/greenery/ocean tiles, the player must have valid placement locations.
func ValidateTileOutputs(
card *gamecards.Card,
p *player.Player,
g *game.Game,
) []player.StateError {
if len(card.Behaviors) == 0 || g == nil {
return nil
}
var errors []player.StateError
for _, behavior := range card.Behaviors {
if !gamecards.HasAutoTrigger(behavior) {
continue
}
for _, outputBC := range behavior.Outputs {
switch outputBC.GetResourceType() {
case shared.ResourceCityPlacement:
cityPlacements := g.CountAvailableHexesForTile("city", p.ID(), shared.GetTileRestrictions(outputBC))
if cityPlacements == 0 {
errors = append(errors, player.StateError{
Code: player.ErrorCodeNoCityPlacements,
Category: player.ErrorCategoryAvailability,
Message: "No valid city placements",
})
}
case shared.ResourceGreeneryPlacement:
greeneryPlacements := g.CountAvailableHexesForTile("greenery", p.ID(), shared.GetTileRestrictions(outputBC))
if greeneryPlacements == 0 {
errors = append(errors, player.StateError{
Code: player.ErrorCodeNoGreeneryPlacements,
Category: player.ErrorCategoryAvailability,
Message: "No valid greenery placements",
})
}
case shared.ResourceOceanPlacement:
oceanPlacements := g.CountAvailableHexesForTile("ocean", p.ID(), nil)
if oceanPlacements == 0 {
errors = append(errors, player.StateError{
Code: player.ErrorCodeNoOceanTiles,
Category: player.ErrorCategoryAvailability,
Message: "No ocean tiles remaining",
})
}
case shared.ResourceVolcanoPlacement:
volcanoPlacements := g.CountAvailableHexesForTile("volcano", p.ID(), nil)
if volcanoPlacements == 0 {
errors = append(errors, player.StateError{
Code: player.ErrorCodeNoTilePlacements,
Category: player.ErrorCategoryAvailability,
Message: "No valid volcanic placements",
})
}
case shared.ResourceTileReplacement:
var tileType string
if tc, ok := outputBC.(*shared.TileModificationCondition); ok {
tileType = tc.TileType
}
if tileType == "" {
continue
}
replacementPlacements := g.CountAvailableHexesForTile("tile-replacement:"+tileType, p.ID(), nil)
if replacementPlacements == 0 {
errors = append(errors, player.StateError{
Code: player.ErrorCodeNoTilePlacements,
Category: player.ErrorCategoryAvailability,
Message: "No valid tile placements",
})
}
case shared.ResourceTilePlacement:
var tileType string
var tileRestrictions *shared.TileRestrictions
if tc, ok := outputBC.(*shared.TilePlacementCondition); ok {
tileType = tc.TileType
tileRestrictions = tc.TileRestrictions
}
if tileType == "" {
continue
}
tilePlacements := g.CountAvailableHexesForTile(tileType, p.ID(), tileRestrictions)
if tilePlacements == 0 {
errors = append(errors, player.StateError{
Code: player.ErrorCodeNoTilePlacements,
Category: player.ErrorCategoryAvailability,
Message: "No valid tile placements",
})
}
}
}
}
return errors
}
func validateGenerationalEventRequirements(
behavior shared.CardBehavior,
p *player.Player,
) []player.StateError {
if len(behavior.GenerationalEventRequirements) == 0 {
return nil
}
var errors []player.StateError
playerEvents := p.GenerationalEvents()
for _, req := range behavior.GenerationalEventRequirements {
count := playerEvents.GetCount(req.Event)
if req.Count != nil {
if req.Count.Min != nil && count < *req.Count.Min {
errors = append(errors, player.StateError{
Code: player.ErrorCodeGenerationalEventNotMet,
Category: player.ErrorCategoryRequirement,
Message: formatGenerationalEventError(req.Event),
})
}
if req.Count.Max != nil && count > *req.Count.Max {
errors = append(errors, player.StateError{
Code: player.ErrorCodeGenerationalEventNotMet,
Category: player.ErrorCategoryRequirement,
Message: formatGenerationalEventError(req.Event),
})
}
}
}
return errors
}
func formatGenerationalEventError(event shared.GenerationalEvent) string {
switch event {
case shared.GenerationalEventTRRaise:
return "TR not raised this generation"
case shared.GenerationalEventOceanPlacement:
return "Ocean not placed this generation"
case shared.GenerationalEventCityPlacement:
return "City not placed this generation"
case shared.GenerationalEventGreeneryPlacement:
return "Greenery not placed this generation"
default:
return "Generational event requirement not met"
}
}
// validateBehaviorTileOutputs checks tile availability for a single behavior's outputs.
// Used by card action state calculation.
func validateBehaviorTileOutputs(
behavior shared.CardBehavior,
p *player.Player,
g *game.Game,
) []player.StateError {
if g == nil {
return nil
}
var errors []player.StateError
// Check outputs for tile placements
for _, outputBC := range behavior.Outputs {
switch outputBC.GetResourceType() {
case shared.ResourceCityPlacement:
cityPlacements := g.CountAvailableHexesForTile("city", p.ID(), nil)
if cityPlacements == 0 {
errors = append(errors, player.StateError{
Code: player.ErrorCodeNoCityPlacements,
Category: player.ErrorCategoryAvailability,
Message: "No valid city placements",
})
}
case shared.ResourceGreeneryPlacement:
greeneryPlacements := g.CountAvailableHexesForTile("greenery", p.ID(), nil)
if greeneryPlacements == 0 {
errors = append(errors, player.StateError{
Code: player.ErrorCodeNoGreeneryPlacements,
Category: player.ErrorCategoryAvailability,
Message: "No valid greenery placements",
})
}
case shared.ResourceOceanPlacement:
oceanPlacements := g.CountAvailableHexesForTile("ocean", p.ID(), nil)
if oceanPlacements == 0 {
errors = append(errors, player.StateError{
Code: player.ErrorCodeNoOceanTiles,
Category: player.ErrorCategoryAvailability,
Message: "No ocean tiles remaining",
})
}
case shared.ResourceTilePlacement:
if tc, ok := outputBC.(*shared.TilePlacementCondition); ok {
if tc.TileType == "" {
continue
}
tilePlacements := g.CountAvailableHexesForTile(tc.TileType, p.ID(), tc.TileRestrictions)
if tilePlacements == 0 {
errors = append(errors, player.StateError{
Code: player.ErrorCodeNoTilePlacements,
Category: player.ErrorCategoryAvailability,
Message: "No valid tile placements",
})
}
}
}
}
return errors
}
// checkRequirement validates a single requirement.
// Uses calculator to compute per-parameter lenience from player effects.
func checkRequirement(
req gamecards.Requirement,
p *player.Player,
g *game.Game,
cardRegistry cards.CardRegistry,
calculator *gamecards.RequirementModifierCalculator,
) *player.StateError {
switch req.Type {
case gamecards.RequirementTemperature:
lenience := calculator.CalculateGlobalParameterLenience(p, "temperature")
temp := g.GlobalParameters().Temperature()
if req.Min != nil && temp < *req.Min-lenience {
return &player.StateError{
Code: player.ErrorCodeTemperatureTooLow,
Category: player.ErrorCategoryRequirement,
Message: "Temperature too low",
}
}
if req.Max != nil && temp > *req.Max+lenience {
return &player.StateError{
Code: player.ErrorCodeTemperatureTooHigh,
Category: player.ErrorCategoryRequirement,
Message: "Temperature too high",
}
}
case gamecards.RequirementOxygen:
lenience := calculator.CalculateGlobalParameterLenience(p, "oxygen")
oxygen := g.GlobalParameters().Oxygen()
if req.Min != nil && oxygen < *req.Min-lenience {
return &player.StateError{
Code: player.ErrorCodeOxygenTooLow,
Category: player.ErrorCategoryRequirement,
Message: "Oxygen too low",
}
}
if req.Max != nil && oxygen > *req.Max+lenience {
return &player.StateError{
Code: player.ErrorCodeOxygenTooHigh,
Category: player.ErrorCategoryRequirement,
Message: "Oxygen too high",
}
}
case gamecards.RequirementOceans:
lenience := calculator.CalculateGlobalParameterLenience(p, "ocean")
oceans := g.GlobalParameters().Oceans()
if req.Min != nil && oceans < *req.Min-lenience {
return &player.StateError{
Code: player.ErrorCodeOceansTooLow,
Category: player.ErrorCategoryRequirement,
Message: "Too few oceans",
}
}
if req.Max != nil && oceans > *req.Max+lenience {
return &player.StateError{
Code: player.ErrorCodeOceansTooHigh,
Category: player.ErrorCategoryRequirement,
Message: "Too many oceans",
}
}
case gamecards.RequirementTR:
tr := p.Resources().TerraformRating()
if req.Min != nil && tr < *req.Min {
return &player.StateError{
Code: player.ErrorCodeTRTooLow,
Category: player.ErrorCategoryRequirement,
Message: "TR too low",
}
}
if req.Max != nil && tr > *req.Max {
return &player.StateError{
Code: player.ErrorCodeTRTooHigh,
Category: player.ErrorCategoryRequirement,
Message: "TR too high",
}
}
case gamecards.RequirementTags:
if req.Tag == nil {
return &player.StateError{
Code: player.ErrorCodeInvalidRequirement,
Category: player.ErrorCategoryRequirement,
Message: "Invalid tag requirement",
}
}
tagCount := 0
if cardRegistry != nil {
tagCount = gamecards.CountPlayerTagsByType(p, cardRegistry, *req.Tag)
}
if req.Min != nil && tagCount < *req.Min {
return &player.StateError{
Code: player.ErrorCodeInsufficientTags,
Category: player.ErrorCategoryRequirement,
Message: formatInsufficientTagsMessage(string(*req.Tag)),
}
}
if req.Max != nil && tagCount > *req.Max {
return &player.StateError{
Code: player.ErrorCodeTooManyTags,
Category: player.ErrorCategoryRequirement,
Message: formatTooManyTagsMessage(string(*req.Tag)),
}
}
case gamecards.RequirementProduction:
if req.Resource == nil {
return &player.StateError{
Code: player.ErrorCodeInvalidRequirement,
Category: player.ErrorCategoryRequirement,
Message: "Invalid production requirement",
}
}
// Validate resource type is producible (only 6 basic resources have production)
if !isProducibleResource(*req.Resource) {
return &player.StateError{
Code: player.ErrorCodeInvalidRequirement,
Category: player.ErrorCategoryRequirement,
Message: "Invalid production type",
}
}
production := p.Resources().Production()
currentProduction := production.GetAmount(*req.Resource)
if req.Min != nil && currentProduction < *req.Min {
return &player.StateError{
Code: player.ErrorCodeInsufficientProduction,
Category: player.ErrorCategoryRequirement,
Message: formatInsufficientProductionMessage(*req.Resource),
}
}
// Note: No max check needed for production requirements in base game
case gamecards.RequirementResource:
if req.Resource == nil {
return &player.StateError{
Code: player.ErrorCodeInvalidRequirement,
Category: player.ErrorCategoryRequirement,
Message: "Invalid resource requirement",
}
}
resources := p.Resources().Get()
currentAmount := resources.GetAmount(*req.Resource)
if req.Min != nil && currentAmount < *req.Min {
return &player.StateError{
Code: player.ErrorCodeInsufficientResources,
Category: player.ErrorCategoryRequirement,
Message: formatInsufficientResourceMessage(*req.Resource),
}
}
if req.Max != nil && currentAmount > *req.Max {
return &player.StateError{
Code: player.ErrorCodeTooManyResources,
Category: player.ErrorCategoryRequirement,
Message: formatTooMuchResourceMessage(*req.Resource),
}
}
case gamecards.RequirementCities, gamecards.RequirementGreeneries:
// TODO: Implement tile-based requirements when Board tile counting is ready
// For now, skip these validations (same as PlayCardAction line 310-312)
case gamecards.RequirementColony:
colonyCount := g.Colonies().CountPlayerColonies(p.ID())
if req.Min != nil && colonyCount < *req.Min {
return &player.StateError{
Code: player.ErrorCodeColoniesTooFew,
Category: player.ErrorCategoryRequirement,
Message: "Not enough colonies",
}
}
if req.Max != nil && colonyCount > *req.Max {
return &player.StateError{
Code: player.ErrorCodeColoniesTooMany,
Category: player.ErrorCategoryRequirement,
Message: "Too many colonies",
}
}
case gamecards.RequirementVenus:
lenience := calculator.CalculateGlobalParameterLenience(p, "venus")
venus := g.GlobalParameters().Venus()
if req.Min != nil && venus < *req.Min-lenience {
return &player.StateError{
Code: player.ErrorCodeVenusTooLow,
Category: player.ErrorCategoryRequirement,
Message: "Venus too low",
}
}
if req.Max != nil && venus > *req.Max+lenience {
return &player.StateError{
Code: player.ErrorCodeVenusTooHigh,
Category: player.ErrorCategoryRequirement,
Message: "Venus too high",
}
}
}
return nil
}
// calculateEffectiveCost computes cost with discounts using RequirementModifierCalculator.
// Returns the effective cost map (resource type -> amount) and discounts map (resource type -> discount amount).
// Cards typically only cost credits, so the map will usually just have {"credits": X}.
func calculateEffectiveCost(card *gamecards.Card, p *player.Player, cardRegistry cards.CardRegistry) (map[string]int, map[string]int) {
baseCost := card.Cost
// Calculate discounts using RequirementModifierCalculator
calculator := gamecards.NewRequirementModifierCalculator(cardRegistry)
discountAmount := calculator.CalculateCardDiscounts(p, card)
effectiveCost := baseCost - discountAmount
if effectiveCost < 0 {
effectiveCost = 0
}
// Build cost map (cards cost credits only)
costMap := make(map[string]int)
if effectiveCost > 0 {
costMap[string(shared.ResourceCredit)] = effectiveCost
}
// Build discounts map for metadata
discounts := make(map[string]int)
if discountAmount > 0 {
discounts[string(shared.ResourceCredit)] = discountAmount
}
return costMap, discounts
}
// validateAffordability checks if player can afford the cost (single int, for credits).
func validateAffordability(p *player.Player, cost int) []player.StateError {
credits := p.Resources().Get().Credits
if credits < cost {
return []player.StateError{{
Code: player.ErrorCodeInsufficientCredits,
Category: player.ErrorCategoryCost,
Message: "Cannot afford",
}}
}
return nil
}
// validateAffordabilityMap checks if player can afford a multi-resource cost.
// Note: This function does NOT consider payment substitutes. Use validateAffordabilityWithSubstitutes for card costs.
func validateAffordabilityMap(p *player.Player, costMap map[string]int) []player.StateError {
var errors []player.StateError
resources := p.Resources().Get()
for resourceType, cost := range costMap {
var available int
var errorCode player.StateErrorCode
var errorMessage string
switch shared.ResourceType(resourceType) {
case shared.ResourceCredit:
available = resources.Credits
errorCode = player.ErrorCodeInsufficientCredits
errorMessage = "Cannot afford"
case shared.ResourceSteel:
available = resources.Steel
errorCode = player.ErrorCodeInsufficientResources
errorMessage = "Not enough steel"
case shared.ResourceTitanium:
available = resources.Titanium
errorCode = player.ErrorCodeInsufficientResources
errorMessage = "Not enough titanium"
case shared.ResourcePlant:
available = resources.Plants
errorCode = player.ErrorCodeInsufficientResources
errorMessage = "Not enough plants"
case shared.ResourceEnergy:
available = resources.Energy
errorCode = player.ErrorCodeInsufficientResources
errorMessage = "Not enough energy"
case shared.ResourceHeat:
available = resources.Heat
errorCode = player.ErrorCodeInsufficientResources
errorMessage = "Not enough heat"
default:
available = 0
errorCode = player.ErrorCodeInsufficientResources
errorMessage = fmt.Sprintf("Not enough %s", resourceType)
}
if available < cost {
errors = append(errors, player.StateError{
Code: errorCode,
Category: player.ErrorCategoryCost,
Message: errorMessage,
})
}
}
return errors
}
// calculateStorageSubstituteValueForProject returns the total extra resource value available
// from storage payment substitutes for resource conversion standard projects.
func calculateStorageSubstituteValueForProject(p *player.Player, projectType shared.StandardProject) int {
var targetResource shared.ResourceType
switch projectType {
case shared.StandardProjectConvertHeatToTemperature:
targetResource = shared.ResourceHeat
case shared.StandardProjectConvertPlantsToGreenery:
targetResource = shared.ResourcePlant
default:
return 0
}
totalValue := 0
for _, sub := range p.Resources().StoragePaymentSubstitutes() {
if sub.TargetResource == targetResource {
totalValue += p.Resources().GetCardStorage(sub.CardID) * sub.ConversionRate
}
}
return totalValue
}
// validateAffordabilityMapWithExtraResources checks affordability considering extra resources
// from storage substitutes for resource conversion projects.
func validateAffordabilityMapWithExtraResources(p *player.Player, costMap map[string]int, extraValue int, projectType shared.StandardProject) []player.StateError {
if extraValue == 0 {
return validateAffordabilityMap(p, costMap)
}
var targetResourceType string
switch projectType {
case shared.StandardProjectConvertHeatToTemperature:
targetResourceType = string(shared.ResourceHeat)
case shared.StandardProjectConvertPlantsToGreenery:
targetResourceType = string(shared.ResourcePlant)
default:
return validateAffordabilityMap(p, costMap)
}
adjustedCosts := make(map[string]int)
for k, v := range costMap {
if k == targetResourceType {
adjusted := v - extraValue
if adjusted < 0 {
adjusted = 0
}
adjustedCosts[k] = adjusted
} else {
adjustedCosts[k] = v
}
}
return validateAffordabilityMap(p, adjustedCosts)
}
// validateAffordabilityWithSubstitutes checks if player can afford a multi-resource cost,
// considering payment substitutes like Helion's heat-to-credit conversion and
// storage payment substitutes like Dirigibles' floaters.
// Steel is only counted for cards with the Building tag, titanium only for Space tag.
func validateAffordabilityWithSubstitutes(p *player.Player, card *gamecards.Card, costMap map[string]int) []player.StateError {
var errors []player.StateError
resources := p.Resources().Get()
substitutes := p.Resources().PaymentSubstitutes()
allowSteel := hasCardTag(card.Tags, shared.TagBuilding)
allowTitanium := hasCardTag(card.Tags, shared.TagSpace)
for resourceType, cost := range costMap {
if shared.ResourceType(resourceType) == shared.ResourceCredit {
// For credit costs, calculate effective purchasing power including substitutes
effectiveCredits := resources.Credits
// Add substitute resources at their conversion rates
for _, sub := range substitutes {
switch sub.ResourceType {
case shared.ResourceSteel:
if allowSteel {
effectiveCredits += resources.Steel * sub.ConversionRate
}
case shared.ResourceTitanium:
if allowTitanium {
effectiveCredits += resources.Titanium * sub.ConversionRate
}
case shared.ResourceHeat:
effectiveCredits += resources.Heat * sub.ConversionRate
case shared.ResourceEnergy:
effectiveCredits += resources.Energy * sub.ConversionRate
case shared.ResourcePlant:
effectiveCredits += resources.Plants * sub.ConversionRate
}
}
// Add storage payment substitutes (e.g., Dirigibles floaters for Venus cards)
for _, storageSub := range p.Resources().StoragePaymentSubstitutes() {
if len(storageSub.Selectors) == 0 || gamecards.MatchesAnySelector(card, storageSub.Selectors) {
stored := p.Resources().GetCardStorage(storageSub.CardID)
effectiveCredits += stored * storageSub.ConversionRate
}
}
if effectiveCredits < cost {
errors = append(errors, player.StateError{
Code: player.ErrorCodeInsufficientCredits,
Category: player.ErrorCategoryCost,
Message: "Cannot afford",
})
}
} else {
// Non-credit costs checked directly (no substitutes apply)
available := resources.GetAmount(shared.ResourceType(resourceType))
if available < cost {
errors = append(errors, player.StateError{
Code: player.ErrorCodeInsufficientCredits,
Category: player.ErrorCategoryCost,
Message: "Cannot afford",
})
}
}
}
return errors
}
// getStandardProjectBaseCosts returns the base cost map for a standard project.
// Most projects cost credits only, but convert-plants-to-greenery costs plants
// and convert-heat-to-temperature costs heat.
func getStandardProjectBaseCosts(projectType shared.StandardProject) map[string]int {
switch projectType {
case shared.StandardProjectConvertPlantsToGreenery:
return map[string]int{string(shared.ResourcePlant): 8}
case shared.StandardProjectConvertHeatToTemperature:
return map[string]int{string(shared.ResourceHeat): 8}
default:
// Credit-based projects
cost, exists := shared.StandardProjectCost[projectType]
if !exists {
return nil
}
if cost > 0 {
return map[string]int{string(shared.ResourceCredit): cost}
}
// For sell-patents (cost = 0), return empty map
return map[string]int{}
}
}
// GetStandardProjectBaseCosts is an exported version for use by mappers.
func GetStandardProjectBaseCosts(projectType shared.StandardProject) map[string]int {
return getStandardProjectBaseCosts(projectType)
}
// hasCardTag checks if a tag list contains a specific tag.
func hasCardTag(tags []shared.CardTag, tag shared.CardTag) bool {
for _, t := range tags {
if t == tag {
return true
}
}
return false
}
// isProducibleResource checks if the resource type is one of the 6 basic producible resources
// or their production variants (e.g., "titanium" or "titanium-production").
func isProducibleResource(resourceType shared.ResourceType) bool {
switch resourceType {
case shared.ResourceCredit, shared.ResourceSteel, shared.ResourceTitanium,
shared.ResourcePlant, shared.ResourceEnergy, shared.ResourceHeat,
shared.ResourceCreditProduction, shared.ResourceSteelProduction, shared.ResourceTitaniumProduction,
shared.ResourcePlantProduction, shared.ResourceEnergyProduction, shared.ResourceHeatProduction:
return true
default:
return false
}
}
// validateGlobalParamWarnings checks behavior outputs for global parameter raises
// that would have no effect because the parameter is already at maximum.
func validateGlobalParamWarnings(outputs []shared.BehaviorCondition, g *game.Game) []player.StateWarning {
var warnings []player.StateWarning
seen := make(map[player.StateWarningCode]bool)
addWarning := func(code player.StateWarningCode, msg string) {
if !seen[code] {
seen[code] = true
warnings = append(warnings, player.StateWarning{Code: code, Message: msg})
}
}
for _, output := range outputs {
if output.GetAmount() <= 0 && !shared.IsVariableAmount(output) {
continue
}
switch output.GetResourceType() {
case shared.ResourceTemperature:
if g.GlobalParameters().Temperature() >= global_parameters.MaxTemperature {
addWarning(player.WarningCodeGlobalParamMaxed, "Temperature already at maximum")
}
case shared.ResourceOxygen:
if g.GlobalParameters().Oxygen() >= global_parameters.MaxOxygen {
addWarning(player.WarningCodeGlobalParamMaxed, "Oxygen already at maximum")
}
case shared.ResourceOcean, shared.ResourceOceanTile, shared.ResourceOceanPlacement:
if g.GlobalParameters().Oceans() >= g.GlobalParameters().GetMaxOceans() {
addWarning(player.WarningCodeGlobalParamMaxed, "All oceans already placed")
}
case shared.ResourceVenus:
if g.GlobalParameters().Venus() >= global_parameters.MaxVenus {
addWarning(player.WarningCodeGlobalParamMaxed, "Venus already at maximum")
}
}
// Greenery tiles raise oxygen
if output.GetResourceType() == shared.ResourceGreeneryTile {
if g.GlobalParameters().Oxygen() >= global_parameters.MaxOxygen {
addWarning(player.WarningCodeNoTRGain, "Oxygen already at maximum")
}
}
}
return warnings
}
// resourceDisplayNames maps ResourceType values to human-readable names for error messages
var resourceDisplayNames = map[shared.ResourceType]string{
// Base resources
shared.ResourceCredit: "credits",
shared.ResourceSteel: "steel",
shared.ResourceTitanium: "titanium",
shared.ResourcePlant: "plants",
shared.ResourceEnergy: "energy",
shared.ResourceHeat: "heat",
shared.ResourceMicrobe: "microbes",
shared.ResourceAnimal: "animals",
shared.ResourceFloater: "floaters",
shared.ResourceScience: "science",
shared.ResourceAsteroid: "asteroids",
shared.ResourceDisease: "disease",
// Production types (map to base resource name)
shared.ResourceCreditProduction: "credit",
shared.ResourceSteelProduction: "steel",
shared.ResourceTitaniumProduction: "titanium",
shared.ResourcePlantProduction: "plant",
shared.ResourceEnergyProduction: "energy",
shared.ResourceHeatProduction: "heat",
// Global parameters
shared.ResourceTemperature: "temperature",
shared.ResourceOxygen: "oxygen",
shared.ResourceOcean: "ocean",
shared.ResourceVenus: "venus",
shared.ResourceTR: "terraform rating",
}
// getResourceDisplayName returns human-readable name for a ResourceType, falling back to the raw value
func getResourceDisplayName(resourceType shared.ResourceType) string {
if name, ok := resourceDisplayNames[resourceType]; ok {
return name
}
return string(resourceType)
}
func formatInsufficientResourceMessage(resourceType shared.ResourceType) string {
return fmt.Sprintf("Not enough %s", getResourceDisplayName(resourceType))
}
func formatTooMuchResourceMessage(resourceType shared.ResourceType) string {
return fmt.Sprintf("Too much %s", getResourceDisplayName(resourceType))
}
func formatInsufficientProductionMessage(resourceType shared.ResourceType) string {
return fmt.Sprintf("Not enough %s production", getResourceDisplayName(resourceType))
}
// formatTagDisplayName returns a human-readable tag name.
// Proper nouns (Venus, Earth, Jovian) get capitalized; other tags stay lowercase.
func formatTagDisplayName(tag string) string {
switch strings.ToLower(tag) {
case "venus", "earth", "jovian":
return strings.ToUpper(tag[:1]) + strings.ToLower(tag[1:])
default:
return strings.ToLower(tag)
}
}
func formatInsufficientTagsMessage(tag string) string {
return fmt.Sprintf("Not enough %s tags", formatTagDisplayName(tag))
}
func formatTooManyTagsMessage(tag string) string {
return fmt.Sprintf("Too many %s tags", formatTagDisplayName(tag))
}
// CalculateMilestoneState computes eligibility state for claiming a milestone.
// Returns EntityState with errors indicating why the milestone cannot be claimed.
func CalculateMilestoneState(
milestoneType shared.MilestoneType,
p *player.Player,
g *game.Game,
cardRegistry cards.CardRegistry,
milestoneRegistry milestones.MilestoneRegistry,
) player.EntityState {
var errors []player.StateError
metadata := make(map[string]interface{})
def, err := milestoneRegistry.GetByID(string(milestoneType))
if err != nil {
errors = append(errors, player.StateError{
Code: player.ErrorCodeInvalidRequirement,
Category: player.ErrorCategoryConfiguration,
Message: fmt.Sprintf("Unknown milestone type: %s", milestoneType),
})
return player.EntityState{
Errors: errors,
Cost: make(map[string]int),
Metadata: metadata,
LastCalculated: time.Now(),
}
}
ms := g.Milestones()
errors = append(errors, validateActionsRemaining(p, g)...)
errors = append(errors, validateNoPendingSelection(p, g)...)
if ms.IsClaimed(milestoneType) {
errors = append(errors, player.StateError{
Code: player.ErrorCodeMilestoneAlreadyClaimed,
Category: player.ErrorCategoryAchievement,
Message: "Already claimed",
})
}
if ms.ClaimedCount() >= game.MaxClaimedMilestones {
errors = append(errors, player.StateError{
Code: player.ErrorCodeMaxMilestonesClaimed,
Category: player.ErrorCategoryAchievement,
Message: "Maximum milestones claimed",
})
}
progress := gamecards.CalculateMilestoneProgress(def, p, g.Board(), cardRegistry)
required := def.GetRequired()
metadata["progress"] = progress
metadata["required"] = required
if progress < required {
errors = append(errors, player.StateError{
Code: player.ErrorCodeMilestoneRequirementNotMet,
Category: player.ErrorCategoryRequirement,
Message: "Requirement not met",
})
}
cost := def.ClaimCost
if p.Resources().Get().Credits < cost {
errors = append(errors, player.StateError{
Code: player.ErrorCodeInsufficientCredits,
Category: player.ErrorCategoryCost,
Message: "Cannot afford",
})
}
costMap := map[string]int{string(shared.ResourceCredit): cost}
return player.EntityState{
Errors: errors,
Cost: costMap,
Metadata: metadata,
LastCalculated: time.Now(),
}
}
// CalculateAwardState computes eligibility state for funding an award.
// Returns EntityState with errors indicating why the award cannot be funded.
func CalculateAwardState(
awardType shared.AwardType,
p *player.Player,
g *game.Game,
awardRegistry awards.AwardRegistry,
) player.EntityState {
var errors []player.StateError
metadata := make(map[string]interface{})
def, err := awardRegistry.GetByID(string(awardType))
if err != nil {
errors = append(errors, player.StateError{
Code: player.ErrorCodeInvalidRequirement,
Category: player.ErrorCategoryConfiguration,
Message: fmt.Sprintf("Unknown award type: %s", awardType),
})
return player.EntityState{
Errors: errors,
Cost: make(map[string]int),
Metadata: metadata,
LastCalculated: time.Now(),
}
}
gameAwards := g.Awards()
errors = append(errors, validateActionsRemaining(p, g)...)
errors = append(errors, validateNoPendingSelection(p, g)...)
if gameAwards.IsFunded(awardType) {
errors = append(errors, player.StateError{
Code: player.ErrorCodeAwardAlreadyFunded,
Category: player.ErrorCategoryAchievement,
Message: "Already funded",
})
}
if gameAwards.FundedCount() >= game.MaxFundedAwards {
errors = append(errors, player.StateError{
Code: player.ErrorCodeMaxAwardsFunded,
Category: player.ErrorCategoryAchievement,
Message: "Maximum awards funded",
})
}
cost := def.GetCostForFundedCount(gameAwards.FundedCount())
metadata["fundingCost"] = cost
if p.Resources().Get().Credits < cost {
errors = append(errors, player.StateError{
Code: player.ErrorCodeInsufficientCredits,
Category: player.ErrorCategoryCost,
Message: "Cannot afford",
})
}
costMap := map[string]int{string(shared.ResourceCredit): cost}
return player.EntityState{
Errors: errors,
Cost: costMap,
Metadata: metadata,
LastCalculated: time.Now(),
}
}
// CalculateChoiceErrors validates a single choice's requirements against the player/game state.
// Returns a list of errors explaining why the choice is unavailable (empty if available).
func CalculateChoiceErrors(choice shared.Choice, p *player.Player, g *game.Game, cardRegistry cards.CardRegistry) []player.StateError {
var errors []player.StateError
if choice.Requirements != nil && len(choice.Requirements.Items) > 0 {
for _, req := range choice.Requirements.Items {
if err := checkChoiceRequirement(req, p, g, cardRegistry); err != nil {
errors = append(errors, *err)
}
}
}
resources := p.Resources().Get()
for _, outputBC := range choice.Outputs {
if shared.IsVariableAmount(outputBC) || outputBC.GetAmount() >= 0 {
continue
}
if !isBasicPlayerResource(outputBC.GetResourceType()) {
continue
}
available := resources.GetAmount(outputBC.GetResourceType())
if available < -outputBC.GetAmount() {
errors = append(errors, player.StateError{
Code: player.ErrorCodeInsufficientResources,
Category: player.ErrorCategoryInput,
Message: fmt.Sprintf("Not enough %s", outputBC.GetResourceType()),
})
}
}
for _, inputBC := range choice.Inputs {
if shared.IsVariableAmount(inputBC) || inputBC.GetAmount() <= 0 {
continue
}
if !isBasicPlayerResource(inputBC.GetResourceType()) {
continue
}
available := resources.GetAmount(inputBC.GetResourceType())
if available < inputBC.GetAmount() {
errors = append(errors, player.StateError{
Code: player.ErrorCodeInsufficientResources,
Category: player.ErrorCategoryInput,
Message: fmt.Sprintf("Not enough %s", inputBC.GetResourceType()),
})
}
}
return errors
}
// checkChoiceRequirement validates a single choice requirement and returns a StateError if not met.
func checkChoiceRequirement(req shared.ChoiceRequirement, p *player.Player, g *game.Game, cardRegistry cards.CardRegistry) *player.StateError {
switch req.Type {
case "tags":
if req.Tag == nil {
return &player.StateError{
Code: player.ErrorCodeInvalidRequirement,
Category: player.ErrorCategoryRequirement,
Message: "Invalid tag requirement",
}
}
tagCount := gamecards.CountPlayerTagsByType(p, cardRegistry, *req.Tag)
if req.Min != nil && tagCount < *req.Min {
return &player.StateError{
Code: player.ErrorCodeInsufficientTags,
Category: player.ErrorCategoryRequirement,
Message: formatInsufficientTagsMessage(string(*req.Tag)),
}
}
if req.Max != nil && tagCount > *req.Max {
return &player.StateError{
Code: player.ErrorCodeTooManyTags,
Category: player.ErrorCategoryRequirement,
Message: formatTooManyTagsMessage(string(*req.Tag)),
}
}
case "temperature":
temp := g.GlobalParameters().Temperature()
if req.Min != nil && temp < *req.Min {
return &player.StateError{
Code: player.ErrorCodeTemperatureTooLow,
Category: player.ErrorCategoryRequirement,
Message: "Temperature too low",
}
}
if req.Max != nil && temp > *req.Max {
return &player.StateError{
Code: player.ErrorCodeTemperatureTooHigh,
Category: player.ErrorCategoryRequirement,
Message: "Temperature too high",
}
}
case "oxygen":
oxygen := g.GlobalParameters().Oxygen()
if req.Min != nil && oxygen < *req.Min {
return &player.StateError{
Code: player.ErrorCodeOxygenTooLow,
Category: player.ErrorCategoryRequirement,
Message: "Oxygen too low",
}
}
if req.Max != nil && oxygen > *req.Max {
return &player.StateError{
Code: player.ErrorCodeOxygenTooHigh,
Category: player.ErrorCategoryRequirement,
Message: "Oxygen too high",
}
}
case "ocean":
oceans := g.GlobalParameters().Oceans()
if req.Min != nil && oceans < *req.Min {
return &player.StateError{
Code: player.ErrorCodeOceansTooLow,
Category: player.ErrorCategoryRequirement,
Message: "Too few oceans",
}
}
if req.Max != nil && oceans > *req.Max {
return &player.StateError{
Code: player.ErrorCodeOceansTooHigh,
Category: player.ErrorCategoryRequirement,
Message: "Too many oceans",
}
}
case "tr":
tr := p.Resources().TerraformRating()
if req.Min != nil && tr < *req.Min {
return &player.StateError{
Code: player.ErrorCodeTRTooLow,
Category: player.ErrorCategoryRequirement,
Message: "TR too low",
}
}
if req.Max != nil && tr > *req.Max {
return &player.StateError{
Code: player.ErrorCodeTRTooHigh,
Category: player.ErrorCategoryRequirement,
Message: "TR too high",
}
}
case "production":
if req.Resource == nil {
return &player.StateError{
Code: player.ErrorCodeInvalidRequirement,
Category: player.ErrorCategoryRequirement,
Message: "Invalid production requirement",
}
}
production := p.Resources().Production()
amount := production.GetAmount(*req.Resource)
if req.Min != nil && amount < *req.Min {
return &player.StateError{
Code: player.ErrorCodeInsufficientProduction,
Category: player.ErrorCategoryRequirement,
Message: formatInsufficientProductionMessage(*req.Resource),
}
}
case "resource":
if req.Resource == nil {
return &player.StateError{
Code: player.ErrorCodeInvalidRequirement,
Category: player.ErrorCategoryRequirement,
Message: "Invalid resource requirement",
}
}
resources := p.Resources().Get()
amount := resources.GetAmount(*req.Resource)
if req.Min != nil && amount < *req.Min {
return &player.StateError{
Code: player.ErrorCodeInsufficientResources,
Category: player.ErrorCategoryRequirement,
Message: formatInsufficientResourceMessage(*req.Resource),
}
}
}
return nil
}
// computeBehaviorValues computes per-condition output values for card behaviors.
// Returns a slice of ComputedBehaviorValue with target format "behaviors::N".
func computeBehaviorValues(
behaviors []shared.CardBehavior,
sourceCardID string,
p *player.Player,
g *game.Game,
cardRegistry cards.CardRegistry,
colonyBonusLookup gamecards.ColonyBonusLookup,
) []player.ComputedBehaviorValue {
var result []player.ComputedBehaviorValue
board := g.Board()
allPlayers := g.GetAllPlayers()
for i, behavior := range behaviors {
var outputs []shared.CalculatedOutput
for _, outputBC := range behavior.Outputs {
if outputBC.GetResourceType() == shared.ResourceColonyBonus {
bonusOutputs := computeColonyBonusValues(p, g, colonyBonusLookup)
if outputs == nil {
outputs = bonusOutputs
} else {
outputs = append(outputs, bonusOutputs...)
}
continue
}
per := shared.GetPerCondition(outputBC)
if per == nil {
continue
}
count := gamecards.CountPerCondition(per, sourceCardID, p, board, cardRegistry, allPlayers)
if per.Amount > 0 {
multiplier := count / per.Amount
actualAmount := outputBC.GetAmount() * multiplier
outputs = append(outputs, shared.CalculatedOutput{
ResourceType: string(outputBC.GetResourceType()),
Amount: actualAmount,
IsScaled: true,
})
}
}
for _, choice := range behavior.Choices {
for _, outputBC := range choice.Outputs {
per := shared.GetPerCondition(outputBC)
if per == nil {
continue
}
count := gamecards.CountPerCondition(per, sourceCardID, p, board, cardRegistry, allPlayers)
if per.Amount > 0 {
multiplier := count / per.Amount
actualAmount := outputBC.GetAmount() * multiplier
outputs = append(outputs, shared.CalculatedOutput{
ResourceType: string(outputBC.GetResourceType()),
Amount: actualAmount,
IsScaled: true,
})
}
}
}
if outputs != nil {
result = append(result, player.ComputedBehaviorValue{
Target: fmt.Sprintf("behaviors::%d", i),
Outputs: outputs,
})
}
}
return result
}
func computeColonyBonusValues(
p *player.Player,
g *game.Game,
colonyBonusLookup gamecards.ColonyBonusLookup,
) []shared.CalculatedOutput {
if p == nil || g == nil || colonyBonusLookup == nil || !g.HasColonies() {
return []shared.CalculatedOutput{}
}
return gamecards.ColonyBonusesToCalculatedOutputs(
gamecards.CollectColonyBonuses(p.ID(), g.Colonies().States(), colonyBonusLookup),
)
}
package tile
import (
"context"
"fmt"
baseaction "terraforming-mars-backend/internal/action"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/shared"
)
// Callback types for tile completion
const (
CallbackConvertPlantsToGreenery = "convert-plants-to-greenery"
CallbackStandardProjectGreenery = "standard-project-greenery"
CallbackStandardProjectAquifer = "standard-project-aquifer"
CallbackAdjacentSteal = "adjacent-steal"
)
// TileCompletionHandlerFunc is the signature for tile completion callbacks
type TileCompletionHandlerFunc func(ctx context.Context, g *game.Game, playerID string, result *TilePlacementResult, callback *shared.TileCompletionCallback) error
// TileCompletionRegistry holds registered completion handlers
type TileCompletionRegistry struct {
handlers map[string]TileCompletionHandlerFunc
stateRepo game.GameStateRepository
}
// NewTileCompletionRegistry creates a new registry with default handlers
func NewTileCompletionRegistry(stateRepo game.GameStateRepository) *TileCompletionRegistry {
r := &TileCompletionRegistry{
handlers: make(map[string]TileCompletionHandlerFunc),
stateRepo: stateRepo,
}
r.registerDefaultHandlers()
return r
}
func (r *TileCompletionRegistry) registerDefaultHandlers() {
r.handlers[CallbackConvertPlantsToGreenery] = r.handleConvertPlantsToGreenery
r.handlers[CallbackStandardProjectGreenery] = r.handleStandardProjectGreenery
r.handlers[CallbackStandardProjectAquifer] = r.handleStandardProjectAquifer
r.handlers[CallbackAdjacentSteal] = r.handleAdjacentSteal
}
// Handle invokes the appropriate handler for the callback type
// If no callback is registered, no log is created - only use cases that explicitly register callbacks get logs
func (r *TileCompletionRegistry) Handle(ctx context.Context, g *game.Game, playerID string, result *TilePlacementResult, callback *shared.TileCompletionCallback) error {
if callback == nil {
return nil
}
handler, exists := r.handlers[callback.Type]
if !exists {
return nil
}
return handler(ctx, g, playerID, result, callback)
}
func (r *TileCompletionRegistry) handleConvertPlantsToGreenery(ctx context.Context, g *game.Game, playerID string, result *TilePlacementResult, _ *shared.TileCompletionCallback) error {
outputs := []shared.CalculatedOutput{
{ResourceType: string(shared.ResourceGreeneryPlacement), Amount: 1, IsScaled: false},
}
if result.OxygenSteps > 0 {
outputs = append(outputs, shared.CalculatedOutput{ResourceType: string(shared.ResourceOxygen), Amount: result.OxygenSteps, IsScaled: false})
}
if result.TRGained > 0 {
outputs = append(outputs, shared.CalculatedOutput{ResourceType: string(shared.ResourceTR), Amount: result.TRGained, IsScaled: false})
}
displayData := baseaction.GetStandardProjectDisplayData("Convert Plants")
_, err := r.stateRepo.WriteFull(ctx, g.ID(), g, "Convert Plants", shared.SourceTypeResourceConvert, playerID, "Converted plants to greenery", nil, outputs, displayData)
return err
}
func (r *TileCompletionRegistry) handleStandardProjectGreenery(ctx context.Context, g *game.Game, playerID string, result *TilePlacementResult, _ *shared.TileCompletionCallback) error {
outputs := []shared.CalculatedOutput{
{ResourceType: string(shared.ResourceGreeneryPlacement), Amount: 1, IsScaled: false},
}
if result.OxygenSteps > 0 {
outputs = append(outputs, shared.CalculatedOutput{ResourceType: string(shared.ResourceOxygen), Amount: result.OxygenSteps, IsScaled: false})
}
if result.TRGained > 0 {
outputs = append(outputs, shared.CalculatedOutput{ResourceType: string(shared.ResourceTR), Amount: result.TRGained, IsScaled: false})
}
displayData := baseaction.GetStandardProjectDisplayData("Standard Project: Greenery")
_, err := r.stateRepo.WriteFull(ctx, g.ID(), g, "Standard Project: Greenery", shared.SourceTypeStandardProject, playerID, "Planted greenery", nil, outputs, displayData)
return err
}
func (r *TileCompletionRegistry) handleStandardProjectAquifer(ctx context.Context, g *game.Game, playerID string, result *TilePlacementResult, _ *shared.TileCompletionCallback) error {
outputs := []shared.CalculatedOutput{
{ResourceType: string(shared.ResourceOceanPlacement), Amount: 1, IsScaled: false},
}
if result.TRGained > 0 {
outputs = append(outputs, shared.CalculatedOutput{ResourceType: string(shared.ResourceTR), Amount: result.TRGained, IsScaled: false})
}
displayData := baseaction.GetStandardProjectDisplayData("Standard Project: Aquifer")
_, err := r.stateRepo.WriteFull(ctx, g.ID(), g, "Standard Project: Aquifer", shared.SourceTypeStandardProject, playerID, "Built aquifer", nil, outputs, displayData)
return err
}
func (r *TileCompletionRegistry) handleAdjacentSteal(_ context.Context, g *game.Game, playerID string, result *TilePlacementResult, callback *shared.TileCompletionCallback) error {
amount, _ := callback.Data["amount"].(int)
source, _ := callback.Data["source"].(string)
sourceCardID, _ := callback.Data["sourceCardID"].(string)
coords, err := parseHexPosition(result.Hex)
if err != nil {
return fmt.Errorf("failed to parse hex for adjacent steal: %w", err)
}
neighbors := coords.GetNeighbors()
eligiblePlayerIDs := make(map[string]bool)
for _, neighbor := range neighbors {
neighborTile, tileErr := g.Board().GetTile(neighbor)
if tileErr != nil {
continue
}
if neighborTile.OwnerID != nil && *neighborTile.OwnerID != playerID {
eligiblePlayerIDs[*neighborTile.OwnerID] = true
}
}
if len(eligiblePlayerIDs) == 0 {
return nil
}
ids := make([]string, 0, len(eligiblePlayerIDs))
for id := range eligiblePlayerIDs {
ids = append(ids, id)
}
p, err := g.GetPlayer(playerID)
if err != nil {
return fmt.Errorf("player not found: %w", err)
}
p.Selection().SetPendingStealTargetSelection(&shared.PendingStealTargetSelection{
EligiblePlayerIDs: ids,
ResourceType: shared.ResourceCredit,
Amount: amount,
Source: source,
SourceCardID: sourceCardID,
})
return nil
}
package tile
import (
"context"
"fmt"
"strconv"
"strings"
baseaction "terraforming-mars-backend/internal/action"
"terraforming-mars-backend/internal/action/turn_management"
"time"
"go.uber.org/zap"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/board"
"terraforming-mars-backend/internal/game/shared"
)
// TilePlacementResult contains information about a completed tile placement
type TilePlacementResult struct {
TileType string
Source string
Hex string
OxygenSteps int
TRGained int
OceanPlaced bool
OnComplete *shared.TileCompletionCallback
}
// SelectTileAction handles the business logic for selecting a tile position
type SelectTileAction struct {
baseaction.BaseAction
completionRegistry *TileCompletionRegistry
}
// NewSelectTileAction creates a new select tile action
func NewSelectTileAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
stateRepo game.GameStateRepository,
logger *zap.Logger,
) *SelectTileAction {
return &SelectTileAction{
BaseAction: baseaction.NewBaseActionWithStateRepo(gameRepo, cardRegistry, stateRepo),
completionRegistry: NewTileCompletionRegistry(stateRepo),
}
}
// Execute performs the select tile action and returns placement result
func (a *SelectTileAction) Execute(ctx context.Context, gameID string, playerID string, selectedHex string) (*TilePlacementResult, error) {
log := a.InitLogger(gameID, playerID).With(zap.String("action", "select_tile"))
log.Debug("Selecting tile", zap.String("hex", selectedHex))
g, err := baseaction.ValidateActiveGame(ctx, a.GameRepository(), gameID, log)
if err != nil {
return nil, err
}
phase := g.CurrentPhase()
if phase != shared.GamePhaseStartingSelection &&
phase != shared.GamePhaseInitApplyCorp &&
phase != shared.GamePhaseInitApplyPrelude {
if err := baseaction.ValidateCurrentTurn(g, playerID, log); err != nil {
return nil, err
}
}
p, err := a.GetPlayerFromGame(g, playerID, log)
if err != nil {
return nil, err
}
pendingTileSelection := g.GetPendingTileSelection(playerID)
if pendingTileSelection == nil {
log.Warn("No pending tile selection found")
return nil, fmt.Errorf("no pending tile selection found for player %s", playerID)
}
hexIsValid := false
for _, availableHex := range pendingTileSelection.AvailableHexes {
if availableHex == selectedHex {
hexIsValid = true
break
}
}
if !hexIsValid {
log.Warn("Invalid hex selection",
zap.String("selected_hex", selectedHex),
zap.Strings("available_hexes", pendingTileSelection.AvailableHexes))
return nil, fmt.Errorf("selected hex %s is not valid for placement", selectedHex)
}
coords, err := parseHexPosition(selectedHex)
if err != nil {
log.Warn("Failed to parse hex coordinates", zap.String("hex", selectedHex), zap.Error(err))
return nil, fmt.Errorf("invalid hex format: %w", err)
}
tileType := pendingTileSelection.TileType
// Handle clear differently - removes occupant/reservation from a tile (admin debug tool)
if tileType == "clear" {
// Check if tile is an ocean before clearing, so we can decrement the ocean count
clearedTile, tileErr := g.Board().GetTile(*coords)
if tileErr != nil {
log.Warn("Failed to get tile for clear", zap.Error(tileErr))
return nil, fmt.Errorf("failed to get tile: %w", tileErr)
}
wasOcean := clearedTile.OccupiedBy != nil && clearedTile.OccupiedBy.Type == shared.ResourceOceanTile
if err := g.Board().ClearTileOccupant(ctx, *coords); err != nil {
log.Warn("Failed to clear tile occupant", zap.Error(err))
return nil, fmt.Errorf("failed to clear tile: %w", err)
}
if wasOcean {
currentOceans := g.GlobalParameters().Oceans()
if currentOceans > 0 {
if err := g.GlobalParameters().SetOceans(ctx, currentOceans-1); err != nil {
log.Warn("Failed to decrement ocean count", zap.Error(err))
}
}
}
log.Debug("Tile cleared",
zap.String("position", selectedHex))
result := &TilePlacementResult{
TileType: tileType,
Source: pendingTileSelection.Source,
Hex: selectedHex,
OnComplete: pendingTileSelection.OnComplete,
}
if err := g.SetPendingTileSelection(ctx, playerID, nil); err != nil {
return nil, fmt.Errorf("failed to clear pending tile selection: %w", err)
}
if err := g.ProcessNextTile(ctx, playerID); err != nil {
return nil, fmt.Errorf("failed to process next tile: %w", err)
}
baseaction.AutoAdvanceTurnIfNeeded(g, playerID, log)
log.Debug("Tile cleared",
zap.String("position", selectedHex))
return result, nil
}
// Handle tile replacement - destroys occupant and places a new tile in one step
if strings.HasPrefix(tileType, "tile-replacement:") {
replacementTileType := strings.TrimPrefix(tileType, "tile-replacement:")
if err := g.Board().ClearTileOccupant(ctx, *coords); err != nil {
log.Warn("Failed to destroy tile for replacement", zap.Error(err))
return nil, fmt.Errorf("failed to destroy tile: %w", err)
}
occupantTags := []string{}
if pendingTileSelection.SourceCardID != "" {
occupantTags = append(occupantTags, "source:"+pendingTileSelection.SourceCardID)
}
occupant := board.TileOccupant{
Type: mapTileTypeToResourceType(replacementTileType),
Tags: occupantTags,
}
if err := g.Board().UpdateTileOccupancy(ctx, *coords, occupant, playerID); err != nil {
log.Warn("Failed to place replacement tile", zap.Error(err))
return nil, fmt.Errorf("failed to place replacement tile: %w", err)
}
result := &TilePlacementResult{
TileType: replacementTileType,
Source: pendingTileSelection.Source,
Hex: selectedHex,
OnComplete: pendingTileSelection.OnComplete,
}
if err := g.SetPendingTileSelection(ctx, playerID, nil); err != nil {
return nil, fmt.Errorf("failed to clear pending tile selection: %w", err)
}
if err := g.ProcessNextTile(ctx, playerID); err != nil {
return nil, fmt.Errorf("failed to process next tile: %w", err)
}
if err := a.completionRegistry.Handle(ctx, g, playerID, result, result.OnComplete); err != nil {
log.Warn("Failed to handle completion callback", zap.Error(err))
}
baseaction.AutoAdvanceTurnIfNeeded(g, playerID, log)
log.Info("Tile replaced",
zap.String("position", selectedHex),
zap.String("replacement_type", replacementTileType))
return result, nil
}
// Handle tile destruction - removes any tile (including oceans)
if tileType == "tile-destruction" {
destroyedTile, tileErr := g.Board().GetTile(*coords)
if tileErr != nil {
log.Warn("Failed to get tile for destruction", zap.Error(tileErr))
return nil, fmt.Errorf("failed to get tile: %w", tileErr)
}
wasOcean := destroyedTile.OccupiedBy != nil && destroyedTile.OccupiedBy.Type == shared.ResourceOceanTile
if err := g.Board().ClearTileOccupant(ctx, *coords); err != nil {
log.Warn("Failed to destroy tile", zap.Error(err))
return nil, fmt.Errorf("failed to destroy tile: %w", err)
}
if wasOcean {
currentOceans := g.GlobalParameters().Oceans()
if currentOceans > 0 {
if err := g.GlobalParameters().SetOceans(ctx, currentOceans-1); err != nil {
log.Warn("Failed to decrement ocean count", zap.Error(err))
}
}
}
nuclearOccupant := board.TileOccupant{
Type: shared.ResourceNuclearZoneTile,
}
if err := g.Board().UpdateTileOccupancy(ctx, *coords, nuclearOccupant, playerID); err != nil {
log.Warn("Failed to place nuclear zone tile", zap.Error(err))
return nil, fmt.Errorf("failed to place nuclear zone tile: %w", err)
}
result := &TilePlacementResult{
TileType: tileType,
Source: pendingTileSelection.Source,
Hex: selectedHex,
OnComplete: pendingTileSelection.OnComplete,
}
if err := g.SetPendingTileSelection(ctx, playerID, nil); err != nil {
return nil, fmt.Errorf("failed to clear pending tile selection: %w", err)
}
if err := g.ProcessNextTile(ctx, playerID); err != nil {
return nil, fmt.Errorf("failed to process next tile: %w", err)
}
if err := a.completionRegistry.Handle(ctx, g, playerID, result, result.OnComplete); err != nil {
log.Warn("Failed to handle completion callback", zap.Error(err))
}
baseaction.AutoAdvanceTurnIfNeeded(g, playerID, log)
log.Info("Tile destroyed",
zap.String("position", selectedHex),
zap.Bool("was_ocean", wasOcean))
return result, nil
}
// Handle land claims differently - they reserve a tile instead of placing an occupant
if tileType == "land-claim" {
if err := g.Board().ReserveTile(ctx, *coords, playerID); err != nil {
log.Warn("Failed to reserve tile", zap.Error(err))
return nil, fmt.Errorf("failed to reserve tile: %w", err)
}
log.Debug("Tile reserved for land claim",
zap.String("position", selectedHex))
result := &TilePlacementResult{
TileType: tileType,
Source: pendingTileSelection.Source,
Hex: selectedHex,
OnComplete: pendingTileSelection.OnComplete,
}
if err := g.SetPendingTileSelection(ctx, playerID, nil); err != nil {
return nil, fmt.Errorf("failed to clear pending tile selection: %w", err)
}
if err := g.ProcessNextTile(ctx, playerID); err != nil {
return nil, fmt.Errorf("failed to process next tile: %w", err)
}
if err := a.completionRegistry.Handle(ctx, g, playerID, result, result.OnComplete); err != nil {
log.Warn("Failed to handle completion callback", zap.Error(err))
}
baseaction.AutoAdvanceTurnIfNeeded(g, playerID, log)
log.Debug("Land claim reserved",
zap.String("position", selectedHex))
return result, nil
}
occupantTags := []string{}
if pendingTileSelection.SourceCardID != "" {
occupantTags = append(occupantTags, "source:"+pendingTileSelection.SourceCardID)
}
occupant := board.TileOccupant{
Type: mapTileTypeToResourceType(tileType),
Tags: occupantTags,
}
if err := g.Board().UpdateTileOccupancy(ctx, *coords, occupant, playerID); err != nil {
log.Warn("Failed to place tile", zap.Error(err))
return nil, fmt.Errorf("failed to place tile: %w", err)
}
log.Debug("Tile placed on board",
zap.String("tile_type", tileType),
zap.String("position", selectedHex))
placedTile, err := g.Board().GetTile(*coords)
if err != nil {
log.Warn("Failed to get placed tile for bonus check", zap.Error(err))
} else if len(placedTile.Bonuses) > 0 {
log.Debug("Tile has bonuses", zap.Int("bonus_count", len(placedTile.Bonuses)))
resourceBonuses := make(map[string]int)
for _, bonus := range placedTile.Bonuses {
switch bonus.Type {
case shared.ResourceSteel, shared.ResourceTitanium, shared.ResourcePlant, shared.ResourceCredit:
p.Resources().Add(map[shared.ResourceType]int{
bonus.Type: bonus.Amount,
})
log.Debug("Awarded resource bonus",
zap.String("resource", string(bonus.Type)),
zap.Int("amount", bonus.Amount))
resourceBonuses[string(bonus.Type)] = bonus.Amount
case shared.ResourceCardDraw:
deck := g.Deck()
cardIDs, err := deck.DrawProjectCards(ctx, bonus.Amount)
if err != nil {
log.Warn("Failed to draw cards for bonus", zap.Error(err))
continue
}
for _, cardID := range cardIDs {
p.Hand().AddCard(cardID)
}
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: "Tile Bonus",
PlayerID: playerID,
SourceType: shared.SourceTypeCardPlay,
CalculatedOutputs: []shared.CalculatedOutput{
{ResourceType: string(shared.ResourceCardDraw), Amount: len(cardIDs)},
},
})
log.Debug("Awarded card draw bonus",
zap.Int("cards_drawn", len(cardIDs)),
zap.Strings("card_ids", cardIDs))
default:
log.Warn(" Unhandled tile bonus type",
zap.String("type", string(bonus.Type)),
zap.Int("amount", bonus.Amount))
}
}
if len(resourceBonuses) > 0 {
events.Publish(g.EventBus(), events.PlacementBonusGainedEvent{
GameID: gameID,
PlayerID: playerID,
Resources: resourceBonuses,
SourceCardID: pendingTileSelection.SourceCardID,
Q: coords.Q,
R: coords.R,
S: coords.S,
Timestamp: time.Now(),
})
log.Debug("Published PlacementBonusGainedEvent",
zap.Any("resources", resourceBonuses))
}
// Clear bonuses from tile after claiming
if err := g.Board().ClearTileBonuses(ctx, *coords); err != nil {
log.Warn("Failed to clear tile bonuses", zap.Error(err))
}
}
// Award 2 M€ per adjacent ocean tile (applies to all tile types, including oceans)
neighbors := coords.GetNeighbors()
adjacentOceanCount := 0
for _, neighbor := range neighbors {
neighborTile, err := g.Board().GetTile(neighbor)
if err != nil {
continue
}
if neighborTile.OccupiedBy != nil && neighborTile.OccupiedBy.Type == shared.ResourceOceanTile {
adjacentOceanCount++
}
}
if adjacentOceanCount > 0 {
oceanBonus := adjacentOceanCount * 2
p.Resources().Add(map[shared.ResourceType]int{
shared.ResourceCredit: oceanBonus,
})
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: "Ocean Adjacency",
PlayerID: playerID,
SourceType: shared.SourceTypeGameEvent,
CalculatedOutputs: []shared.CalculatedOutput{
{ResourceType: string(shared.ResourceCredit), Amount: oceanBonus},
},
})
log.Debug("Awarded ocean adjacency bonus",
zap.Int("adjacent_oceans", adjacentOceanCount),
zap.Int("credits_awarded", oceanBonus))
}
result := &TilePlacementResult{
TileType: tileType,
Source: pendingTileSelection.Source,
Hex: selectedHex,
OnComplete: pendingTileSelection.OnComplete,
}
switch tileType {
case "city":
log.Debug("City placed (no TR bonus)")
case "greenery", "world-tree":
actualSteps, err := g.GlobalParameters().IncreaseOxygen(ctx, 1, playerID)
if err != nil {
return nil, fmt.Errorf("failed to increase oxygen: %w", err)
}
result.OxygenSteps = actualSteps
if actualSteps > 0 {
p.Resources().UpdateTerraformRating(1)
result.TRGained = 1
log.Debug("Increased oxygen and TR for forest placement",
zap.Int("oxygen_steps", actualSteps),
zap.Int("tr_gained", 1))
} else {
log.Debug("Forest placed but oxygen already maxed")
}
case "ocean":
success, err := g.GlobalParameters().PlaceOcean(ctx, playerID)
if err != nil {
return nil, fmt.Errorf("failed to place ocean: %w", err)
}
result.OceanPlaced = success
if success {
p.Resources().UpdateTerraformRating(1)
result.TRGained = 1
log.Debug("Placed ocean and increased TR",
zap.Int("tr_gained", 1))
} else {
log.Debug("Ocean placed but ocean count already maxed")
}
case "volcano":
log.Debug("Volcano placed (no TR bonus)")
}
if err := g.SetPendingTileSelection(ctx, playerID, nil); err != nil {
return nil, fmt.Errorf("failed to clear pending tile selection: %w", err)
}
if err := g.ProcessNextTile(ctx, playerID); err != nil {
return nil, fmt.Errorf("failed to process next tile: %w", err)
}
// Invoke completion callback to log the action
if err := a.completionRegistry.Handle(ctx, g, playerID, result, result.OnComplete); err != nil {
log.Warn("Failed to handle completion callback", zap.Error(err))
}
switch g.CurrentPhase() {
case shared.GamePhaseStartingSelection:
a.checkStartingSelectionCompletion(ctx, g, log)
case shared.GamePhaseInitApplyCorp, shared.GamePhaseInitApplyPrelude:
// During init phases, tile placement completes and the frontend sends the next confirm
default:
baseaction.AutoAdvanceTurnIfNeeded(g, playerID, log)
}
log.Info("Tile placed",
zap.String("tile_type", tileType),
zap.String("position", selectedHex))
return result, nil
}
// checkStartingSelectionCompletion checks if all players finished starting selection and tile placements,
// then advances to action phase
func (a *SelectTileAction) checkStartingSelectionCompletion(ctx context.Context, g *game.Game, log *zap.Logger) {
allPlayers := g.GetAllPlayers()
for _, p := range allPlayers {
if g.GetSelectCorporationPhase(p.ID()) != nil {
return
}
if g.GetSelectPreludeCardsPhase(p.ID()) != nil {
return
}
if g.GetSelectStartingCardsPhase(p.ID()) != nil {
return
}
if g.GetPendingTileSelection(p.ID()) != nil {
return
}
if g.GetPendingTileSelectionQueue(p.ID()) != nil {
return
}
}
log.Debug("All starting selections and tiles resolved, advancing to action phase")
turn_management.AdvanceToActionPhase(ctx, g, allPlayers, log)
}
func parseHexPosition(hexStr string) (*shared.HexPosition, error) {
parts := strings.Split(hexStr, ",")
if len(parts) != 3 {
return nil, fmt.Errorf("expected 3 coordinates, got %d", len(parts))
}
q, err := strconv.Atoi(parts[0])
if err != nil {
return nil, fmt.Errorf("invalid q coordinate: %w", err)
}
r, err := strconv.Atoi(parts[1])
if err != nil {
return nil, fmt.Errorf("invalid r coordinate: %w", err)
}
s, err := strconv.Atoi(parts[2])
if err != nil {
return nil, fmt.Errorf("invalid s coordinate: %w", err)
}
if q+r+s != 0 {
return nil, fmt.Errorf("invalid cube coordinates: q+r+s must equal 0")
}
return &shared.HexPosition{Q: q, R: r, S: s}, nil
}
func mapTileTypeToResourceType(tileType string) shared.ResourceType {
switch tileType {
case "city":
return shared.ResourceCityTile
case "greenery":
return shared.ResourceGreeneryTile
case "ocean":
return shared.ResourceOceanTile
case "volcano":
return shared.ResourceVolcanoTile
case "world-tree":
return shared.ResourceWorldTreeTile
default:
return shared.ResourceType(tileType + "-tile")
}
}
package turn_management
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/awards"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/game"
gamecards "terraforming-mars-backend/internal/game/cards"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
)
// ConfirmInitAdvanceAction advances the init phase to the next player's corp/prelude application.
// The frontend sends this after displaying effects and completing any required tile placements.
type ConfirmInitAdvanceAction struct {
gameRepo game.GameRepository
cardRegistry cards.CardRegistry
awardRegistry awards.AwardRegistry
stateRepo game.GameStateRepository
corpProc *gamecards.CorporationProcessor
logger *zap.Logger
}
// NewConfirmInitAdvanceAction creates a new confirm init advance action
func NewConfirmInitAdvanceAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
awardRegistry awards.AwardRegistry,
stateRepo game.GameStateRepository,
logger *zap.Logger,
) *ConfirmInitAdvanceAction {
return &ConfirmInitAdvanceAction{
gameRepo: gameRepo,
cardRegistry: cardRegistry,
awardRegistry: awardRegistry,
stateRepo: stateRepo,
corpProc: gamecards.NewCorporationProcessor(cardRegistry, awardRegistry, logger),
logger: logger,
}
}
// Execute applies the current player's effects and advances to the next player.
// The confirm flow is: enter phase (no effects applied) → confirm → apply current → wait →
// confirm → apply next → wait → ... → confirm → apply last → wait → confirm → transition.
func (a *ConfirmInitAdvanceAction) Execute(ctx context.Context, gameID string, playerID string) error {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("player_id", playerID),
zap.String("action", "confirm_init_advance"),
)
g, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
return fmt.Errorf("game not found: %s", gameID)
}
phase := g.CurrentPhase()
if phase != shared.GamePhaseInitApplyCorp && phase != shared.GamePhaseInitApplyPrelude {
return fmt.Errorf("game not in init apply phase, current: %s", phase)
}
if !g.InitPhaseWaitingForConfirm() {
return fmt.Errorf("init phase not waiting for confirmation")
}
turnOrder := g.TurnOrder()
currentIndex := g.InitPhasePlayerIndex()
if currentIndex >= len(turnOrder) {
return fmt.Errorf("init phase player index out of range")
}
currentPlayerID := turnOrder[currentIndex]
if g.HasAnyPendingSelection(currentPlayerID) {
return fmt.Errorf("current player has pending selection")
}
if g.GetPendingTileSelectionQueue(currentPlayerID) != nil {
return fmt.Errorf("current player has pending tile queue")
}
if fa := g.GetForcedFirstAction(currentPlayerID); fa != nil && !fa.Completed {
return fmt.Errorf("current player has incomplete forced first action")
}
if err := g.SetInitPhaseWaitingForConfirm(ctx, false); err != nil {
return fmt.Errorf("failed to clear waiting for confirm: %w", err)
}
allPlayers := g.GetAllPlayers()
// Check if the current player's effects have already been applied.
choices := g.GetDeferredStartingChoices(currentPlayerID)
needsApply := false
if choices != nil {
switch phase {
case shared.GamePhaseInitApplyCorp:
needsApply = !choices.CorpApplied
case shared.GamePhaseInitApplyPrelude:
needsApply = !choices.PreludesApplied
}
}
if needsApply {
return a.applyCurrentPlayer(ctx, g, phase, currentPlayerID, log)
}
return a.advanceToNextPlayer(ctx, g, phase, currentIndex, turnOrder, allPlayers, log)
}
func (a *ConfirmInitAdvanceAction) applyCurrentPlayer(ctx context.Context, g *game.Game, phase shared.GamePhase, currentPlayerID string, log *zap.Logger) error {
switch phase {
case shared.GamePhaseInitApplyCorp:
if err := ApplyCorpForPlayer(ctx, g, currentPlayerID, a.cardRegistry, a.corpProc, log); err != nil {
return fmt.Errorf("failed to apply corp for player %s: %w", currentPlayerID, err)
}
log.Debug("Applied corp effects", zap.String("player_id", currentPlayerID))
case shared.GamePhaseInitApplyPrelude:
if err := ApplyPreludesForPlayer(ctx, g, currentPlayerID, a.cardRegistry, a.stateRepo, log); err != nil {
return fmt.Errorf("failed to apply preludes for player %s: %w", currentPlayerID, err)
}
log.Debug("Applied prelude effects", zap.String("player_id", currentPlayerID))
}
// After applying, wait for the frontend to display the effects
if err := g.SetInitPhaseWaitingForConfirm(ctx, true); err != nil {
return fmt.Errorf("failed to set waiting for confirm: %w", err)
}
return nil
}
func (a *ConfirmInitAdvanceAction) advanceToNextPlayer(ctx context.Context, g *game.Game, phase shared.GamePhase, currentIndex int, turnOrder []string, allPlayers []*player.Player, log *zap.Logger) error {
nextPlayerID := findNextActivePlayer(g, turnOrder, currentIndex+1)
if nextPlayerID != "" {
actualIndex := findPlayerIndex(turnOrder, nextPlayerID)
if err := g.SetInitPhasePlayerIndex(ctx, actualIndex); err != nil {
return fmt.Errorf("failed to set init phase player index: %w", err)
}
if err := g.SetInitPhaseWaitingForConfirm(ctx, true); err != nil {
return fmt.Errorf("failed to set waiting for confirm: %w", err)
}
return nil
}
// No more players in current phase
switch phase {
case shared.GamePhaseInitApplyCorp:
if g.Settings().HasPrelude() {
log.Debug("All corps applied, advancing to init_apply_prelude phase")
if err := g.UpdatePhase(ctx, shared.GamePhaseInitApplyPrelude); err != nil {
return fmt.Errorf("failed to transition to prelude phase: %w", err)
}
firstPlayerID := findFirstActivePlayer(g, turnOrder)
if firstPlayerID == "" {
AdvanceToActionPhase(ctx, g, allPlayers, log)
return nil
}
firstIndex := findPlayerIndex(turnOrder, firstPlayerID)
if err := g.SetInitPhasePlayerIndex(ctx, firstIndex); err != nil {
return fmt.Errorf("failed to reset init phase player index: %w", err)
}
if err := g.SetInitPhaseWaitingForConfirm(ctx, true); err != nil {
return fmt.Errorf("failed to set waiting for confirm: %w", err)
}
return nil
}
log.Info("All corps applied (no prelude), advancing to action phase")
AdvanceToActionPhase(ctx, g, allPlayers, log)
case shared.GamePhaseInitApplyPrelude:
log.Info("All preludes applied, advancing to action phase")
AdvanceToActionPhase(ctx, g, allPlayers, log)
}
return nil
}
// findNextActivePlayer finds the next non-exited player in turn order starting from the given index
func findNextActivePlayer(g *game.Game, turnOrder []string, fromIndex int) string {
for i := fromIndex; i < len(turnOrder); i++ {
p, err := g.GetPlayer(turnOrder[i])
if err == nil && !p.HasExited() {
return p.ID()
}
}
return ""
}
func findPlayerIndex(turnOrder []string, playerID string) int {
for i, id := range turnOrder {
if id == playerID {
return i
}
}
return 0
}
package turn_management
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game"
playerPkg "terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
)
// ExecuteProductionPhase handles the production phase when all players have passed.
// It calculates production, draws cards, advances the generation, rotates turn order,
// and transitions the game to the production_and_card_draw phase.
func ExecuteProductionPhase(ctx context.Context, g *game.Game, players []*playerPkg.Player, log *zap.Logger) error {
log = log.With(zap.String("game_id", g.ID()))
log.Debug("Starting production phase",
zap.Int("player_count", len(players)),
zap.Int("generation", g.Generation()))
// Solar Phase: advance colony markers and reset trade fleets
if g.HasColonies() {
for _, state := range g.Colonies().States() {
state.TradedThisGen = false
state.TraderID = ""
if state.MarkerPosition < 6 {
state.MarkerPosition++
}
}
for _, p := range players {
g.Colonies().SetTradeFleetAvailable(p.ID(), true)
}
log.Debug("Solar phase complete: colony markers advanced, trade fleets reset")
}
deck := g.Deck()
if deck == nil {
return fmt.Errorf("game deck is nil")
}
for _, p := range players {
currentResources := p.Resources().Get()
energyConverted := currentResources.Energy
production := p.Resources().Production()
tr := p.Resources().TerraformRating()
newResources := shared.Resources{
Credits: currentResources.Credits + production.Credits + tr,
Steel: currentResources.Steel + production.Steel,
Titanium: currentResources.Titanium + production.Titanium,
Plants: currentResources.Plants + production.Plants,
Energy: production.Energy,
Heat: currentResources.Heat + energyConverted + production.Heat,
}
p.Resources().Set(newResources)
p.SetPassed(false)
drawnCards := []string{}
for i := range 4 {
cardIDs, err := deck.DrawProjectCards(ctx, 1)
if err != nil || len(cardIDs) == 0 {
log.Debug("Deck empty or error drawing card, stopping at card draw",
zap.Int("cards_drawn", len(drawnCards)),
zap.Int("attempt", i),
zap.Error(err))
break
}
drawnCards = append(drawnCards, cardIDs[0])
}
productionPhaseData := &shared.ProductionPhase{
AvailableCards: drawnCards,
SelectionComplete: false,
BeforeResources: currentResources,
AfterResources: newResources,
EnergyConverted: energyConverted,
CreditsIncome: production.Credits + tr,
}
log.Debug("Setting production phase data for player",
zap.String("player_id", p.ID()),
zap.Int("available_cards", len(drawnCards)))
err := g.SetProductionPhase(ctx, p.ID(), productionPhaseData)
if err != nil {
log.Error("Failed to set production phase", zap.Error(err))
return fmt.Errorf("failed to set production phase: %w", err)
}
log.Debug("Production phase data set",
zap.String("player_id", p.ID()),
zap.Int("cards_drawn", len(drawnCards)),
zap.Int("credits_income", productionPhaseData.CreditsIncome),
zap.Int("energy_converted", energyConverted))
}
oldGeneration := g.Generation()
if err := g.AdvanceGeneration(ctx); err != nil {
return fmt.Errorf("failed to increment generation: %w", err)
}
newGeneration := g.Generation()
turnOrder := g.TurnOrder()
if g.IsNextGenTurnOrderFrozen() {
g.SetNextGenTurnOrderFrozen(false)
log.Debug("Turn order frozen, skipping rotation for this generation")
} else if len(turnOrder) > 1 {
var activePart []string
var exitedPart []string
for _, id := range turnOrder {
p, _ := g.GetPlayer(id)
if p != nil && p.HasExited() {
exitedPart = append(exitedPart, id)
} else {
activePart = append(activePart, id)
}
}
if len(activePart) > 1 {
rotated := make([]string, 0, len(activePart))
rotated = append(rotated, activePart[1:]...)
rotated = append(rotated, activePart[0])
activePart = rotated
}
rotatedOrder := append(activePart, exitedPart...)
if err := g.SetTurnOrder(ctx, rotatedOrder); err != nil {
return fmt.Errorf("failed to rotate turn order: %w", err)
}
turnOrder = rotatedOrder
log.Debug("Turn order rotated for new generation",
zap.Strings("new_turn_order", turnOrder))
}
if len(turnOrder) > 0 {
// Find first non-exited player in rotated turn order
firstPlayerID := ""
activeCount := 0
for _, id := range turnOrder {
p, _ := g.GetPlayer(id)
if p != nil && !p.HasExited() {
activeCount++
if firstPlayerID == "" {
firstPlayerID = id
}
}
}
if firstPlayerID != "" {
actionsForNewGeneration := 2
if activeCount == 1 {
actionsForNewGeneration = -1
}
if err := g.SetCurrentTurn(ctx, firstPlayerID, actionsForNewGeneration); err != nil {
return fmt.Errorf("failed to set current turn: %w", err)
}
}
}
log.Debug("Updating game phase to production_and_card_draw",
zap.String("current_phase", string(g.CurrentPhase())),
zap.String("new_phase", string(shared.GamePhaseProductionAndCardDraw)))
err := g.UpdatePhase(ctx, shared.GamePhaseProductionAndCardDraw)
if err != nil {
log.Error("Failed to update phase", zap.Error(err))
return fmt.Errorf("failed to update phase: %w", err)
}
log.Info("Production phase complete, generation advanced",
zap.Int("old_generation", oldGeneration),
zap.Int("new_generation", newGeneration))
return nil
}
// ExecuteFinalProductionPhase runs the production phase for the final generation.
// Per TM rules, there is no research phase (no card drawing) after the final production.
// Sets up ProductionPhase data for the modal and transitions to production_and_card_draw.
func ExecuteFinalProductionPhase(ctx context.Context, g *game.Game, players []*playerPkg.Player, log *zap.Logger) error {
log = log.With(zap.String("game_id", g.ID()))
log.Debug("Starting final production phase",
zap.Int("player_count", len(players)),
zap.Int("generation", g.Generation()))
if g.HasColonies() {
for _, state := range g.Colonies().States() {
state.TradedThisGen = false
state.TraderID = ""
if state.MarkerPosition < 6 {
state.MarkerPosition++
}
}
for _, p := range players {
g.Colonies().SetTradeFleetAvailable(p.ID(), true)
}
log.Debug("Solar phase complete: colony markers advanced, trade fleets reset")
}
for _, p := range players {
currentResources := p.Resources().Get()
energyConverted := currentResources.Energy
production := p.Resources().Production()
tr := p.Resources().TerraformRating()
newResources := shared.Resources{
Credits: currentResources.Credits + production.Credits + tr,
Steel: currentResources.Steel + production.Steel,
Titanium: currentResources.Titanium + production.Titanium,
Plants: currentResources.Plants + production.Plants,
Energy: production.Energy,
Heat: currentResources.Heat + energyConverted + production.Heat,
}
p.Resources().Set(newResources)
p.SetPassed(false)
productionPhaseData := &shared.ProductionPhase{
AvailableCards: []string{},
SelectionComplete: false,
BeforeResources: currentResources,
AfterResources: newResources,
EnergyConverted: energyConverted,
CreditsIncome: production.Credits + tr,
}
if err := g.SetProductionPhase(ctx, p.ID(), productionPhaseData); err != nil {
log.Error("Failed to set final production phase", zap.Error(err))
return fmt.Errorf("failed to set final production phase: %w", err)
}
log.Debug("Final production applied",
zap.String("player_id", p.ID()),
zap.Int("credits_income", production.Credits+tr),
zap.Int("energy_converted", energyConverted))
}
if err := g.UpdatePhase(ctx, shared.GamePhaseProductionAndCardDraw); err != nil {
log.Error("Failed to update phase", zap.Error(err))
return fmt.Errorf("failed to update phase: %w", err)
}
log.Info("Final production phase set up, awaiting player confirmation")
return nil
}
package turn_management
import (
"context"
"fmt"
"time"
baseaction "terraforming-mars-backend/internal/action"
"go.uber.org/zap"
"terraforming-mars-backend/internal/awards"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game"
gamecards "terraforming-mars-backend/internal/game/cards"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
)
// SelectStartingChoicesAction handles the combined selection of corporation, preludes, and starting cards.
// Selections are validated and stored but effects are NOT applied until the init_apply phases.
type SelectStartingChoicesAction struct {
gameRepo game.GameRepository
cardRegistry cards.CardRegistry
awardRegistry awards.AwardRegistry
corpProc *gamecards.CorporationProcessor
logger *zap.Logger
}
// NewSelectStartingChoicesAction creates a new select starting choices action
func NewSelectStartingChoicesAction(
gameRepo game.GameRepository,
cardRegistry cards.CardRegistry,
awardRegistry awards.AwardRegistry,
logger *zap.Logger,
) *SelectStartingChoicesAction {
return &SelectStartingChoicesAction{
gameRepo: gameRepo,
cardRegistry: cardRegistry,
awardRegistry: awardRegistry,
corpProc: gamecards.NewCorporationProcessor(cardRegistry, awardRegistry, logger),
logger: logger,
}
}
// Execute validates and stores starting selections without applying effects.
// Effects are deferred to init_apply_corp and init_apply_prelude phases.
func (a *SelectStartingChoicesAction) Execute(ctx context.Context, gameID string, playerID string, corporationID string, preludeIDs []string, cardIDs []string) error {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("player_id", playerID),
zap.String("action", "select_starting_choices"),
zap.String("corporation_id", corporationID),
zap.Strings("prelude_ids", preludeIDs),
zap.Strings("card_ids", cardIDs),
)
log.Debug("Player selecting starting choices")
g, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
return fmt.Errorf("game not found: %s", gameID)
}
if g.CurrentPhase() != shared.GamePhaseStartingSelection {
log.Error("Game not in starting selection phase", zap.String("phase", string(g.CurrentPhase())))
return fmt.Errorf("game not in starting selection phase")
}
p, err := g.GetPlayer(playerID)
if err != nil {
log.Error("Player not found in game", zap.Error(err))
return fmt.Errorf("player not found: %s", playerID)
}
if err := a.validateCorporation(g, p, corporationID, log); err != nil {
return err
}
if err := a.validatePreludes(g, p, preludeIDs, log); err != nil {
return err
}
if err := a.validateStartingCards(g, p, corporationID, cardIDs, log); err != nil {
return err
}
p.SetCorporationID(corporationID)
if err := g.SetDeferredStartingChoices(ctx, playerID, &shared.DeferredStartingChoices{
CorporationID: corporationID,
PreludeIDs: preludeIDs,
CardIDs: cardIDs,
}); err != nil {
return fmt.Errorf("failed to store deferred starting choices: %w", err)
}
// Remove unchosen preludes permanently (preludes must never enter the discard/draw cycle)
preludePhase := g.GetSelectPreludeCardsPhase(p.ID())
if preludePhase != nil {
selectedSet := make(map[string]bool, len(preludeIDs))
for _, id := range preludeIDs {
selectedSet[id] = true
}
var unselected []string
for _, id := range preludePhase.AvailablePreludes {
if !selectedSet[id] {
unselected = append(unselected, id)
}
}
if len(unselected) > 0 {
if err := g.Deck().Remove(ctx, unselected); err != nil {
log.Error("Failed to remove unselected preludes", zap.Error(err))
return fmt.Errorf("failed to remove unselected preludes: %w", err)
}
}
}
// Discard unchosen project cards
selectionPhase := g.GetSelectStartingCardsPhase(p.ID())
if selectionPhase != nil {
selectedSet := make(map[string]bool, len(cardIDs))
for _, id := range cardIDs {
selectedSet[id] = true
}
var unselected []string
for _, cardID := range selectionPhase.AvailableCards {
if !selectedSet[cardID] {
unselected = append(unselected, cardID)
}
}
if len(unselected) > 0 {
if err := g.Deck().Discard(ctx, unselected); err != nil {
log.Error("Failed to discard unselected project cards", zap.Error(err))
return fmt.Errorf("failed to discard unselected project cards: %w", err)
}
}
}
// Clear all selection phases to signal completion
if err := g.SetSelectCorporationPhase(ctx, p.ID(), nil); err != nil {
return fmt.Errorf("failed to clear corporation phase: %w", err)
}
if err := g.SetSelectPreludeCardsPhase(ctx, p.ID(), nil); err != nil {
return fmt.Errorf("failed to clear prelude phase: %w", err)
}
if err := g.SetSelectStartingCardsPhase(ctx, p.ID(), nil); err != nil {
return fmt.Errorf("failed to clear starting cards phase: %w", err)
}
a.checkAndAdvanceToInitApplyCorp(ctx, g, log)
log.Info("Starting choices stored")
return nil
}
func (a *SelectStartingChoicesAction) validateCorporation(g *game.Game, p *player.Player, corporationID string, log *zap.Logger) error {
corpPhase := g.GetSelectCorporationPhase(p.ID())
if corpPhase == nil {
return fmt.Errorf("not in corporation selection phase")
}
if p.HasCorporation() {
return fmt.Errorf("corporation already selected")
}
corpAvailable := false
for _, corpID := range corpPhase.AvailableCorporations {
if corpID == corporationID {
corpAvailable = true
break
}
}
if !corpAvailable {
return fmt.Errorf("corporation %s not available", corporationID)
}
corpCard, err := a.cardRegistry.GetByID(corporationID)
if err != nil {
return fmt.Errorf("corporation card not found: %s", corporationID)
}
if corpCard.Type != gamecards.CardTypeCorporation {
return fmt.Errorf("card %s is not a corporation card", corporationID)
}
return nil
}
func (a *SelectStartingChoicesAction) validatePreludes(g *game.Game, p *player.Player, preludeIDs []string, log *zap.Logger) error {
preludePhase := g.GetSelectPreludeCardsPhase(p.ID())
if preludePhase == nil {
if len(preludeIDs) > 0 {
return fmt.Errorf("prelude cards submitted but player has no prelude phase")
}
return nil
}
if len(preludeIDs) != preludePhase.MaxSelectable {
return fmt.Errorf("must select exactly %d preludes, got %d", preludePhase.MaxSelectable, len(preludeIDs))
}
availableSet := make(map[string]bool, len(preludePhase.AvailablePreludes))
for _, id := range preludePhase.AvailablePreludes {
availableSet[id] = true
}
for _, id := range preludeIDs {
if !availableSet[id] {
return fmt.Errorf("prelude %s not available for selection", id)
}
}
return nil
}
func (a *SelectStartingChoicesAction) validateStartingCards(g *game.Game, p *player.Player, corporationID string, cardIDs []string, log *zap.Logger) error {
selectionPhase := g.GetSelectStartingCardsPhase(p.ID())
if selectionPhase == nil {
return fmt.Errorf("not in starting card selection phase")
}
availableSet := make(map[string]bool)
for _, id := range selectionPhase.AvailableCards {
availableSet[id] = true
}
for _, cardID := range cardIDs {
if !availableSet[cardID] {
return fmt.Errorf("card %s not available for selection", cardID)
}
}
costPerCard := getCardBuyCost(a.cardRegistry, corporationID)
cost := len(cardIDs) * costPerCard
startingCredits := getCorpStartingCredits(a.cardRegistry, corporationID)
if startingCredits < cost {
return fmt.Errorf("insufficient credits: need %d, corp provides %d", cost, startingCredits)
}
return nil
}
// getCardBuyCost returns the per-card buy cost accounting for corporation effects (e.g., Polyphemos pays 5 instead of 3)
func getCardBuyCost(cardRegistry cards.CardRegistry, corporationID string) int {
baseCost := 3
corpCard, err := cardRegistry.GetByID(corporationID)
if err != nil {
return baseCost
}
discount := gamecards.CalculateActionDiscountsFromCard(corpCard, shared.ActionCardBuying)
effectiveCost := baseCost - discount
if effectiveCost < 0 {
effectiveCost = 0
}
return effectiveCost
}
// getCorpStartingCredits calculates the starting credits a corporation provides
// by examining its auto-corporation-start behaviors
func getCorpStartingCredits(cardRegistry cards.CardRegistry, corporationID string) int {
corpCard, err := cardRegistry.GetByID(corporationID)
if err != nil {
return 0
}
credits := 0
for _, behavior := range corpCard.Behaviors {
for _, trigger := range behavior.Triggers {
if trigger.Type == string(gamecards.ResourceTriggerAutoCorporationStart) {
for _, output := range behavior.Outputs {
if output.GetResourceType() == shared.ResourceCredit {
credits += output.GetAmount()
}
}
}
}
}
return credits
}
// checkAndAdvanceToInitApplyCorp checks if all players have stored their choices
// and transitions to the init_apply_corp phase
func (a *SelectStartingChoicesAction) checkAndAdvanceToInitApplyCorp(ctx context.Context, g *game.Game, log *zap.Logger) {
allPlayers := g.GetAllPlayers()
turnOrder := g.TurnOrder()
for _, p := range allPlayers {
if p.HasExited() {
continue
}
if g.GetDeferredStartingChoices(p.ID()) == nil {
log.Debug("Waiting for other players to complete starting selection")
return
}
}
log.Debug("All players stored starting choices, advancing to init_apply_corp phase")
if err := g.UpdatePhase(ctx, shared.GamePhaseInitApplyCorp); err != nil {
log.Error("Failed to transition to init_apply_corp phase", zap.Error(err))
return
}
firstPlayerID := findFirstActivePlayer(g, turnOrder)
if firstPlayerID == "" {
return
}
firstIndex := findPlayerIndex(turnOrder, firstPlayerID)
if err := g.SetInitPhasePlayerIndex(ctx, firstIndex); err != nil {
log.Error("Failed to set init phase player index", zap.Error(err))
return
}
if err := g.SetInitPhaseWaitingForConfirm(ctx, true); err != nil {
log.Error("Failed to set waiting for confirm", zap.Error(err))
return
}
}
// ApplyCorpForPlayer applies corporation effects for a single player during init_apply_corp phase.
// This includes starting effects, auto effects, registering triggers/actions,
// and purchasing project cards.
func ApplyCorpForPlayer(ctx context.Context, g *game.Game, playerID string, cardRegistry cards.CardRegistry, corpProc *gamecards.CorporationProcessor, log *zap.Logger) error {
choices := g.GetDeferredStartingChoices(playerID)
if choices == nil {
return fmt.Errorf("no deferred starting choices for player %s", playerID)
}
p, err := g.GetPlayer(playerID)
if err != nil {
return fmt.Errorf("player not found: %s", playerID)
}
corpCard, err := cardRegistry.GetByID(choices.CorporationID)
if err != nil {
return fmt.Errorf("corporation card not found: %s", choices.CorporationID)
}
if corpCard.ResourceStorage != nil {
p.Resources().AddToStorage(choices.CorporationID, corpCard.ResourceStorage.Starting)
}
log.Debug("Applying corporation effects",
zap.String("player_id", playerID),
zap.String("corporation", corpCard.Name))
// Register trigger effects BEFORE applying starting effects so that
// production-increased triggers (e.g. Manutech) fire on starting production
triggerEffects := corpProc.GetTriggerEffects(corpCard)
for _, effect := range triggerEffects {
p.Effects().AddEffect(effect)
baseaction.SubscribePassiveEffectToEvents(ctx, g, p, effect, log, cardRegistry)
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: corpCard.Name,
PlayerID: p.ID(),
SourceType: shared.SourceTypeEffectAdded,
Behaviors: []shared.CardBehavior{effect.Behavior},
})
}
if err := corpProc.ApplyStartingEffects(ctx, corpCard, p, g); err != nil {
return fmt.Errorf("failed to apply corporation starting effects: %w", err)
}
if err := corpProc.ApplyAutoEffects(ctx, corpCard, p, g); err != nil {
return fmt.Errorf("failed to apply corporation auto effects: %w", err)
}
autoEffects := corpProc.GetAutoEffects(corpCard)
for _, effect := range autoEffects {
p.Effects().AddEffect(effect)
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: corpCard.Name,
PlayerID: p.ID(),
SourceType: shared.SourceTypeEffectAdded,
Behaviors: []shared.CardBehavior{effect.Behavior},
})
}
for _, tag := range corpCard.Tags {
events.Publish(g.EventBus(), events.TagPlayedEvent{
GameID: g.ID(),
PlayerID: p.ID(),
CardID: choices.CorporationID,
CardName: corpCard.Name,
Tag: string(tag),
Timestamp: time.Now(),
})
}
g.RegisterCorporationVPGranter(p.ID(), choices.CorporationID)
manualActions := corpProc.GetManualActions(corpCard)
for _, action := range manualActions {
p.Actions().AddAction(action)
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: corpCard.Name,
PlayerID: p.ID(),
SourceType: shared.SourceTypeActionAdded,
Behaviors: []shared.CardBehavior{action.Behavior},
})
}
if err := corpProc.SetupForcedFirstAction(ctx, corpCard, g, p.ID()); err != nil {
return fmt.Errorf("failed to setup forced first action: %w", err)
}
// Purchase project cards now that starting credits are applied (skip cost in demo mode)
if !g.Settings().DemoGame {
costPerCard := getCardBuyCost(cardRegistry, choices.CorporationID)
cost := len(choices.CardIDs) * costPerCard
if cost > 0 {
p.Resources().Add(map[shared.ResourceType]int{
shared.ResourceCredit: -cost,
})
}
}
if len(choices.CardIDs) > 0 {
for _, cardID := range choices.CardIDs {
p.Hand().AddCard(cardID)
}
}
// In demo mode, override resources/production/TR with player's chosen values
// This runs after corp effects so the player's overrides are authoritative
if g.Settings().DemoGame {
demoChoices := p.PendingDemoChoices()
if demoChoices != nil {
p.Resources().Set(demoChoices.Resources)
p.Resources().SetProduction(demoChoices.Production)
p.Resources().SetTerraformRating(demoChoices.TerraformRating)
}
}
g.MarkCorpApplied(playerID)
log.Debug("Corporation effects and card purchase complete",
zap.String("player_id", playerID),
zap.String("corporation", corpCard.Name))
return nil
}
// ApplyPreludesForPlayer applies all prelude card effects for a single player
// during the init_apply_prelude phase.
func ApplyPreludesForPlayer(ctx context.Context, g *game.Game, playerID string, cardRegistry cards.CardRegistry, stateRepo game.GameStateRepository, log *zap.Logger) error {
choices := g.GetDeferredStartingChoices(playerID)
if choices == nil {
return fmt.Errorf("no deferred starting choices for player %s", playerID)
}
if len(choices.PreludeIDs) == 0 {
return nil
}
p, err := g.GetPlayer(playerID)
if err != nil {
return fmt.Errorf("player not found: %s", playerID)
}
log.Debug("Applying prelude effects",
zap.String("player_id", playerID),
zap.Strings("preludes", choices.PreludeIDs))
for _, preludeID := range choices.PreludeIDs {
if err := ApplyPreludeCard(ctx, g, p, preludeID, cardRegistry, stateRepo, log); err != nil {
return fmt.Errorf("failed to apply prelude %s: %w", preludeID, err)
}
}
g.MarkPreludesApplied(playerID)
log.Debug("Prelude effects complete", zap.String("player_id", playerID))
return nil
}
// ApplyPreludeCard applies a single prelude card's effects: adds to played cards,
// processes auto behaviors, registers trigger effects and manual actions.
func ApplyPreludeCard(ctx context.Context, g *game.Game, p *player.Player, preludeID string, cardRegistry cards.CardRegistry, stateRepo game.GameStateRepository, log *zap.Logger) error {
card, err := cardRegistry.GetByID(preludeID)
if err != nil {
return fmt.Errorf("prelude card not found: %s", preludeID)
}
if card.Type != gamecards.CardTypePrelude {
return fmt.Errorf("card %s is not a prelude card", preludeID)
}
tags := make([]string, len(card.Tags))
for i, tag := range card.Tags {
tags[i] = string(tag)
}
p.PlayedCards().AddCard(card.ID, card.Name, string(card.Type), tags)
for behaviorIndex, behavior := range card.Behaviors {
if !gamecards.HasAutoTrigger(behavior) {
continue
}
_, outputs := behavior.ExtractInputsOutputs(nil)
applier := gamecards.NewBehaviorApplier(p, g, card.Name, log).
WithSourceCardID(card.ID).
WithCardRegistry(cardRegistry).
WithSourceType(shared.SourceTypeCardPlay)
_, err := applier.ApplyOutputsAndGetCalculated(ctx, outputs)
if err != nil {
return fmt.Errorf("failed to apply prelude behavior %d: %w", behaviorIndex, err)
}
if gamecards.HasPersistentEffects(behavior) {
effect := shared.CardEffect{
CardID: card.ID,
CardName: card.Name,
BehaviorIndex: behaviorIndex,
Behavior: behavior,
}
p.Effects().AddEffect(effect)
events.Publish(g.EventBus(), events.PlayerEffectsChangedEvent{
GameID: g.ID(),
PlayerID: p.ID(),
Timestamp: time.Now(),
})
}
}
for behaviorIndex, behavior := range card.Behaviors {
if !gamecards.HasConditionalTrigger(behavior) {
continue
}
effect := shared.CardEffect{
CardID: card.ID,
CardName: card.Name,
BehaviorIndex: behaviorIndex,
Behavior: behavior,
}
p.Effects().AddEffect(effect)
baseaction.SubscribePassiveEffectToEvents(ctx, g, p, effect, log, cardRegistry)
}
for behaviorIndex, behavior := range card.Behaviors {
if !gamecards.HasManualTrigger(behavior) {
continue
}
p.Actions().AddAction(shared.CardAction{
CardID: card.ID,
CardName: card.Name,
BehaviorIndex: behaviorIndex,
Behavior: behavior,
})
}
if stateRepo != nil {
description := fmt.Sprintf("Played prelude %s", card.Name)
displayData := baseaction.BuildCardDisplayData(card, shared.SourceTypeCardPlay)
if _, err := stateRepo.WriteFull(ctx, g.ID(), g, card.Name, shared.SourceTypeCardPlay, p.ID(), description, nil, nil, displayData); err != nil {
log.Warn("Failed to write prelude state log", zap.String("card", card.Name), zap.Error(err))
}
}
return nil
}
// AdvanceToActionPhase transitions the game to the action phase and sets the first player's turn.
func AdvanceToActionPhase(ctx context.Context, g *game.Game, allPlayers []*player.Player, log *zap.Logger) {
if err := g.UpdatePhase(ctx, shared.GamePhaseAction); err != nil {
log.Error("Failed to transition game phase", zap.Error(err))
return
}
activePlayerCount := 0
for _, p := range allPlayers {
if !p.HasExited() {
activePlayerCount++
}
}
turnOrder := g.TurnOrder()
if len(turnOrder) > 0 {
firstPlayerID := findFirstActivePlayer(g, turnOrder)
if firstPlayerID == "" {
return
}
availableActions := 2
if activePlayerCount == 1 {
availableActions = -1
log.Debug("Solo mode detected - setting unlimited actions")
}
if err := g.SetCurrentTurn(ctx, firstPlayerID, availableActions); err != nil {
log.Error("Failed to set current turn", zap.Error(err))
return
}
log.Debug("Set first player turn with actions",
zap.String("first_player_id", firstPlayerID),
zap.Int("available_actions", availableActions))
}
}
func findFirstActivePlayer(g *game.Game, turnOrder []string) string {
for _, id := range turnOrder {
p, err := g.GetPlayer(id)
if err == nil && !p.HasExited() {
return p.ID()
}
}
return ""
}
package turn_management
import (
"context"
"fmt"
baseaction "terraforming-mars-backend/internal/action"
gameaction "terraforming-mars-backend/internal/action/game"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game"
playerPkg "terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
)
// SkipActionAction handles the business logic for skipping/passing player turns
type SkipActionAction struct {
baseaction.BaseAction
finalScoringAction *gameaction.FinalScoringAction
}
// NewSkipActionAction creates a new skip action action
func NewSkipActionAction(
gameRepo game.GameRepository,
finalScoringAction *gameaction.FinalScoringAction,
logger *zap.Logger,
) *SkipActionAction {
return &SkipActionAction{
BaseAction: baseaction.NewBaseAction(gameRepo, nil),
finalScoringAction: finalScoringAction,
}
}
// Execute performs the skip action
func (a *SkipActionAction) Execute(ctx context.Context, gameID string, playerID string) error {
log := a.InitLogger(gameID, playerID).With(zap.String("action", "skip_action"))
log.Debug("Skipping player turn")
g, err := baseaction.ValidateActiveGame(ctx, a.GameRepository(), gameID, log)
if err != nil {
return err
}
if err := baseaction.ValidateCurrentTurn(g, playerID, log); err != nil {
return err
}
if err := baseaction.ValidateNoPendingSelections(g, playerID, log); err != nil {
return err
}
turnOrder := g.TurnOrder()
currentTurn := g.CurrentTurn()
if currentTurn != nil {
currentTurn.IncrementGlobalActionCounter()
}
currentPlayer, err := g.GetPlayer(playerID)
if err != nil {
log.Error("Current player not found in game")
return fmt.Errorf("player not found in game")
}
currentPlayerIndex := -1
for i, id := range turnOrder {
if id == playerID {
currentPlayerIndex = i
break
}
}
if currentPlayerIndex == -1 {
log.Error("Current player not found in turn order")
return fmt.Errorf("player not found in turn order")
}
activePlayerCount := 0
for _, id := range turnOrder {
p, _ := g.GetPlayer(id)
if p != nil && !p.HasPassed() && !p.HasExited() {
activePlayerCount++
}
}
if currentTurn == nil {
log.Error("No current turn set")
return fmt.Errorf("no current turn set")
}
availableActions := currentTurn.ActionsRemaining()
isPassing := availableActions == 2 || availableActions == -1 || len(turnOrder) == 1
if isPassing {
currentPlayer.SetPassed(true)
log.Debug("Player PASSED (marked as passed for generation)",
zap.String("player_id", playerID),
zap.Int("available_actions", availableActions))
if activePlayerCount == 2 {
for _, id := range turnOrder {
p, _ := g.GetPlayer(id)
if p != nil && !p.HasPassed() && !p.HasExited() && p.ID() != playerID {
if err := g.SetCurrentTurn(ctx, p.ID(), -1); err != nil {
log.Error("Failed to grant unlimited actions to last player", zap.Error(err))
return fmt.Errorf("failed to grant unlimited actions: %w", err)
}
log.Debug("Last active player granted unlimited actions due to others passing",
zap.String("player_id", p.ID()))
}
}
}
} else {
// SKIP: Player is done with their turn but not passing for the generation
// Don't consume action - just advance to next player
log.Debug("Player SKIPPED (turn advanced, not passed)",
zap.String("player_id", playerID),
zap.Int("available_actions", availableActions))
}
passedOrExitedCount := 0
for _, id := range turnOrder {
p, _ := g.GetPlayer(id)
if p != nil && (p.HasPassed() || p.HasExited()) {
passedOrExitedCount++
}
}
allPlayersFinished := passedOrExitedCount == len(turnOrder)
log.Debug("Checking generation end condition",
zap.Int("passed_or_exited_count", passedOrExitedCount),
zap.Int("total_players", len(turnOrder)),
zap.Bool("all_players_finished", allPlayersFinished))
if allPlayersFinished {
var activePlayers []*playerPkg.Player
for _, p := range g.GetAllPlayers() {
if !p.HasExited() {
activePlayers = append(activePlayers, p)
}
}
if g.CurrentPhase() == shared.GamePhaseFinalPhase {
log.Debug("All players finished final phase - triggering final scoring",
zap.String("game_id", gameID))
if err := a.finalScoringAction.Execute(ctx, gameID); err != nil {
log.Error("Failed to execute final scoring", zap.Error(err))
return fmt.Errorf("failed to execute final scoring: %w", err)
}
log.Info("Game ended after final phase")
return nil
}
if g.GlobalParameters().IsMaxed() {
log.Debug("All global parameters maxed - running final production phase",
zap.String("game_id", gameID),
zap.Int("generation", g.Generation()))
err = ExecuteFinalProductionPhase(ctx, g, activePlayers, log)
if err != nil {
log.Error("Failed to execute final production phase", zap.Error(err))
return fmt.Errorf("failed to execute final production phase: %w", err)
}
log.Info("Final production phase started, awaiting player confirmation")
return nil
}
log.Debug("All players finished their turns - executing production phase",
zap.String("game_id", gameID),
zap.Int("generation", g.Generation()),
zap.Int("passed_or_exited_players", passedOrExitedCount))
err = ExecuteProductionPhase(ctx, g, activePlayers, log)
if err != nil {
log.Error("Failed to execute production phase", zap.Error(err))
return fmt.Errorf("failed to execute production phase: %w", err)
}
log.Info("New generation started")
return nil
}
nextPlayerIndex := (currentPlayerIndex + 1) % len(turnOrder)
for i := 0; i < len(turnOrder); i++ {
nextPlayer, _ := g.GetPlayer(turnOrder[nextPlayerIndex])
if nextPlayer != nil && !nextPlayer.HasPassed() && !nextPlayer.HasExited() {
break
}
nextPlayerIndex = (nextPlayerIndex + 1) % len(turnOrder)
}
nextPlayerID := turnOrder[nextPlayerIndex]
nextActions := 2
nonPassedCount := 0
for _, id := range turnOrder {
p, _ := g.GetPlayer(id)
if p != nil && !p.HasPassed() && !p.HasExited() {
nonPassedCount++
}
}
if nonPassedCount == 1 {
nextActions = -1
log.Debug("Next player is the last non-passed player, granting unlimited actions",
zap.String("player_id", nextPlayerID))
}
err = g.SetCurrentTurn(ctx, nextPlayerID, nextActions)
if err != nil {
log.Error("Failed to update current turn", zap.Error(err))
return fmt.Errorf("failed to update game: %w", err)
}
log.Info("Player turn skipped, advanced to next player",
zap.String("previous_player", playerID),
zap.String("current_player", nextPlayerID))
return nil
}
package turn_management
import (
"context"
"fmt"
"math/rand"
"time"
"go.uber.org/zap"
"terraforming-mars-backend/internal/awards"
"terraforming-mars-backend/internal/colonies"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/colony"
playerPkg "terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/projectfunding"
"terraforming-mars-backend/internal/game/shared"
"terraforming-mars-backend/internal/milestones"
pfRegistry "terraforming-mars-backend/internal/projectfunding"
)
// BotStarter starts bot sessions when a game begins.
type BotStarter interface {
StartBot(gameID, playerID, botName, difficulty, speed string, settings shared.GameSettings) error
}
// StartGameAction handles the business logic for starting games
// NOTE: Deck initialization is handled separately before calling this action
type StartGameAction struct {
gameRepo game.GameRepository
colonyRegistry colonies.ColonyRegistry
projectFundingRegistry pfRegistry.ProjectFundingRegistry
milestoneRegistry milestones.MilestoneRegistry
awardRegistry awards.AwardRegistry
botStarter BotStarter
logger *zap.Logger
}
// NewStartGameAction creates a new start game action
func NewStartGameAction(
gameRepo game.GameRepository,
colonyRegistry colonies.ColonyRegistry,
projectFundingRegistry pfRegistry.ProjectFundingRegistry,
milestoneRegistry milestones.MilestoneRegistry,
awardRegistry awards.AwardRegistry,
botStarter BotStarter,
logger *zap.Logger,
) *StartGameAction {
return &StartGameAction{
gameRepo: gameRepo,
colonyRegistry: colonyRegistry,
projectFundingRegistry: projectFundingRegistry,
milestoneRegistry: milestoneRegistry,
awardRegistry: awardRegistry,
botStarter: botStarter,
logger: logger,
}
}
// Execute performs the start game action
func (a *StartGameAction) Execute(ctx context.Context, gameID string, playerID string) error {
log := a.logger.With(
zap.String("game_id", gameID),
zap.String("player_id", playerID),
zap.String("action", "start_game"),
)
log.Debug("Starting game")
// 1. Fetch game from repository
g, err := a.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
return fmt.Errorf("game not found: %s", gameID)
}
// 2. BUSINESS LOGIC: Validate game is in lobby status
if g.Status() != shared.GameStatusLobby {
log.Warn("Game is not in lobby", zap.String("status", string(g.Status())))
return fmt.Errorf("game is not in lobby: %s", g.Status())
}
// 3. BUSINESS LOGIC: Validate player is the host
if g.HostPlayerID() != playerID {
log.Warn("Only host can start the game",
zap.String("host_id", g.HostPlayerID()),
zap.String("requesting_player", playerID))
return fmt.Errorf("only host can start the game")
}
// 4. Get all players
players := g.GetAllPlayers()
log.Debug("Starting game with players", zap.Int("player_count", len(players)))
// 5. BUSINESS LOGIC: Randomize and set turn order
playerIDs := make([]string, len(players))
for i, p := range players {
playerIDs[i] = p.ID()
}
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
rng.Shuffle(len(playerIDs), func(i, j int) {
playerIDs[i], playerIDs[j] = playerIDs[j], playerIDs[i]
})
if err := g.SetTurnOrder(ctx, playerIDs); err != nil {
log.Error("Failed to set turn order", zap.Error(err))
return fmt.Errorf("failed to set turn order: %w", err)
}
log.Debug("Randomized turn order", zap.Strings("turn_order", playerIDs))
// 5b. BUSINESS LOGIC: Initialize colony tiles if colonies pack enabled
if g.Settings().HasColonies() {
a.initializeColonies(g, playerIDs, rng, log)
}
// 5c. BUSINESS LOGIC: Initialize project funding if enabled
if g.Settings().HasProjectFunding() {
a.initializeProjectFunding(g, log)
}
// 5d. BUSINESS LOGIC: Select random milestones and awards
a.initializeMilestonesAndAwards(g, rng, log)
// 6. BUSINESS LOGIC: Ensure deck is initialized
deck := g.Deck()
if deck == nil {
log.Error("Game deck not initialized")
return fmt.Errorf("game deck not initialized - must initialize deck before starting game")
}
// 7. BUSINESS LOGIC: Update game status to Active
if err := g.UpdateStatus(ctx, shared.GameStatusActive); err != nil {
log.Error("Failed to update game status", zap.Error(err))
return fmt.Errorf("failed to update game status: %w", err)
}
// 8. BUSINESS LOGIC: Set first player's turn (use randomized turn order)
if len(playerIDs) > 0 {
firstPlayerID := playerIDs[0]
if err := g.SetCurrentTurn(ctx, firstPlayerID, 0); err != nil {
log.Error("Failed to set current turn", zap.Error(err))
return fmt.Errorf("failed to set current turn: %w", err)
}
log.Debug("Set initial turn", zap.String("first_player_id", firstPlayerID))
}
// 9. BUSINESS LOGIC: Demo games use pre-selected choices, normal games distribute cards
if g.Settings().DemoGame {
if err := a.startDemoGame(ctx, g, players, log); err != nil {
return err
}
} else {
if err := g.UpdatePhase(ctx, shared.GamePhaseStartingSelection); err != nil {
log.Error("Failed to update game phase", zap.Error(err))
return fmt.Errorf("failed to update game phase: %w", err)
}
if err := a.distributeCorporations(ctx, g, players); err != nil {
log.Error("Failed to distribute corporations", zap.Error(err))
return fmt.Errorf("failed to distribute corporations: %w", err)
}
log.Debug("Corporations distributed to all players")
if g.Settings().HasPrelude() {
if err := a.distributePreludeCards(ctx, g, players); err != nil {
log.Error("Failed to distribute prelude cards", zap.Error(err))
return fmt.Errorf("failed to distribute prelude cards: %w", err)
}
log.Debug("Prelude cards distributed to all players")
}
if err := a.distributeProjectCards(ctx, g, players); err != nil {
log.Error("Failed to distribute project cards", zap.Error(err))
return fmt.Errorf("failed to distribute project cards: %w", err)
}
log.Debug("Project cards distributed to all players")
}
// Start bot sessions for any bot players
if a.botStarter != nil {
settings := g.Settings()
for _, p := range players {
if p.IsBot() {
if err := a.botStarter.StartBot(gameID, p.ID(), p.Name(), string(p.BotDifficulty()), string(p.BotSpeed()), settings); err != nil {
log.Error("Failed to start bot",
zap.String("bot_player_id", p.ID()),
zap.Error(err))
}
}
}
}
log.Info("Game started")
return nil
}
func (a *StartGameAction) initializeColonies(g *game.Game, playerIDs []string, rng *rand.Rand, log *zap.Logger) {
allColonies := a.colonyRegistry.GetAll()
if len(allColonies) == 0 {
log.Warn("No colony definitions available")
return
}
// Select N+2 colonies (min 5)
numToSelect := len(playerIDs) + 2
if numToSelect < 5 {
numToSelect = 5
}
if numToSelect > len(allColonies) {
numToSelect = len(allColonies)
}
// Shuffle and pick
rng.Shuffle(len(allColonies), func(i, j int) {
allColonies[i], allColonies[j] = allColonies[j], allColonies[i]
})
selected := allColonies[:numToSelect]
// Initialize tile states
states := make([]*colony.ColonyState, len(selected))
for i, def := range selected {
states[i] = &colony.ColonyState{
DefinitionID: def.ID,
MarkerPosition: 1,
PlayerColonies: []string{},
TradedThisGen: false,
}
}
g.Colonies().SetStates(states)
g.InitializeTradeFleets(playerIDs)
log.Debug("Colony tiles initialized",
zap.Int("colony_count", len(states)),
zap.Int("player_count", len(playerIDs)))
}
const (
maxSelectedMilestones = 5
maxSelectedAwards = 5
)
func (a *StartGameAction) initializeMilestonesAndAwards(g *game.Game, rng *rand.Rand, log *zap.Logger) {
settings := g.Settings()
// Use pre-selected milestones/awards from settings if provided
if len(settings.SelectedMilestones) > 0 {
g.SetSelectedMilestones(settings.SelectedMilestones)
log.Debug("Using pre-selected milestones", zap.Strings("selected", settings.SelectedMilestones))
} else if a.milestoneRegistry != nil {
eligible := a.getEligibleMilestoneIDs(settings)
rng.Shuffle(len(eligible), func(i, j int) {
eligible[i], eligible[j] = eligible[j], eligible[i]
})
count := maxSelectedMilestones
if count > len(eligible) {
count = len(eligible)
}
g.SetSelectedMilestones(eligible[:count])
log.Debug("Milestones randomly selected", zap.Int("count", count), zap.Strings("selected", eligible[:count]))
}
if len(settings.SelectedAwards) > 0 {
g.SetSelectedAwards(settings.SelectedAwards)
log.Debug("Using pre-selected awards", zap.Strings("selected", settings.SelectedAwards))
} else if a.awardRegistry != nil {
eligible := a.getEligibleAwardIDs(settings)
rng.Shuffle(len(eligible), func(i, j int) {
eligible[i], eligible[j] = eligible[j], eligible[i]
})
count := maxSelectedAwards
if count > len(eligible) {
count = len(eligible)
}
g.SetSelectedAwards(eligible[:count])
log.Debug("Awards randomly selected", zap.Int("count", count), zap.Strings("selected", eligible[:count]))
}
}
func (a *StartGameAction) getEligibleMilestoneIDs(settings shared.GameSettings) []string {
enabledPacks := settings.EnabledPacks()
var eligible []string
for _, def := range a.milestoneRegistry.GetAll() {
if def.Pack != "" && !enabledPacks[def.Pack] {
continue
}
eligible = append(eligible, def.ID)
}
return eligible
}
func (a *StartGameAction) getEligibleAwardIDs(settings shared.GameSettings) []string {
enabledPacks := settings.EnabledPacks()
var eligible []string
for _, def := range a.awardRegistry.GetAll() {
if def.Pack != "" && !enabledPacks[def.Pack] {
continue
}
eligible = append(eligible, def.ID)
}
return eligible
}
func (a *StartGameAction) initializeProjectFunding(g *game.Game, log *zap.Logger) {
if a.projectFundingRegistry == nil {
log.Warn("No project funding registry available")
return
}
allProjects := a.projectFundingRegistry.GetAll()
if len(allProjects) == 0 {
log.Warn("No project funding definitions available")
return
}
states := make([]*projectfunding.ProjectState, len(allProjects))
for i, def := range allProjects {
states[i] = &projectfunding.ProjectState{
DefinitionID: def.ID,
SeatOwners: []string{},
IsCompleted: false,
}
}
g.SetProjectFundingStates(states)
log.Debug("Project funding initialized", zap.Int("project_count", len(states)))
}
func (a *StartGameAction) startDemoGame(ctx context.Context, g *game.Game, players []*playerPkg.Player, log *zap.Logger) error {
settings := g.Settings()
deck := g.Deck()
turnOrder := g.TurnOrder()
// Validate all human players have made selections; auto-assign bots
for _, p := range players {
if p.IsBot() {
if !p.HasPendingDemoChoices() {
// Auto-assign random corporation for bots
corpIDs, err := deck.DrawCorporations(ctx, 1)
if err != nil {
return fmt.Errorf("failed to draw corporation for bot %s: %w", p.ID(), err)
}
p.SetPendingDemoChoices(&shared.PendingDemoChoices{
CorporationID: corpIDs[0],
})
}
} else if !p.HasPendingDemoChoices() {
return fmt.Errorf("player %s has not selected cards", p.Name())
}
}
// Convert PendingDemoChoices to DeferredStartingChoices for each player
for _, p := range players {
choices := p.PendingDemoChoices()
if err := g.SetDeferredStartingChoices(ctx, p.ID(), &shared.DeferredStartingChoices{
CorporationID: choices.CorporationID,
PreludeIDs: choices.PreludeIDs,
CardIDs: choices.CardIDs,
}); err != nil {
return fmt.Errorf("failed to set deferred choices for player %s: %w", p.ID(), err)
}
p.SetCorporationID(choices.CorporationID)
}
// Apply global parameter overrides from settings
gp := g.GlobalParameters()
if settings.Temperature != nil {
if err := gp.SetTemperature(ctx, *settings.Temperature); err != nil {
return fmt.Errorf("failed to set temperature: %w", err)
}
}
if settings.Oxygen != nil {
if err := gp.SetOxygen(ctx, *settings.Oxygen); err != nil {
return fmt.Errorf("failed to set oxygen: %w", err)
}
}
if settings.Oceans != nil {
if err := gp.SetOceans(ctx, *settings.Oceans); err != nil {
return fmt.Errorf("failed to set oceans: %w", err)
}
}
if settings.Generation != nil {
if err := g.SetGeneration(ctx, *settings.Generation); err != nil {
return fmt.Errorf("failed to set generation: %w", err)
}
}
// Transition to InitApplyCorp phase (same as normal flow)
if err := g.UpdatePhase(ctx, shared.GamePhaseInitApplyCorp); err != nil {
return fmt.Errorf("failed to update game phase: %w", err)
}
firstPlayerID := findFirstActivePlayer(g, turnOrder)
if firstPlayerID != "" {
firstIndex := findPlayerIndex(turnOrder, firstPlayerID)
if err := g.SetInitPhasePlayerIndex(ctx, firstIndex); err != nil {
return fmt.Errorf("failed to set init phase player index: %w", err)
}
if err := g.SetInitPhaseWaitingForConfirm(ctx, true); err != nil {
return fmt.Errorf("failed to set waiting for confirm: %w", err)
}
}
log.Debug("Demo game entering init_apply_corp phase")
return nil
}
func (a *StartGameAction) distributeCorporations(ctx context.Context, g *game.Game, players []*playerPkg.Player) error {
deck := g.Deck()
if deck == nil {
return fmt.Errorf("game deck is nil")
}
for _, p := range players {
corporationIDs, err := deck.DrawCorporations(ctx, 2)
if err != nil {
return fmt.Errorf("failed to draw corporations for player %s: %w", p.ID(), err)
}
phase := &shared.SelectCorporationPhase{
AvailableCorporations: corporationIDs,
}
if err := g.SetSelectCorporationPhase(ctx, p.ID(), phase); err != nil {
return fmt.Errorf("failed to set corporation phase for player %s: %w", p.ID(), err)
}
}
return nil
}
func (a *StartGameAction) distributePreludeCards(ctx context.Context, g *game.Game, players []*playerPkg.Player) error {
deck := g.Deck()
if deck == nil {
return fmt.Errorf("game deck is nil")
}
for _, p := range players {
preludeIDs, err := deck.DrawPreludeCards(ctx, 4)
if err != nil {
return fmt.Errorf("failed to draw prelude cards for player %s: %w", p.ID(), err)
}
phase := &shared.SelectPreludeCardsPhase{
AvailablePreludes: preludeIDs,
MaxSelectable: 2,
}
if err := g.SetSelectPreludeCardsPhase(ctx, p.ID(), phase); err != nil {
return fmt.Errorf("failed to set prelude phase for player %s: %w", p.ID(), err)
}
}
return nil
}
func (a *StartGameAction) distributeProjectCards(ctx context.Context, g *game.Game, players []*playerPkg.Player) error {
deck := g.Deck()
if deck == nil {
return fmt.Errorf("game deck is nil")
}
for _, p := range players {
projectCardIDs, err := deck.DrawProjectCards(ctx, 10)
if err != nil {
return fmt.Errorf("failed to draw project cards for player %s: %w", p.ID(), err)
}
phase := &shared.SelectStartingCardsPhase{
AvailableCards: projectCardIDs,
}
if err := g.SetSelectStartingCardsPhase(ctx, p.ID(), phase); err != nil {
return fmt.Errorf("failed to set selection phase for player %s: %w", p.ID(), err)
}
}
return nil
}
package action
import (
"context"
"fmt"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/shared"
"go.uber.org/zap"
)
// ValidateGameExists validates that a game exists (any status)
// Returns the game if valid, or an error if not found
func ValidateGameExists(
ctx context.Context,
gameRepo game.GameRepository,
gameID string,
log *zap.Logger,
) (*game.Game, error) {
game, err := gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Game not found", zap.Error(err))
return nil, fmt.Errorf("game not found: %w", err)
}
return game, nil
}
// ValidateActiveGame validates that a game exists and is in active status
// Returns the game if valid, or an error if not found or wrong status
func ValidateActiveGame(
ctx context.Context,
gameRepo game.GameRepository,
gameID string,
log *zap.Logger,
) (*game.Game, error) {
return ValidateGameStatus(ctx, gameRepo, gameID, shared.GameStatusActive, log)
}
// ValidateLobbyGame validates that a game exists and is in lobby status
// Returns the game if valid, or an error if not found or wrong status
func ValidateLobbyGame(
ctx context.Context,
gameRepo game.GameRepository,
gameID string,
log *zap.Logger,
) (*game.Game, error) {
return ValidateGameStatus(ctx, gameRepo, gameID, shared.GameStatusLobby, log)
}
// ValidateGameStatus validates that a game exists and has the expected status
// Returns the game if valid, or an error if not found or wrong status
func ValidateGameStatus(
ctx context.Context,
gameRepo game.GameRepository,
gameID string,
expectedStatus shared.GameStatus,
log *zap.Logger,
) (*game.Game, error) {
gameResult, err := gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Game not found", zap.Error(err))
return nil, fmt.Errorf("game not found: %w", err)
}
if gameResult.Status() != expectedStatus {
log.Error("Game not in expected status",
zap.String("expected", string(expectedStatus)),
zap.String("actual", string(gameResult.Status())))
return nil, fmt.Errorf("game not in %s status", expectedStatus)
}
return gameResult, nil
}
// ValidateGamePhase validates that a game is in the expected phase
// Returns error if game is not in the expected phase
func ValidateGamePhase(
gameInstance *game.Game,
expectedPhase shared.GamePhase,
log *zap.Logger,
) error {
if gameInstance.CurrentPhase() != expectedPhase {
log.Error("Game not in expected phase",
zap.String("expected", string(expectedPhase)),
zap.String("actual", string(gameInstance.CurrentPhase())))
return fmt.Errorf("game not in %s phase", expectedPhase)
}
return nil
}
// ValidateHostPermission validates that the specified player is the game host
// Returns error if player is not the host
func ValidateHostPermission(
gameInstance *game.Game,
playerID string,
log *zap.Logger,
) error {
if gameInstance.HostPlayerID() != playerID {
log.Error("Non-host attempted privileged action",
zap.String("player_id", playerID),
zap.String("host_id", gameInstance.HostPlayerID()))
return fmt.Errorf("only the host can perform this action")
}
return nil
}
// ValidateCurrentTurn validates that it's the specified player's turn
// Returns error if it's not their turn or no current turn is set
func ValidateCurrentTurn(
gameInstance *game.Game,
playerID string,
log *zap.Logger,
) error {
currentTurn := gameInstance.CurrentTurn()
if currentTurn == nil {
log.Error("No current turn set")
return fmt.Errorf("no current turn set")
}
if currentTurn.PlayerID() != playerID {
log.Error("Not player's turn",
zap.String("player_id", playerID),
zap.String("current_turn", currentTurn.PlayerID()))
return fmt.Errorf("not your turn")
}
return nil
}
// ValidateActionsRemaining validates that the current player has actions remaining
// Returns error if actionsRemaining == 0; allows -1 (unlimited) and >0
func ValidateActionsRemaining(
gameInstance *game.Game,
playerID string,
log *zap.Logger,
) error {
currentTurn := gameInstance.CurrentTurn()
if currentTurn == nil {
return nil
}
if currentTurn.PlayerID() != playerID {
return nil
}
remaining := currentTurn.ActionsRemaining()
if remaining == 0 {
log.Warn("No actions remaining",
zap.String("player_id", playerID),
zap.Int("actions_remaining", remaining))
return fmt.Errorf("no actions remaining")
}
return nil
}
// ValidateNoPendingSelections validates that the player has no pending selections.
// Actions should be blocked while a player is resolving a pending selection.
func ValidateNoPendingSelections(
gameInstance *game.Game,
playerID string,
log *zap.Logger,
) error {
if gameInstance.HasAnyPendingSelection(playerID) {
return fmt.Errorf("pending selection")
}
return nil
}
package awards
import (
"encoding/json"
"fmt"
"os"
"terraforming-mars-backend/internal/game/award"
)
// LoadAwardsFromJSON loads award definitions from a JSON file
func LoadAwardsFromJSON(filepath string) ([]award.AwardDefinition, error) {
data, err := os.ReadFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to read awards file: %w", err)
}
var awards []award.AwardDefinition
if err := json.Unmarshal(data, &awards); err != nil {
return nil, fmt.Errorf("failed to parse awards JSON: %w", err)
}
if len(awards) == 0 {
return nil, fmt.Errorf("no awards found in file: %s", filepath)
}
return awards, nil
}
package awards
import (
"fmt"
"terraforming-mars-backend/internal/game/award"
)
// AwardRegistry provides lookup functionality for award definitions
type AwardRegistry interface {
GetByID(awardID string) (*award.AwardDefinition, error)
GetAll() []award.AwardDefinition
}
// InMemoryAwardRegistry implements AwardRegistry with an in-memory map
type InMemoryAwardRegistry struct {
awards map[string]award.AwardDefinition
order []string
}
// NewInMemoryAwardRegistry creates a new registry from a slice of definitions
func NewInMemoryAwardRegistry(awardList []award.AwardDefinition) *InMemoryAwardRegistry {
awardMap := make(map[string]award.AwardDefinition, len(awardList))
order := make([]string, 0, len(awardList))
for _, a := range awardList {
awardMap[a.ID] = a
order = append(order, a.ID)
}
return &InMemoryAwardRegistry{awards: awardMap, order: order}
}
// GetByID retrieves an award definition by ID
func (r *InMemoryAwardRegistry) GetByID(awardID string) (*award.AwardDefinition, error) {
a, exists := r.awards[awardID]
if !exists {
return nil, fmt.Errorf("award not found: %s", awardID)
}
return &a, nil
}
// GetAll returns all award definitions in their original JSON order
func (r *InMemoryAwardRegistry) GetAll() []award.AwardDefinition {
result := make([]award.AwardDefinition, 0, len(r.order))
for _, id := range r.order {
result = append(result, r.awards[id])
}
return result
}
package cards
import (
"encoding/json"
"fmt"
"os"
"terraforming-mars-backend/internal/game/cards"
)
// LoadCardsFromJSON loads cards from a JSON file
func LoadCardsFromJSON(filepath string) ([]cards.Card, error) {
data, err := os.ReadFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to read card file: %w", err)
}
var cards []cards.Card
if err := json.Unmarshal(data, &cards); err != nil {
return nil, fmt.Errorf("failed to parse card JSON: %w", err)
}
if len(cards) == 0 {
return nil, fmt.Errorf("no cards found in file: %s", filepath)
}
return cards, nil
}
package cards
import (
"fmt"
"terraforming-mars-backend/internal/game"
gamecards "terraforming-mars-backend/internal/game/cards"
"terraforming-mars-backend/internal/game/shared"
)
// CardRegistry provides lookup functionality for card data
type CardRegistry interface {
// GetByID retrieves a card by its ID
GetByID(cardID string) (*gamecards.Card, error)
// GetAll returns all cards in the registry
GetAll() []gamecards.Card
}
// InMemoryCardRegistry implements CardRegistry with an in-memory map
type InMemoryCardRegistry struct {
cards map[string]gamecards.Card
}
// NewInMemoryCardRegistry creates a new card registry from a slice of cards
func NewInMemoryCardRegistry(cardList []gamecards.Card) *InMemoryCardRegistry {
cardMap := make(map[string]gamecards.Card, len(cardList))
for _, card := range cardList {
cardMap[card.ID] = card
}
return &InMemoryCardRegistry{
cards: cardMap,
}
}
// GetByID retrieves a card by its ID, returning a copy to prevent mutation
func (r *InMemoryCardRegistry) GetByID(cardID string) (*gamecards.Card, error) {
card, exists := r.cards[cardID]
if !exists {
return nil, fmt.Errorf("card not found: %s", cardID)
}
// Return a deep copy to prevent external mutation
cardCopy := card.DeepCopy()
return &cardCopy, nil
}
// GetAll returns all cards in the registry
func (r *InMemoryCardRegistry) GetAll() []gamecards.Card {
cardList := make([]gamecards.Card, 0, len(r.cards))
for _, card := range r.cards {
cardList = append(cardList, card.DeepCopy())
}
return cardList
}
// GetCardIDsByPacks filters cards by pack and separates them by type.
// Returns project card IDs, corporation IDs, and prelude IDs.
func GetCardIDsByPacks(registry CardRegistry, packs []string) (projectCards, corps, preludes []string) {
allCards := registry.GetAll()
packMap := make(map[string]bool, len(packs))
for _, pack := range packs {
packMap[pack] = true
}
for _, card := range allCards {
if !packMap[card.Pack] {
continue
}
switch card.Type {
case gamecards.CardTypeCorporation:
corps = append(corps, card.ID)
case gamecards.CardTypePrelude:
preludes = append(preludes, card.ID)
default:
projectCards = append(projectCards, card.ID)
}
}
return projectCards, corps, preludes
}
type VPCardLookupAdapter struct {
registry CardRegistry
}
func NewVPCardLookupAdapter(registry CardRegistry) *VPCardLookupAdapter {
return &VPCardLookupAdapter{registry: registry}
}
func (a *VPCardLookupAdapter) LookupVPCard(cardID string) (*game.VPCardInfo, error) {
card, err := a.registry.GetByID(cardID)
if err != nil {
return nil, err
}
vpConditions := make([]shared.VPCondition, len(card.VPConditions))
for i, vc := range card.VPConditions {
vpConditions[i] = convertVPCondition(vc)
}
tags := make([]shared.CardTag, len(card.Tags))
copy(tags, card.Tags)
return &game.VPCardInfo{
CardID: card.ID,
CardName: card.Name,
CardType: string(card.Type),
Description: card.Description,
VPConditions: vpConditions,
Tags: tags,
}, nil
}
func convertVPCondition(vc gamecards.VictoryPointCondition) shared.VPCondition {
cond := shared.VPCondition{
Amount: vc.Amount,
Condition: string(vc.Condition),
MaxTrigger: vc.MaxTrigger,
Per: vc.Per,
}
return cond
}
package colonies
import (
"encoding/json"
"fmt"
"os"
"terraforming-mars-backend/internal/game/colony"
)
// LoadColoniesFromJSON loads colony tile definitions from a JSON file
func LoadColoniesFromJSON(filepath string) ([]colony.ColonyDefinition, error) {
data, err := os.ReadFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to read colony file: %w", err)
}
var colonies []colony.ColonyDefinition
if err := json.Unmarshal(data, &colonies); err != nil {
return nil, fmt.Errorf("failed to parse colony JSON: %w", err)
}
if len(colonies) == 0 {
return nil, fmt.Errorf("no colonies found in file: %s", filepath)
}
return colonies, nil
}
package colonies
import (
"fmt"
"terraforming-mars-backend/internal/game/colony"
)
// ColonyRegistry provides lookup functionality for colony tile definitions
type ColonyRegistry interface {
GetByID(colonyID string) (*colony.ColonyDefinition, error)
GetAll() []colony.ColonyDefinition
}
// InMemoryColonyRegistry implements ColonyRegistry with an in-memory map
type InMemoryColonyRegistry struct {
colonies map[string]colony.ColonyDefinition
}
// NewInMemoryColonyRegistry creates a new colony registry from a slice of definitions
func NewInMemoryColonyRegistry(colonyList []colony.ColonyDefinition) *InMemoryColonyRegistry {
colonyMap := make(map[string]colony.ColonyDefinition, len(colonyList))
for _, c := range colonyList {
colonyMap[c.ID] = c
}
return &InMemoryColonyRegistry{colonies: colonyMap}
}
// GetByID retrieves a colony tile definition by ID
func (r *InMemoryColonyRegistry) GetByID(colonyID string) (*colony.ColonyDefinition, error) {
c, exists := r.colonies[colonyID]
if !exists {
return nil, fmt.Errorf("colony not found: %s", colonyID)
}
return &c, nil
}
// GetAll returns all colony tile definitions
func (r *InMemoryColonyRegistry) GetAll() []colony.ColonyDefinition {
result := make([]colony.ColonyDefinition, 0, len(r.colonies))
for _, c := range r.colonies {
result = append(result, c)
}
return result
}
package dto
// ActionType represents different types of game actions
type ActionType string
const (
ActionTypeSelectStartingCard ActionType = "select-starting-card"
ActionTypeSelectCards ActionType = "select-cards"
ActionTypeStartGame ActionType = "start-game"
ActionTypeSkipAction ActionType = "skip-action"
ActionTypePlayCard ActionType = "play-card"
ActionTypeCardAction ActionType = "card-action"
ActionTypeSellPatents ActionType = "sell-patents"
ActionTypeBuildPowerPlant ActionType = "build-power-plant"
ActionTypeLaunchAsteroid ActionType = "launch-asteroid"
ActionTypeBuildAquifer ActionType = "build-aquifer"
ActionTypePlantGreenery ActionType = "plant-greenery"
ActionTypeBuildCity ActionType = "build-city"
ActionTypeConvertPlantsToGreenery ActionType = "convert-plants-to-greenery"
ActionTypeConvertHeatToTemperature ActionType = "convert-heat-to-temperature"
)
// SelectStartingCardAction represents selecting starting cards and corporation
type SelectStartingCardAction struct {
Type ActionType `json:"type"`
CardIDs []string `json:"cardIds"`
CorporationID string `json:"corporationId"`
}
// StartGameAction represents starting the game (host only)
type StartGameAction struct {
Type ActionType `json:"type"`
}
// SkipAction represents skipping a player's turn
type SkipAction struct {
Type ActionType `json:"type"`
}
// PlayCardAction represents playing a card from hand
type PlayCardAction struct {
CardID string `json:"cardId"`
Payment CardPaymentDto `json:"payment"` // Required: payment breakdown (credits, steel, titanium)
ChoiceIndex *int `json:"choiceIndex,omitempty"` // Optional: index of choice to play (for cards with choices)
CardStorageTargets []string `json:"cardStorageTargets,omitempty"` // Optional: target card IDs for resource storage (positional, one per any-card output)
}
// PlayCardActionAction represents playing a card action from player's action list
type PlayCardActionAction struct {
CardID string `json:"cardId"`
BehaviorIndex int `json:"behaviorIndex"`
ChoiceIndex *int `json:"choiceIndex,omitempty"` // Optional: index of choice to play (for actions with choices)
CardStorageTargets []string `json:"cardStorageTargets,omitempty"` // Optional: target card IDs for resource storage (positional, one per any-card output)
}
// HexPositionDto represents a position on the Mars board
type HexPositionDto struct {
Q int `json:"q"`
R int `json:"r"`
S int `json:"s"`
}
// Standard Project Actions
// SellPatentsAction represents selling patent cards for megacredits (initiates card selection)
type SellPatentsAction struct {
Type ActionType `json:"type"`
}
// BuildPowerPlantAction represents building a power plant
type BuildPowerPlantAction struct {
Type ActionType `json:"type"`
}
// LaunchAsteroidAction represents launching an asteroid
type LaunchAsteroidAction struct {
Type ActionType `json:"type"`
}
// BuildAquiferAction represents building an aquifer
type BuildAquiferAction struct {
Type ActionType `json:"type"`
HexPosition HexPositionDto `json:"hexPosition"`
}
// PlantGreeneryAction represents planting greenery
type PlantGreeneryAction struct {
Type ActionType `json:"type"`
HexPosition HexPositionDto `json:"hexPosition"`
}
// BuildCityAction represents building a city
type BuildCityAction struct {
Type ActionType `json:"type"`
HexPosition HexPositionDto `json:"hexPosition"`
}
// ActionSelectStartingCardRequest contains the action data for select starting card actions
type ActionSelectStartingCardRequest struct {
Type ActionType `json:"type"`
CardIDs []string `json:"cardIds"`
CorporationID string `json:"corporationId"` // Corporation selected alongside starting cards
}
// ActionSelectProductionCardsRequest contains the action data for select production card actions
type ActionSelectProductionCardsRequest struct {
Type ActionType `json:"type"`
CardIDs []string `json:"cardIds"`
}
// GetAction returns the select starting card action
func (ap *ActionSelectStartingCardRequest) GetAction() *SelectStartingCardAction {
return &SelectStartingCardAction{Type: ap.Type, CardIDs: ap.CardIDs, CorporationID: ap.CorporationID}
}
// ActionStartGameRequest contains the action data for start game actions
type ActionStartGameRequest struct {
Type ActionType `json:"type"`
}
// GetAction returns the start game action
func (ap *ActionStartGameRequest) GetAction() *StartGameAction {
return &StartGameAction{Type: ap.Type}
}
// ActionSkipActionRequest contains the action data for skip action actions
type ActionSkipActionRequest struct {
Type ActionType `json:"type"`
}
// GetAction returns the skip action action
func (ap *ActionSkipActionRequest) GetAction() *SkipAction {
return &SkipAction{Type: ap.Type}
}
// SelectDemoChoicesRequest contains a player's demo lobby card selections
type SelectDemoChoicesRequest struct {
CorporationID string `json:"corporationId"`
PreludeIDs []string `json:"preludeIds"`
CardIDs []string `json:"cardIds"`
Resources ResourcesDto `json:"resources"`
Production ProductionDto `json:"production"`
TerraformRating int `json:"terraformRating"`
GlobalParameters *GlobalParametersDto `json:"globalParameters,omitempty"` // Host only
Generation *int `json:"generation,omitempty"` // Host only
SelectedMilestones []string `json:"selectedMilestones,omitempty"` // Host only
SelectedAwards []string `json:"selectedAwards,omitempty"` // Host only
}
// ActionPlayCardRequest contains the action data for play card actions
type ActionPlayCardRequest struct {
Type ActionType `json:"type"`
CardID string `json:"cardId"`
Payment CardPaymentDto `json:"payment"` // Required: payment breakdown (credits, steel, titanium)
ChoiceIndex *int `json:"choiceIndex,omitempty"` // Optional: index of choice to play (for cards with choices)
CardStorageTargets []string `json:"cardStorageTargets,omitempty"` // Optional: target card IDs for resource storage (positional, one per any-card output)
}
// GetAction returns the play card action
func (ap *ActionPlayCardRequest) GetAction() *PlayCardAction {
return &PlayCardAction{CardID: ap.CardID, Payment: ap.Payment, ChoiceIndex: ap.ChoiceIndex, CardStorageTargets: ap.CardStorageTargets}
}
// ActionPlayCardActionRequest contains the action data for play card action actions
type ActionPlayCardActionRequest struct {
Type ActionType `json:"type"`
CardID string `json:"cardId"`
BehaviorIndex int `json:"behaviorIndex"`
ChoiceIndex *int `json:"choiceIndex,omitempty"` // Optional: index of choice to play (for actions with choices)
CardStorageTargets []string `json:"cardStorageTargets,omitempty"` // Optional: target card IDs for resource storage (positional, one per any-card output)
}
// GetAction returns the play card action action
func (ap *ActionPlayCardActionRequest) GetAction() *PlayCardActionAction {
return &PlayCardActionAction{CardID: ap.CardID, BehaviorIndex: ap.BehaviorIndex, ChoiceIndex: ap.ChoiceIndex, CardStorageTargets: ap.CardStorageTargets}
}
// Standard Project Action Requests
// ActionSellPatentsRequest contains the action data for sell patents actions (initiates card selection)
type ActionSellPatentsRequest struct {
Type ActionType `json:"type"`
}
// GetAction returns the sell patents action
func (ap *ActionSellPatentsRequest) GetAction() *SellPatentsAction {
return &SellPatentsAction{Type: ap.Type}
}
// ActionBuildPowerPlantRequest contains the action data for build power plant actions
type ActionBuildPowerPlantRequest struct {
Type ActionType `json:"type"`
}
// GetAction returns the build power plant action
func (ap *ActionBuildPowerPlantRequest) GetAction() *BuildPowerPlantAction {
return &BuildPowerPlantAction{Type: ap.Type}
}
// ActionLaunchAsteroidRequest contains the action data for launch asteroid actions
type ActionLaunchAsteroidRequest struct {
Type ActionType `json:"type"`
}
// GetAction returns the launch asteroid action
func (ap *ActionLaunchAsteroidRequest) GetAction() *LaunchAsteroidAction {
return &LaunchAsteroidAction{Type: ap.Type}
}
// ActionBuildAquiferRequest contains the action data for build aquifer actions
type ActionBuildAquiferRequest struct {
Type ActionType `json:"type"`
HexPosition HexPositionDto `json:"hexPosition"`
}
// GetAction returns the build aquifer action
func (ap *ActionBuildAquiferRequest) GetAction() *BuildAquiferAction {
return &BuildAquiferAction{Type: ap.Type, HexPosition: ap.HexPosition}
}
// ActionPlantGreeneryRequest contains the action data for plant greenery actions
type ActionPlantGreeneryRequest struct {
Type ActionType `json:"type"`
HexPosition HexPositionDto `json:"hexPosition"`
}
// GetAction returns the plant greenery action
func (ap *ActionPlantGreeneryRequest) GetAction() *PlantGreeneryAction {
return &PlantGreeneryAction{Type: ap.Type, HexPosition: ap.HexPosition}
}
// ActionBuildCityRequest contains the action data for build city actions
type ActionBuildCityRequest struct {
Type ActionType `json:"type"`
HexPosition HexPositionDto `json:"hexPosition"`
}
// GetAction returns the build city action
func (ap *ActionBuildCityRequest) GetAction() *BuildCityAction {
return &BuildCityAction{Type: ap.Type, HexPosition: ap.HexPosition}
}
// ActionConvertPlantsToGreeneryRequest contains the action data for initiating plant conversion
type ActionConvertPlantsToGreeneryRequest struct {
Type ActionType `json:"type"`
StorageSubstitutes map[string]int `json:"storageSubstitutes,omitempty"`
}
// GetAction returns the convert plants to greenery action
func (ap *ActionConvertPlantsToGreeneryRequest) GetAction() *ConvertPlantsToGreeneryAction {
return &ConvertPlantsToGreeneryAction{Type: ap.Type, StorageSubstitutes: ap.StorageSubstitutes}
}
// ActionConvertHeatToTemperatureRequest contains the action data for converting heat to temperature
type ActionConvertHeatToTemperatureRequest struct {
Type ActionType `json:"type"`
StorageSubstitutes map[string]int `json:"storageSubstitutes,omitempty"`
}
// GetAction returns the convert heat to temperature action
func (ap *ActionConvertHeatToTemperatureRequest) GetAction() *ConvertHeatToTemperatureAction {
return &ConvertHeatToTemperatureAction{Type: ap.Type, StorageSubstitutes: ap.StorageSubstitutes}
}
// ConvertPlantsToGreeneryAction represents converting 8 plants to a greenery tile
type ConvertPlantsToGreeneryAction struct {
Type ActionType `json:"type"`
StorageSubstitutes map[string]int `json:"storageSubstitutes,omitempty"`
}
// ConvertHeatToTemperatureAction represents converting 8 heat to raise temperature
type ConvertHeatToTemperatureAction struct {
Type ActionType `json:"type"`
StorageSubstitutes map[string]int `json:"storageSubstitutes,omitempty"`
}
// Admin Command Types (Development Mode Only)
// AdminCommandType represents different types of admin commands
type AdminCommandType string
const (
AdminCommandTypeGiveCard AdminCommandType = "give-card"
AdminCommandTypeSetPhase AdminCommandType = "set-phase"
AdminCommandTypeSetResources AdminCommandType = "set-resources"
AdminCommandTypeSetProduction AdminCommandType = "set-production"
AdminCommandTypeSetGlobalParams AdminCommandType = "set-global-params"
AdminCommandTypeStartTileSelection AdminCommandType = "start-tile-selection"
AdminCommandTypeSetCurrentTurn AdminCommandType = "set-current-turn"
AdminCommandTypeSetCorporation AdminCommandType = "set-corporation"
AdminCommandTypeSetTR AdminCommandType = "set-tr"
)
// AdminCommandRequest contains the admin command data
type AdminCommandRequest struct {
CommandType AdminCommandType `json:"commandType"`
Payload interface{} `json:"payload"`
}
// GiveCardAdminCommand represents giving a card to a player
type GiveCardAdminCommand struct {
PlayerID string `json:"playerId"`
CardID string `json:"cardId"`
}
// SetPhaseAdminCommand represents setting the game phase
type SetPhaseAdminCommand struct {
Phase string `json:"phase"`
}
// SetResourcesAdminCommand represents setting a player's resources
type SetResourcesAdminCommand struct {
PlayerID string `json:"playerId"`
Resources ResourcesDto `json:"resources"`
}
// SetProductionAdminCommand represents setting a player's production
type SetProductionAdminCommand struct {
PlayerID string `json:"playerId"`
Production ProductionDto `json:"production"`
}
// SetGlobalParamsAdminCommand represents setting global parameters
type SetGlobalParamsAdminCommand struct {
GlobalParameters GlobalParametersDto `json:"globalParameters"`
}
// StartTileSelectionAdminCommand represents starting tile selection for testing
type StartTileSelectionAdminCommand struct {
PlayerID string `json:"playerId"`
TileType string `json:"tileType"`
}
// SetCorporationAdminCommand represents setting a player's corporation
type SetCorporationAdminCommand struct {
PlayerID string `json:"playerId"`
CorporationID string `json:"corporationId"`
}
// SetTRAdminCommand represents setting a player's terraform rating
type SetTRAdminCommand struct {
PlayerID string `json:"playerId"`
TerraformRating int `json:"terraformRating"`
}
// CardPaymentDto represents how a player is paying for a card
type CardPaymentDto struct {
Credits int `json:"credits"` // MC spent
Steel int `json:"steel"` // Steel resources used (2 MC value each)
Titanium int `json:"titanium"` // Titanium resources used (3 MC value each)
Substitutes map[string]int `json:"substitutes,omitempty"` // Payment substitutes (e.g., heat for Helion)
StorageSubstitutes map[string]int `json:"storageSubstitutes,omitempty"` // Storage payment substitutes (e.g., floaters from Dirigibles)
}
package dto
// GamePhase represents the current phase of the game
type GamePhase string
const (
GamePhaseWaitingForGameStart GamePhase = "waiting_for_game_start"
GamePhaseStartingSelection GamePhase = "starting_selection"
GamePhaseStartGameSelection GamePhase = "start_game_selection"
GamePhaseInitApplyCorp GamePhase = "init_apply_corp"
GamePhaseInitApplyPrelude GamePhase = "init_apply_prelude"
GamePhaseAction GamePhase = "action"
GamePhaseProductionAndCardDraw GamePhase = "production_and_card_draw"
GamePhaseFinalPhase GamePhase = "final_phase"
GamePhaseComplete GamePhase = "complete"
)
// GameStatus represents the current status of the game
type GameStatus string
const (
GameStatusLobby GameStatus = "lobby"
GameStatusActive GameStatus = "active"
GameStatusCompleted GameStatus = "completed"
)
// CardType represents different types of cards
type CardType string
const (
CardTypeAutomated CardType = "automated"
CardTypeActive CardType = "active"
CardTypeEvent CardType = "event"
CardTypeCorporation CardType = "corporation"
CardTypePrelude CardType = "prelude"
)
// StandardProject represents the different types of standard projects
type StandardProject string
const (
StandardProjectSellPatents StandardProject = "sell-patents"
StandardProjectPowerPlant StandardProject = "power-plant"
StandardProjectAsteroid StandardProject = "asteroid"
StandardProjectAquifer StandardProject = "aquifer"
StandardProjectGreenery StandardProject = "greenery"
StandardProjectCity StandardProject = "city"
StandardProjectConvertPlantsToGreenery StandardProject = "convert-plants-to-greenery"
StandardProjectConvertHeatToTemperature StandardProject = "convert-heat-to-temperature"
)
// CardTag represents different card categories and attributes
type CardTag string
const (
TagSpace CardTag = "space"
TagEarth CardTag = "earth"
TagScience CardTag = "science"
TagPower CardTag = "power"
TagBuilding CardTag = "building"
TagMicrobe CardTag = "microbe"
TagAnimal CardTag = "animal"
TagPlant CardTag = "plant"
TagEvent CardTag = "event"
TagCity CardTag = "city"
TagVenus CardTag = "venus"
TagJovian CardTag = "jovian"
TagWildlife CardTag = "wildlife"
TagWild CardTag = "wild"
)
// ResourceType represents different types of resources for client consumption
// This is a 1:1 mapping from types.ResourceType
type ResourceType string
const (
ResourceTypeCredit ResourceType = "credit"
ResourceTypeSteel ResourceType = "steel"
ResourceTypeTitanium ResourceType = "titanium"
ResourceTypePlant ResourceType = "plant"
ResourceTypeEnergy ResourceType = "energy"
ResourceTypeHeat ResourceType = "heat"
ResourceTypeMicrobe ResourceType = "microbe"
ResourceTypeAnimal ResourceType = "animal"
ResourceTypeFloater ResourceType = "floater"
ResourceTypeScience ResourceType = "science"
ResourceTypeAsteroid ResourceType = "asteroid"
ResourceTypeFighter ResourceType = "fighter"
ResourceTypeDisease ResourceType = "disease"
ResourceTypeCardDraw ResourceType = "card-draw"
ResourceTypeCardTake ResourceType = "card-take"
ResourceTypeCardPeek ResourceType = "card-peek"
ResourceTypeCityPlacement ResourceType = "city-placement"
ResourceTypeOceanPlacement ResourceType = "ocean-placement"
ResourceTypeGreeneryPlacement ResourceType = "greenery-placement"
ResourceTypeCityTile ResourceType = "city-tile"
ResourceTypeOceanTile ResourceType = "ocean-tile"
ResourceTypeGreeneryTile ResourceType = "greenery-tile"
ResourceTypeColony ResourceType = "colony"
ResourceTypeTemperature ResourceType = "temperature"
ResourceTypeOxygen ResourceType = "oxygen"
ResourceTypeVenus ResourceType = "venus"
ResourceTypeTR ResourceType = "tr"
ResourceTypeCreditProduction ResourceType = "credit-production"
ResourceTypeSteelProduction ResourceType = "steel-production"
ResourceTypeTitaniumProduction ResourceType = "titanium-production"
ResourceTypePlantProduction ResourceType = "plant-production"
ResourceTypeEnergyProduction ResourceType = "energy-production"
ResourceTypeHeatProduction ResourceType = "heat-production"
ResourceTypeEffect ResourceType = "effect"
ResourceTypeTag ResourceType = "tag"
ResourceTypeGlobalParameterLenience ResourceType = "global-parameter-lenience"
ResourceTypeDefense ResourceType = "defense"
ResourceTypeDiscount ResourceType = "discount"
ResourceTypeValueModifier ResourceType = "value-modifier"
)
// TargetType represents different targeting scopes for resource conditions for client consumption
type TargetType string
const (
TargetSelfPlayer TargetType = "self-player"
TargetSelfCard TargetType = "self-card"
TargetAnyCard TargetType = "any-card"
TargetAnyPlayer TargetType = "any-player"
TargetOpponent TargetType = "opponent"
TargetNone TargetType = "none"
)
// CardApplyLocation represents different locations where card conditions can be evaluated for client consumption
type CardApplyLocation string
const (
CardApplyLocationAnywhere CardApplyLocation = "anywhere"
CardApplyLocationMars CardApplyLocation = "mars"
)
// RequirementType represents different card requirement types for client consumption
type RequirementType string
const (
RequirementTemperature RequirementType = "temperature"
RequirementOxygen RequirementType = "oxygen"
RequirementOceans RequirementType = "oceans"
RequirementVenus RequirementType = "venus"
RequirementCities RequirementType = "cities"
RequirementGreeneries RequirementType = "greeneries"
RequirementTags RequirementType = "tags"
RequirementProduction RequirementType = "production"
RequirementTR RequirementType = "tr"
RequirementResource RequirementType = "resource"
)
// VPConditionType represents different types of VP conditions for client consumption
type VPConditionType string
const (
VPConditionFixed VPConditionType = "fixed"
VPConditionPer VPConditionType = "per"
VPConditionResourcesOn VPConditionType = "resources-on"
)
// TriggerType represents different trigger conditions for client consumption
type TriggerType string
const (
TriggerOceanPlaced TriggerType = "ocean-placed"
TriggerGlobalParameterRaised TriggerType = "global-parameter-raised"
TriggerCityPlaced TriggerType = "city-placed"
TriggerCardPlayed TriggerType = "card-played"
TriggerTagPlayed TriggerType = "tag-played"
TriggerTilePlaced TriggerType = "tile-placed"
)
// ResourceTriggerType represents different trigger types for resource exchanges for client consumption
type ResourceTriggerType string
const (
ResourceTriggerManual ResourceTriggerType = "manual"
ResourceTriggerAuto ResourceTriggerType = "auto"
ResourceTriggerAutoCorporationFirstAction ResourceTriggerType = "auto-corporation-first-action"
ResourceTriggerAutoCorporationStart ResourceTriggerType = "auto-corporation-start"
)
// ResourceSet represents a collection of resources and their amounts
type ResourceSet struct {
Credits int `json:"credits"`
Steel int `json:"steel"`
Titanium int `json:"titanium"`
Plants int `json:"plants"`
Energy int `json:"energy"`
Heat int `json:"heat"`
}
// TileRestrictionsDto represents tile placement restrictions for client consumption
type TileRestrictionsDto struct {
BoardTags []string `json:"boardTags,omitempty"`
Adjacency string `json:"adjacency,omitempty"` // "none" = no adjacent occupied tiles
OnTileType string `json:"onTileType,omitempty"` // "ocean" = only on ocean spaces
AdjacentToType string `json:"adjacentToType,omitempty"` // "city", "greenery" = must be adjacent to this tile type
MinAdjacentOfType *int `json:"minAdjacentOfType,omitempty"` // min count of adjacent tiles of AdjacentToType
AdjacentToOwned *bool `json:"adjacentToOwned,omitempty"` // must be adjacent to a tile owned by the placing player
OnBonusType []string `json:"onBonusType,omitempty"` // tile must have one of these bonus types
}
// TargetRestrictionDto represents restrictions on target player selection
type TargetRestrictionDto struct {
Adjacent string `json:"adjacent,omitempty"`
}
// SelectorDto represents matching criteria for cards, resources, or projects.
// Multiple fields within a Selector use AND logic (all must match).
// Multiple Selectors in a slice use OR logic (any match is sufficient).
type SelectorDto struct {
Tags []CardTag `json:"tags,omitempty"`
CardTypes []CardType `json:"cardTypes,omitempty"`
Resources []string `json:"resources,omitempty"`
StandardProjects []StandardProject `json:"standardProjects,omitempty"`
RequiredOriginalCost *MinMaxValueDto `json:"requiredOriginalCost,omitempty"`
VP *MinMaxValueDto `json:"vp,omitempty"`
GlobalParameters []string `json:"globalParameters,omitempty"`
Actions []string `json:"actions,omitempty"`
}
// BasicResourceConditionDto covers credit, steel, titanium, plant, energy, heat.
//
//tygo:emit export type ResourceCondition = BasicResourceConditionDto | ProductionConditionDto | TilePlacementConditionDto | GlobalParameterConditionDto | CardOperationConditionDto | CardStorageConditionDto | EffectConditionDto | ColonyConditionDto | TileModificationConditionDto | MiscConditionDto;
type BasicResourceConditionDto struct {
Type string `json:"type" tstype:"'credit' | 'steel' | 'titanium' | 'plant' | 'energy' | 'heat'"`
Amount int `json:"amount"`
Target TargetType `json:"target"`
Per *PerConditionDto `json:"per,omitempty"`
VariableAmount *bool `json:"variableAmount,omitempty"`
TargetRestriction *TargetRestrictionDto `json:"targetRestriction,omitempty"`
MaxTrigger *int `json:"maxTrigger,omitempty"`
}
func (d BasicResourceConditionDto) GetConditionType() string { return d.Type }
func (d BasicResourceConditionDto) GetConditionAmount() int { return d.Amount }
// ProductionConditionDto covers all production types.
type ProductionConditionDto struct {
Type string `json:"type" tstype:"'credit-production' | 'steel-production' | 'titanium-production' | 'plant-production' | 'energy-production' | 'heat-production' | 'any-production'"`
Amount int `json:"amount"`
Target TargetType `json:"target"`
Per *PerConditionDto `json:"per,omitempty"`
VariableAmount *bool `json:"variableAmount,omitempty"`
}
func (d ProductionConditionDto) GetConditionType() string { return d.Type }
func (d ProductionConditionDto) GetConditionAmount() int { return d.Amount }
// TilePlacementConditionDto covers tile placements and land claims.
type TilePlacementConditionDto struct {
Type string `json:"type" tstype:"'city-placement' | 'ocean-placement' | 'greenery-placement' | 'volcano-placement' | 'tile-placement' | 'land-claim'"`
Amount int `json:"amount"`
Target TargetType `json:"target"`
TileRestrictions *TileRestrictionsDto `json:"tileRestrictions,omitempty"`
TileType string `json:"tileType,omitempty"`
}
func (d TilePlacementConditionDto) GetConditionType() string { return d.Type }
func (d TilePlacementConditionDto) GetConditionAmount() int { return d.Amount }
// GlobalParameterConditionDto covers temperature, oxygen, ocean, venus, tr, global-parameter.
type GlobalParameterConditionDto struct {
Type string `json:"type" tstype:"'temperature' | 'oxygen' | 'ocean' | 'venus' | 'tr' | 'global-parameter'"`
Amount int `json:"amount"`
Target TargetType `json:"target"`
Per *PerConditionDto `json:"per,omitempty"`
}
func (d GlobalParameterConditionDto) GetConditionType() string { return d.Type }
func (d GlobalParameterConditionDto) GetConditionAmount() int { return d.Amount }
// CardOperationConditionDto covers card-draw, card-take, card-peek, card-buy, card-discard.
type CardOperationConditionDto struct {
Type string `json:"type" tstype:"'card-draw' | 'card-take' | 'card-peek' | 'card-buy' | 'card-discard'"`
Amount int `json:"amount"`
Target TargetType `json:"target"`
Selectors []SelectorDto `json:"selectors,omitempty"`
VariableAmount *bool `json:"variableAmount,omitempty"`
}
func (d CardOperationConditionDto) GetConditionType() string { return d.Type }
func (d CardOperationConditionDto) GetConditionAmount() int { return d.Amount }
// CardStorageConditionDto covers microbe, animal, floater, science, asteroid, fighter, disease, card-resource.
type CardStorageConditionDto struct {
Type string `json:"type" tstype:"'microbe' | 'animal' | 'floater' | 'science' | 'asteroid' | 'fighter' | 'disease' | 'card-resource'"`
Amount int `json:"amount"`
Target TargetType `json:"target"`
Selectors []SelectorDto `json:"selectors,omitempty"`
Per *PerConditionDto `json:"per,omitempty"`
VariableAmount *bool `json:"variableAmount,omitempty"`
}
func (d CardStorageConditionDto) GetConditionType() string { return d.Type }
func (d CardStorageConditionDto) GetConditionAmount() int { return d.Amount }
// EffectConditionDto covers discount, payment-substitute, and other effect types.
type EffectConditionDto struct {
Type string `json:"type" tstype:"'discount' | 'payment-substitute' | 'storage-payment-substitute' | 'value-modifier' | 'global-parameter-lenience' | 'ignore-global-requirements' | 'ocean-adjacency-bonus' | 'defense' | 'action-reuse' | 'effect' | 'tag'"`
Amount int `json:"amount"`
Target TargetType `json:"target"`
Selectors []SelectorDto `json:"selectors,omitempty"`
}
func (d EffectConditionDto) GetConditionType() string { return d.Type }
func (d EffectConditionDto) GetConditionAmount() int { return d.Amount }
// ColonyConditionDto covers colony, colony-count, colony-bonus, colony-track-step.
type ColonyConditionDto struct {
Type string `json:"type" tstype:"'colony' | 'colony-count' | 'colony-bonus' | 'colony-track-step'"`
Amount int `json:"amount"`
Target TargetType `json:"target"`
}
func (d ColonyConditionDto) GetConditionType() string { return d.Type }
func (d ColonyConditionDto) GetConditionAmount() int { return d.Amount }
// TileModificationConditionDto covers tile-destruction and tile-replacement.
type TileModificationConditionDto struct {
Type string `json:"type" tstype:"'tile-destruction' | 'tile-replacement'"`
Amount int `json:"amount"`
Target TargetType `json:"target"`
TileType string `json:"tileType,omitempty"`
}
func (d TileModificationConditionDto) GetConditionType() string { return d.Type }
func (d TileModificationConditionDto) GetConditionAmount() int { return d.Amount }
// MiscConditionDto covers extra-actions, bonus-tags, world-tree-tile, award-fund, trade.
type MiscConditionDto struct {
Type string `json:"type" tstype:"'extra-actions' | 'bonus-tags' | 'world-tree-tile' | 'award-fund' | 'trade'"`
Amount int `json:"amount"`
Target TargetType `json:"target"`
Per *PerConditionDto `json:"per,omitempty"`
Selectors []SelectorDto `json:"selectors,omitempty"`
}
func (d MiscConditionDto) GetConditionType() string { return d.Type }
func (d MiscConditionDto) GetConditionAmount() int { return d.Amount }
// PerConditionDto represents a per condition for client consumption
type PerConditionDto struct {
Type ResourceType `json:"type"`
Amount int `json:"amount"`
Location *CardApplyLocation `json:"location,omitempty"`
Target *TargetType `json:"target,omitempty"`
Tag *CardTag `json:"tag,omitempty"`
AdjacentToSelfTile bool `json:"adjacentToSelfTile"`
}
// ChoiceDto represents a choice for client consumption
type ChoiceDto struct {
OriginalIndex int `json:"originalIndex"`
Inputs []any `json:"inputs,omitempty" tstype:"ResourceCondition[]"`
Outputs []any `json:"outputs,omitempty" tstype:"ResourceCondition[]"`
Requirements *CardRequirementsDto `json:"requirements,omitempty"`
Available bool `json:"available"`
Errors []StateErrorDto `json:"errors"`
}
// TriggerDto represents a trigger for client consumption
type TriggerDto struct {
Type ResourceTriggerType `json:"type"`
Condition *ResourceTriggerConditionDto `json:"condition,omitempty"`
}
// MinMaxValueDto represents a minimum and/or maximum value constraint for client consumption
type MinMaxValueDto struct {
Min *int `json:"min,omitempty"`
Max *int `json:"max,omitempty"`
}
// ResourceTriggerConditionDto represents a resource trigger condition for client consumption
type ResourceTriggerConditionDto struct {
Type TriggerType `json:"type"`
ResourceTypes []ResourceType `json:"resourceTypes,omitempty"`
Location *CardApplyLocation `json:"location,omitempty"`
Selectors []SelectorDto `json:"selectors,omitempty"`
Target *TargetType `json:"target,omitempty"`
RequiredOriginalCost *MinMaxValueDto `json:"requiredOriginalCost,omitempty"`
RequiredResourceChange map[ResourceType]MinMaxValueDto `json:"requiredResourceChange,omitempty"`
OnBonusType []string `json:"onBonusType,omitempty"`
Unique bool `json:"unique,omitempty"`
}
// ChoicePolicySelectDto describes an auto-selection rule for a choice policy
type ChoicePolicySelectDto struct {
Option int `json:"option"`
MinMax MinMaxValueDto `json:"minMax"`
ResourceType string `json:"resourceType"`
Tag *string `json:"tag,omitempty"`
}
// ChoicePolicyDto represents a choice policy for client consumption
type ChoicePolicyDto struct {
Type string `json:"type"`
Default *int `json:"default,omitempty"`
Select *ChoicePolicySelectDto `json:"select,omitempty"`
}
// CardBehaviorDto represents a card behavior for client consumption
type CardBehaviorDto struct {
Description string `json:"description,omitempty"`
Triggers []TriggerDto `json:"triggers,omitempty"`
Inputs []any `json:"inputs,omitempty" tstype:"ResourceCondition[]"`
Outputs []any `json:"outputs,omitempty" tstype:"ResourceCondition[]"`
Choices []ChoiceDto `json:"choices,omitempty"`
ChoicePolicy *ChoicePolicyDto `json:"choicePolicy,omitempty"`
GenerationalEventRequirements []GenerationalEventRequirementDto `json:"generationalEventRequirements,omitempty"`
Group string `json:"group,omitempty"`
}
// PaymentConstantsDto represents payment conversion rates
type PaymentConstantsDto struct {
SteelValue int `json:"steelValue"`
TitaniumValue int `json:"titaniumValue"`
}
// CardRequirementsDto wraps requirement items with a description for client consumption
type CardRequirementsDto struct {
Description string `json:"description,omitempty"`
Items []RequirementDto `json:"items"`
}
// RequirementDto represents a card requirement for client consumption
type RequirementDto struct {
Type RequirementType `json:"type"`
Min *int `json:"min,omitempty"`
Max *int `json:"max,omitempty"`
Location *CardApplyLocation `json:"location,omitempty"`
Tag *CardTag `json:"tag,omitempty"`
Resource *ResourceType `json:"resource,omitempty"`
}
// ResourceStorageDto represents a card's resource storage for client consumption
type ResourceStorageDto struct {
Type ResourceType `json:"type"`
Capacity *int `json:"capacity,omitempty"`
Starting int `json:"starting"`
Description string `json:"description,omitempty"`
}
// VPConditionDto represents a victory point condition for client consumption
type VPConditionDto struct {
Amount int `json:"amount"`
Condition VPConditionType `json:"condition"`
MaxTrigger *int `json:"maxTrigger,omitempty"`
Per *PerConditionDto `json:"per,omitempty"`
Description string `json:"description,omitempty"`
}
// CardDto represents a card for client consumption
type CardDto struct {
ID string `json:"id"`
Name string `json:"name"`
Type CardType `json:"type"`
Cost int `json:"cost"`
Description string `json:"description"`
Pack string `json:"pack"`
Tags []CardTag `json:"tags,omitempty"`
Requirements *CardRequirementsDto `json:"requirements,omitempty"`
Behaviors []CardBehaviorDto `json:"behaviors,omitempty"`
ResourceStorage *ResourceStorageDto `json:"resourceStorage,omitempty"`
VPConditions []VPConditionDto `json:"vpConditions,omitempty"`
StartingResources *ResourceSet `json:"startingResources,omitempty"`
StartingProduction *ResourceSet `json:"startingProduction,omitempty"`
}
// SelectCorporationPhaseDto represents corporation selection state for the current player
type SelectCorporationPhaseDto struct {
AvailableCorporations []CardDto `json:"availableCorporations"`
}
// SelectCorporationOtherPlayerDto represents corporation selection state for other players
type SelectCorporationOtherPlayerDto struct{}
type SelectStartingCardsPhaseDto struct {
AvailableCards []CardDto `json:"availableCards"`
}
type SelectStartingCardsOtherPlayerDto struct{}
// SelectPreludeCardsPhaseDto represents prelude card selection state for the current player
type SelectPreludeCardsPhaseDto struct {
AvailablePreludes []CardDto `json:"availablePreludes"`
MaxSelectable int `json:"maxSelectable"`
}
// SelectPreludeCardsOtherPlayerDto represents prelude card selection state for other players
type SelectPreludeCardsOtherPlayerDto struct {
// Empty - other players don't see selection details
}
// ProductionPhaseDto represents card selection and production phase state for a player
type ProductionPhaseDto struct {
AvailableCards []CardDto `json:"availableCards"` // Cards available for selection
SelectionComplete bool `json:"selectionComplete"` // Whether player completed card selection
BeforeResources ResourcesDto `json:"beforeResources"`
AfterResources ResourcesDto `json:"afterResources"`
ResourceDelta ResourcesDto `json:"resourceDelta"`
EnergyConverted int `json:"energyConverted"`
CreditsIncome int `json:"creditsIncome"`
}
type ProductionPhaseOtherPlayerDto struct {
SelectionComplete bool `json:"selectionComplete"` // Whether player completed card selection
BeforeResources ResourcesDto `json:"beforeResources"`
AfterResources ResourcesDto `json:"afterResources"`
ResourceDelta ResourcesDto `json:"resourceDelta"`
EnergyConverted int `json:"energyConverted"`
CreditsIncome int `json:"creditsIncome"`
}
// GameSettingsDto contains configurable game parameters
type GameSettingsDto struct {
MaxPlayers int `json:"maxPlayers"`
VenusNextEnabled bool `json:"venusNextEnabled"`
DevelopmentMode bool `json:"developmentMode"`
DemoGame bool `json:"demoGame"`
CardPacks []string `json:"cardPacks,omitempty"`
HasClaudeAPIKey bool `json:"hasClaudeApiKey"`
ClaudeModel string `json:"claudeModel,omitempty"`
AvailablePlayerColors []string `json:"availablePlayerColors"`
Temperature *int `json:"temperature,omitempty"`
Oxygen *int `json:"oxygen,omitempty"`
Oceans *int `json:"oceans,omitempty"`
Generation *int `json:"generation,omitempty"`
}
// PendingDemoChoicesDto contains a player's demo lobby card selections
type PendingDemoChoicesDto struct {
CorporationID string `json:"corporationId"`
PreludeIDs []string `json:"preludeIds"`
CardIDs []string `json:"cardIds"`
Resources ResourcesDto `json:"resources"`
Production ProductionDto `json:"production"`
TerraformRating int `json:"terraformRating"`
}
// GlobalParameterBonusDto describes a bonus step on a global parameter track
type GlobalParameterBonusDto struct {
Parameter string `json:"parameter"`
Threshold int `json:"threshold"`
RewardType string `json:"rewardType"`
RewardAmount int `json:"rewardAmount"`
}
// GlobalParametersDto represents the terraforming progress
type GlobalParametersDto struct {
Temperature int `json:"temperature"` // Range: -30 to +8°C
Oxygen int `json:"oxygen"` // Range: 0-14%
Oceans int `json:"oceans"` // Range: 0-9
MaxOceans int `json:"maxOceans"` // Dynamic max, starts at 9
Venus int `json:"venus"` // Range: 0-30%
Bonuses []GlobalParameterBonusDto `json:"bonuses"`
}
// ResourcesDto represents a player's resources
type ResourcesDto struct {
Credits int `json:"credits"`
Steel int `json:"steel"`
Titanium int `json:"titanium"`
Plants int `json:"plants"`
Energy int `json:"energy"`
Heat int `json:"heat"`
}
// ProductionDto represents a player's production values
type ProductionDto struct {
Credits int `json:"credits"`
Steel int `json:"steel"`
Titanium int `json:"titanium"`
Plants int `json:"plants"`
Energy int `json:"energy"`
Heat int `json:"heat"`
}
// PaymentSubstituteDto represents an alternative resource that can be used as payment for credits
type PaymentSubstituteDto struct {
ResourceType ResourceType `json:"resourceType"`
ConversionRate int `json:"conversionRate"`
}
// StoragePaymentSubstituteDto represents card storage resources that can be used as payment
type StoragePaymentSubstituteDto struct {
CardID string `json:"cardId"`
ResourceType ResourceType `json:"resourceType"`
ConversionRate int `json:"conversionRate"`
TargetResource ResourceType `json:"targetResource"`
Selectors []SelectorDto `json:"selectors"`
}
// StateErrorCode represents error codes for entity state validation.
// All codes use kebab-case for consistency with JSON serialization.
type StateErrorCode string
const (
ErrorCodeNotYourTurn StateErrorCode = "not-your-turn"
ErrorCodeWrongPhase StateErrorCode = "wrong-phase"
ErrorCodeInsufficientCredits StateErrorCode = "insufficient-credits"
ErrorCodeInsufficientResources StateErrorCode = "insufficient-resources"
ErrorCodeTooManyResources StateErrorCode = "too-many-resources"
ErrorCodeTemperatureTooLow StateErrorCode = "temperature-too-low"
ErrorCodeTemperatureTooHigh StateErrorCode = "temperature-too-high"
ErrorCodeOxygenTooLow StateErrorCode = "oxygen-too-low"
ErrorCodeOxygenTooHigh StateErrorCode = "oxygen-too-high"
ErrorCodeOceansTooLow StateErrorCode = "oceans-too-low"
ErrorCodeOceansTooHigh StateErrorCode = "oceans-too-high"
ErrorCodeTRTooLow StateErrorCode = "tr-too-low"
ErrorCodeTRTooHigh StateErrorCode = "tr-too-high"
ErrorCodeInsufficientTags StateErrorCode = "insufficient-tags"
ErrorCodeTooManyTags StateErrorCode = "too-many-tags"
ErrorCodeInsufficientProduction StateErrorCode = "insufficient-production"
ErrorCodeNoOceanTiles StateErrorCode = "no-ocean-tiles"
ErrorCodeNoCityPlacements StateErrorCode = "no-city-placements"
ErrorCodeNoGreeneryPlacements StateErrorCode = "no-greenery-placements"
ErrorCodeNoCardsInHand StateErrorCode = "no-cards-in-hand"
ErrorCodeInvalidProjectType StateErrorCode = "invalid-project-type"
ErrorCodeInvalidRequirement StateErrorCode = "invalid-requirement"
ErrorCodeInvalidCardType StateErrorCode = "invalid-card-type"
)
// StateErrorCategory represents categories for error grouping.
// Categories enable UI filtering and display organization.
type StateErrorCategory string
const (
ErrorCategoryTurn StateErrorCategory = "turn"
ErrorCategoryPhase StateErrorCategory = "phase"
ErrorCategoryCost StateErrorCategory = "cost"
ErrorCategoryInput StateErrorCategory = "input"
ErrorCategoryRequirement StateErrorCategory = "requirement"
ErrorCategoryAvailability StateErrorCategory = "availability"
ErrorCategoryConfiguration StateErrorCategory = "configuration"
ErrorCategoryInternal StateErrorCategory = "internal"
)
// StateErrorDto represents a specific reason why an entity (card, action, project) is unavailable
// Part of the Player-Scoped Card Architecture for rich error information
type StateErrorDto struct {
Code StateErrorCode `json:"code"` // Error code (e.g., ErrorCodeInsufficientCredits)
Category StateErrorCategory `json:"category"` // Error category (e.g., ErrorCategoryCost)
Message string `json:"message"` // Human-readable error message
}
// StateWarningCode represents warning codes for entity state validation.
// All codes use kebab-case for consistency with JSON serialization.
type StateWarningCode string
// StateWarningDto represents a non-blocking warning about an action
// Warnings inform the player of potential issues without preventing the action
type StateWarningDto struct {
Code StateWarningCode `json:"code"`
Message string `json:"message"`
}
// PlayerCardDto represents a card in a player's hand with calculated playability state
// Part of the Player-Scoped Card Architecture
type PlayerCardDto struct {
ID string `json:"id"`
Name string `json:"name"`
Type CardType `json:"type"`
Cost int `json:"cost"` // Original card cost (same as CardDto.Cost)
Description string `json:"description"`
Pack string `json:"pack"`
Tags []CardTag `json:"tags,omitempty"`
Requirements *CardRequirementsDto `json:"requirements,omitempty"`
Behaviors []CardBehaviorDto `json:"behaviors,omitempty"`
ResourceStorage *ResourceStorageDto `json:"resourceStorage,omitempty"`
VPConditions []VPConditionDto `json:"vpConditions,omitempty"`
Available bool `json:"available"` // Computed: len(Errors) == 0
Errors []StateErrorDto `json:"errors"` // Single source of truth for availability
Warnings []StateWarningDto `json:"warnings,omitempty"` // Non-blocking warnings
EffectiveCost int `json:"effectiveCost"` // Effective cost after discounts (credits)
Discounts map[string]int `json:"discounts,omitempty"` // Discount amounts per resource type (if any)
ComputedValues []ComputedBehaviorValueDto `json:"computedValues,omitempty"` // Pre-computed per-condition values
}
// PlayerEffectDto represents ongoing effects that a player has active for client consumption
// Aligned with PlayerActionDto structure for consistent behavior handling
type PlayerEffectDto struct {
CardID string `json:"cardId"` // ID of the card that provides this effect
CardName string `json:"cardName"` // Name of the card for display purposes
BehaviorIndex int `json:"behaviorIndex"` // Which behavior on the card this effect represents
Behavior CardBehaviorDto `json:"behavior"` // The actual behavior definition with inputs/outputs
ComputedValues []ComputedBehaviorValueDto `json:"computedValues,omitempty"` // Pre-computed per-condition values
}
// PlayerActionDto represents an action that a player can take for client consumption
// Enhanced with calculated usability state from Player-Scoped Card Architecture
type PlayerActionDto struct {
CardID string `json:"cardId"` // ID of the card that provides this action
CardName string `json:"cardName"` // Name of the card for display purposes
BehaviorIndex int `json:"behaviorIndex"` // Which behavior on the card this action represents
Behavior CardBehaviorDto `json:"behavior"` // The actual behavior definition with inputs/outputs
TimesUsedThisTurn int `json:"timesUsedThisTurn"` // Times used this turn
TimesUsedThisGeneration int `json:"timesUsedThisGeneration"` // Times used this generation
Available bool `json:"available"` // Computed: action is usable
Errors []StateErrorDto `json:"errors"` // Reasons why action is not usable
Warnings []StateWarningDto `json:"warnings,omitempty"` // Non-blocking warnings
ComputedValues []ComputedBehaviorValueDto `json:"computedValues,omitempty"` // Pre-computed per-condition values
}
// PlayerStandardProjectDto represents a standard project with availability state
type PlayerStandardProjectDto struct {
ProjectType string `json:"projectType"`
Name string `json:"name"`
Description string `json:"description"`
Behaviors []CardBehaviorDto `json:"behaviors"`
Style *StyleDto `json:"style,omitempty"`
BaseCost map[string]int `json:"baseCost"`
Available bool `json:"available"`
Errors []StateErrorDto `json:"errors"`
Warnings []StateWarningDto `json:"warnings,omitempty"`
EffectiveCost map[string]int `json:"effectiveCost"`
Discounts map[string]int `json:"discounts,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// StyleDto provides visual hints for the frontend
type StyleDto struct {
Color string `json:"color"`
Icon string `json:"icon"`
}
// ForcedFirstActionDto represents an action that must be completed as the player's first turn action
type ForcedFirstActionDto struct {
ActionType string `json:"actionType"` // Type of action: "city_placement", "card_draw", etc.
CorporationID string `json:"corporationId"` // Corporation that requires this action
Completed bool `json:"completed"` // Whether the forced action has been completed
Description string `json:"description"` // Human-readable description for UI
}
// PendingTileSelectionDto represents a pending tile placement action for client consumption
type PendingTileSelectionDto struct {
TileType string `json:"tileType"` // "city", "greenery", "ocean"
AvailableHexes []string `json:"availableHexes"` // Backend-calculated valid hex coordinates
Source string `json:"source"` // What triggered this selection (card ID, standard project, etc.)
}
// PendingCardSelectionDto represents a pending card selection action (e.g., sell patents, card effects)
type PendingCardSelectionDto struct {
AvailableCards []PlayerCardDto `json:"availableCards"` // Cards with playability state
CardCosts map[string]int `json:"cardCosts"` // Card ID -> cost to select (0 for sell patents, 3 for buying cards)
CardRewards map[string]int `json:"cardRewards"` // Card ID -> reward for selecting (1 MC for sell patents)
Source string `json:"source"` // What triggered this selection ("sell-patents", card ID, etc.)
MinCards int `json:"minCards"` // Minimum cards to select (0 for sell patents)
MaxCards int `json:"maxCards"` // Maximum cards to select (hand size for sell patents)
}
// PendingCardDrawSelectionDto represents a pending card draw/peek/take/buy action from card effects
type PendingCardDrawSelectionDto struct {
AvailableCards []PlayerCardDto `json:"availableCards"` // Cards with playability state
FreeTakeCount int `json:"freeTakeCount"` // Number of cards to take for free (mandatory for card-draw, 0 = optional)
MaxBuyCount int `json:"maxBuyCount"` // Maximum cards to buy (optional, 0 = no buying allowed)
CardBuyCost int `json:"cardBuyCost"` // Cost per card when buying (typically 3 MC, 0 if no buying)
Source string `json:"source"` // Card ID or action that triggered this
PlayAsPrelude bool `json:"playAsPrelude"` // When true, selected card is played as prelude
}
// PendingCardDiscardSelectionDto represents a pending card discard action from card effects
type PendingCardDiscardSelectionDto struct {
MinCards int `json:"minCards"` // 0 if optional (player can skip)
MaxCards int `json:"maxCards"` // Maximum cards to discard
Source string `json:"source"` // Card name that triggered this
SourceCardID string `json:"sourceCardId"` // Card ID that triggered this
}
// PendingBehaviorChoiceSelectionDto represents a pending behavior choice from a passive triggered effect
type PendingBehaviorChoiceSelectionDto struct {
Choices []ChoiceDto `json:"choices"`
Source string `json:"source"`
SourceCardID string `json:"sourceCardId"`
}
// PendingStealTargetSelectionDto represents a pending steal target selection after tile placement
type PendingStealTargetSelectionDto struct {
EligiblePlayerIDs []string `json:"eligiblePlayerIds"`
ResourceType string `json:"resourceType"`
Amount int `json:"amount"`
Source string `json:"source"`
SourceCardID string `json:"sourceCardId"`
}
// PendingColonyResourceSelectionDto represents a pending card storage selection for colony resources
// ColonyResourceReason represents why a colony resource selection is pending
type ColonyResourceReason string
const (
ColonyResourceReasonTrade ColonyResourceReason = "trade"
ColonyResourceReasonColonyTax ColonyResourceReason = "colony-tax"
ColonyResourceReasonBuild ColonyResourceReason = "build"
ColonyResourceReasonColonyBonus ColonyResourceReason = "colony-bonus"
)
// PendingColonyResourceSelectionDto represents a pending card storage selection for colony resources
type PendingColonyResourceSelectionDto struct {
ResourceType string `json:"resourceType"`
Amount int `json:"amount"`
Source string `json:"source"`
ColonyID string `json:"colonyId"`
Reason ColonyResourceReason `json:"reason"`
}
// PendingAwardFundSelectionDto represents a pending award fund selection for client consumption
type PendingAwardFundSelectionDto struct {
AvailableAwards []string `json:"availableAwards"`
Source string `json:"source"`
}
// PendingColonySelectionDto represents a pending colony selection from a card effect
type PendingColonySelectionDto struct {
AvailableColonyIDs []string `json:"availableColonyIds"`
AllowDuplicatePlayerColony bool `json:"allowDuplicatePlayerColony"`
Source string `json:"source"`
SourceCardID string `json:"sourceCardId"`
}
// PendingFreeTradeSelectionDto represents a pending free trade colony selection
type PendingFreeTradeSelectionDto struct {
AvailableColonyIDs []string `json:"availableColonyIds"`
Source string `json:"source"`
SourceCardID string `json:"sourceCardId"`
}
// PlayerStatus represents the current status of a player in the game
type PlayerStatus string
const (
PlayerStatusSelectingStartingCards PlayerStatus = "selecting-starting-cards"
PlayerStatusSelectingProductionCards PlayerStatus = "selecting-production-cards"
PlayerStatusWaiting PlayerStatus = "waiting"
PlayerStatusActive PlayerStatus = "active"
PlayerStatusSelection PlayerStatus = "selection"
PlayerStatusTile PlayerStatus = "tile"
PlayerStatusExited PlayerStatus = "exited"
)
// PlayerDto represents a player in the game for client consumption
type PlayerDto struct {
ID string `json:"id"`
Name string `json:"name"`
PlayerType string `json:"playerType"`
BotStatus string `json:"botStatus,omitempty"`
BotDifficulty string `json:"botDifficulty,omitempty"`
BotSpeed string `json:"botSpeed,omitempty"`
Color string `json:"color"`
Status PlayerStatus `json:"status"`
Corporation *CardDto `json:"corporation"`
Cards []PlayerCardDto `json:"cards"` // Hand cards with playability state (Player-Scoped Architecture)
Resources ResourcesDto `json:"resources"`
Production ProductionDto `json:"production"`
TerraformRating int `json:"terraformRating"`
PlayedCards []CardDto `json:"playedCards"` // Full card details for all played cards
Passed bool `json:"passed"`
AvailableActions int `json:"availableActions"`
TotalActions int `json:"totalActions"`
IsConnected bool `json:"isConnected"`
IsExited bool `json:"isExited"`
Effects []PlayerEffectDto `json:"effects"` // Active ongoing effects (discounts, special abilities, etc.)
Actions []PlayerActionDto `json:"actions"` // Available actions from played cards with manual triggers
StandardProjects []PlayerStandardProjectDto `json:"standardProjects"` // Standard projects with availability state (Player-Scoped Architecture)
Milestones []PlayerMilestoneDto `json:"milestones"` // Milestones with player eligibility state
Awards []PlayerAwardDto `json:"awards"` // Awards with player eligibility state
DemoReady bool `json:"demoReady"`
PendingDemoChoices *PendingDemoChoicesDto `json:"pendingDemoChoices,omitempty"`
SelectCorporationPhase *SelectCorporationPhaseDto `json:"selectCorporationPhase"`
SelectStartingCardsPhase *SelectStartingCardsPhaseDto `json:"selectStartingCardsPhase"`
SelectPreludeCardsPhase *SelectPreludeCardsPhaseDto `json:"selectPreludeCardsPhase"`
ProductionPhase *ProductionPhaseDto `json:"productionPhase"`
StartingCards []CardDto `json:"startingCards"`
PendingTileSelection *PendingTileSelectionDto `json:"pendingTileSelection"`
PendingCardSelection *PendingCardSelectionDto `json:"pendingCardSelection"`
PendingCardDrawSelection *PendingCardDrawSelectionDto `json:"pendingCardDrawSelection"`
PendingCardDiscardSelection *PendingCardDiscardSelectionDto `json:"pendingCardDiscardSelection"`
PendingBehaviorChoiceSelection *PendingBehaviorChoiceSelectionDto `json:"pendingBehaviorChoiceSelection"`
PendingStealTargetSelection *PendingStealTargetSelectionDto `json:"pendingStealTargetSelection"`
PendingColonyResourceSelection *PendingColonyResourceSelectionDto `json:"pendingColonyResourceSelection"`
PendingAwardFundSelection *PendingAwardFundSelectionDto `json:"pendingAwardFundSelection"`
PendingColonySelection *PendingColonySelectionDto `json:"pendingColonySelection"`
PendingFreeTradeSelection *PendingFreeTradeSelectionDto `json:"pendingFreeTradeSelection"`
ForcedFirstAction *ForcedFirstActionDto `json:"forcedFirstAction"`
ResourceStorage map[string]int `json:"resourceStorage"`
PaymentSubstitutes []PaymentSubstituteDto `json:"paymentSubstitutes"`
StoragePaymentSubstitutes []StoragePaymentSubstituteDto `json:"storagePaymentSubstitutes"`
GenerationalEvents []PlayerGenerationalEventEntryDto `json:"generationalEvents"`
VPGranters []VPGranterDto `json:"vpGranters"`
BonusTags map[string]int `json:"bonusTags"`
ActionCosts []ActionCostDto `json:"actionCosts"`
}
// ActionCostDto represents the costs for a specific action type (e.g., card-buying, colony-trade)
type ActionCostDto struct {
ActionType string `json:"actionType"`
Costs []ActionCostEntryDto `json:"costs"`
}
// ActionCostEntryDto represents a single resource cost for an action
type ActionCostEntryDto struct {
Resource string `json:"resource"`
BaseCost int `json:"baseCost"`
EffectiveCost int `json:"effectiveCost"`
Discount int `json:"discount"`
}
// OtherPlayerDto represents another player from the viewing player's perspective (limited data)
type OtherPlayerDto struct {
ID string `json:"id"`
Name string `json:"name"`
PlayerType string `json:"playerType"`
BotStatus string `json:"botStatus,omitempty"`
BotDifficulty string `json:"botDifficulty,omitempty"`
BotSpeed string `json:"botSpeed,omitempty"`
Color string `json:"color"`
Status PlayerStatus `json:"status"`
Corporation *CardDto `json:"corporation"`
HandCardCount int `json:"handCardCount"` // Number of cards in hand (private)
Resources ResourcesDto `json:"resources"`
Production ProductionDto `json:"production"`
TerraformRating int `json:"terraformRating"`
PlayedCards []CardDto `json:"playedCards"` // Played cards are public - full card details
Passed bool `json:"passed"`
AvailableActions int `json:"availableActions"`
TotalActions int `json:"totalActions"`
IsConnected bool `json:"isConnected"`
IsExited bool `json:"isExited"`
Effects []PlayerEffectDto `json:"effects"`
Actions []PlayerActionDto `json:"actions"`
DemoReady bool `json:"demoReady"`
SelectCorporationPhase *SelectCorporationOtherPlayerDto `json:"selectCorporationPhase"`
SelectStartingCardsPhase *SelectStartingCardsOtherPlayerDto `json:"selectStartingCardsPhase"`
SelectPreludeCardsPhase *SelectPreludeCardsOtherPlayerDto `json:"selectPreludeCardsPhase"`
ProductionPhase *ProductionPhaseOtherPlayerDto `json:"productionPhase"`
ResourceStorage map[string]int `json:"resourceStorage"`
PaymentSubstitutes []PaymentSubstituteDto `json:"paymentSubstitutes"`
StoragePaymentSubstitutes []StoragePaymentSubstituteDto `json:"storagePaymentSubstitutes"`
VPGranters []VPGranterDto `json:"vpGranters"`
BonusTags map[string]int `json:"bonusTags"`
}
// GameDto represents a game for client consumption (clean architecture)
type GameDto struct {
ID string `json:"id"`
Status GameStatus `json:"status"`
Settings GameSettingsDto `json:"settings"`
HostPlayerID string `json:"hostPlayerId"`
CurrentPhase GamePhase `json:"currentPhase"`
GlobalParameters GlobalParametersDto `json:"globalParameters"`
CurrentPlayer PlayerDto `json:"currentPlayer"` // Viewing player's full data
OtherPlayers []OtherPlayerDto `json:"otherPlayers"` // Other players' limited data
ViewingPlayerID string `json:"viewingPlayerId"` // The player viewing this game state
CurrentTurn *string `json:"currentTurn"` // Whose turn it is (nullable)
Generation int `json:"generation"`
PlayerOrder []string `json:"playerOrder"` // Player IDs in join order
TurnOrder []string `json:"turnOrder"` // Turn order of all players in game
Board BoardDto `json:"board"` // Game board with tiles and occupancy state
PaymentConstants PaymentConstantsDto `json:"paymentConstants"` // Conversion rates for alternative payments
Milestones []MilestoneDto `json:"milestones"` // All milestones with claim status
Awards []AwardDto `json:"awards"` // All awards with funding status
AwardResults []AwardResultDto `json:"awardResults"` // Current award placements (1st/2nd place per award)
FinalScores []FinalScoreDto `json:"finalScores,omitempty"` // Final scores (only when game completed)
TriggeredEffects []TriggeredEffectDto `json:"triggeredEffects,omitempty"` // Recently triggered passive effects
PlaceableTileTypes []PlaceableTileTypeDto `json:"placeableTileTypes"` // Available tile types for the demo tile picker
InitPhase *InitPhaseDto `json:"initPhase,omitempty"`
Spectators []SpectatorDto `json:"spectators"`
ChatMessages []ChatMessageDto `json:"chatMessages"`
IsSpectator bool `json:"isSpectator"`
Colonies []ColonyDto `json:"colonies,omitempty"`
TradeFleetAvailable bool `json:"tradeFleetAvailable"`
TradeFleets map[string]bool `json:"tradeFleets,omitempty"`
ProjectFunding []ProjectFundingDto `json:"projectFunding,omitempty"`
IsLastRound bool `json:"isLastRound"`
}
// SpectatorDto represents a spectator visible to all clients.
type SpectatorDto struct {
ID string `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
}
// ChatMessageDto represents a chat message sent by a player or spectator.
type ChatMessageDto struct {
SenderID string `json:"senderId"`
SenderName string `json:"senderName"`
SenderColor string `json:"senderColor"`
Message string `json:"message"`
Timestamp string `json:"timestamp"`
IsSpectator bool `json:"isSpectator"`
}
// PlaceableTileTypeDto represents a tile type available for placement in the demo tile picker
type PlaceableTileTypeDto struct {
Type string `json:"type"`
Label string `json:"label"`
Group string `json:"group"`
}
// InitPhaseDto represents the state of the init_apply_corp or init_apply_prelude phase
type InitPhaseDto struct {
CurrentPlayerID string `json:"currentPlayerId"`
CurrentPlayerIndex int `json:"currentPlayerIndex"`
TotalPlayers int `json:"totalPlayers"`
WaitingForConfirm bool `json:"waitingForConfirm"`
ConfirmVersion int `json:"confirmVersion"`
HasPreludePhase bool `json:"hasPreludePhase"`
HasPendingTiles bool `json:"hasPendingTiles"`
}
// Colony-related DTOs
// ColonyDto represents a colony in the game
type ColonyDto struct {
ID string `json:"id"`
Name string `json:"name"`
Location string `json:"location"`
Steps []ColonyStepDto `json:"steps"`
ColonyBonus []ColonyOutputDto `json:"colonyBonus"`
Colonies []ColonySlotDto `json:"colonies"`
MarkerPosition int `json:"markerPosition"`
PlayerColonies []string `json:"playerColonies"`
TradedThisGen bool `json:"tradedThisGen"`
TraderID string `json:"traderId"`
Style StyleDto `json:"style"`
TradeStepBonus int `json:"tradeStepBonus"`
TradeAvailable bool `json:"tradeAvailable"`
BuildAvailable bool `json:"buildAvailable"`
TradeErrors []StateErrorDto `json:"tradeErrors"`
BuildErrors []StateErrorDto `json:"buildErrors"`
}
// ColonyStepDto represents one position on the trade track
type ColonyStepDto struct {
Outputs []ColonyOutputDto `json:"outputs"`
}
// ColonyOutputDto represents a resource output from a colony
type ColonyOutputDto struct {
Type string `json:"type"`
Amount int `json:"amount"`
}
// ColonySlotDto represents a colony placement slot
type ColonySlotDto struct {
Reward []ColonyOutputDto `json:"reward"`
}
// Project Funding DTOs
// ProjectFundingDto represents a project funding tile in the game
type ProjectFundingDto struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Seats []ProjectSeatDto `json:"seats"`
SeatOwners []ProjectSeatOwnerDto `json:"seatOwners"`
IsCompleted bool `json:"isCompleted"`
NextSeatIndex int `json:"nextSeatIndex"`
NextSeatCost int `json:"nextSeatCost"`
CanBuySeat bool `json:"canBuySeat"`
BuyErrors []StateErrorDto `json:"buyErrors"`
CurrentPlayerSeats int `json:"currentPlayerSeats"`
CurrentPlayerTier *ProjectRewardTierDto `json:"currentPlayerTier,omitempty"`
PaymentSubstitutes []ProjectPaymentSubDto `json:"paymentSubstitutes"`
RewardTiers []ProjectRewardTierDto `json:"rewardTiers"`
CompletionEffect ProjectCompletionEffectDto `json:"completionEffect"`
Style StyleDto `json:"style"`
}
// ProjectSeatDto represents a seat definition
type ProjectSeatDto struct {
Cost int `json:"cost"`
PaymentSubstitutes []ProjectPaymentSubDto `json:"paymentSubstitutes"`
OwnerID string `json:"ownerId"`
OwnerName string `json:"ownerName"`
OwnerColor string `json:"ownerColor"`
IsFilled bool `json:"isFilled"`
}
// ProjectSeatOwnerDto represents a seat owner entry
type ProjectSeatOwnerDto struct {
PlayerID string `json:"playerId"`
Name string `json:"name"`
Color string `json:"color"`
}
// ProjectRewardTierDto represents a reward tier
type ProjectRewardTierDto struct {
SeatsOwned int `json:"seatsOwned"`
Rewards []ColonyOutputDto `json:"rewards"`
}
// ProjectCompletionEffectDto represents the completion effect
type ProjectCompletionEffectDto struct {
Description string `json:"description"`
Rewards []ColonyOutputDto `json:"rewards"`
GlobalEffects []ProjectGlobalOutputDto `json:"globalEffects,omitempty"`
}
// ProjectGlobalOutputDto represents a one-time game-wide effect on project completion
type ProjectGlobalOutputDto struct {
Type string `json:"type"`
Amount int `json:"amount"`
}
// ProjectPaymentSubDto represents a payment substitute for a seat
type ProjectPaymentSubDto struct {
ResourceType string `json:"resourceType"`
ConversionRate int `json:"conversionRate"`
}
// Board-related DTOs for tygo generation
// TileBonusDto represents a resource bonus provided by a tile when occupied
type TileBonusDto struct {
Type string `json:"type"`
Amount int `json:"amount"`
}
// TileOccupantDto represents what currently occupies a tile
type TileOccupantDto struct {
Type string `json:"type"`
Tags []string `json:"tags"`
}
// TileDto represents a single hexagonal tile on the game board
type TileDto struct {
Coordinates HexPositionDto `json:"coordinates"`
Tags []string `json:"tags"`
Type string `json:"type"`
Location string `json:"location"`
DisplayName *string `json:"displayName,omitempty"`
Bonuses []TileBonusDto `json:"bonuses"`
OccupiedBy *TileOccupantDto `json:"occupiedBy,omitempty"`
OwnerID *string `json:"ownerId,omitempty"`
ReservedBy *string `json:"reservedBy,omitempty"`
}
// BoardDto represents the game board containing all tiles
type BoardDto struct {
Tiles []TileDto `json:"tiles"`
}
// MilestoneDto represents a milestone for client consumption
type MilestoneDto struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
IsClaimed bool `json:"isClaimed"`
ClaimedBy *string `json:"claimedBy"`
ClaimCost int `json:"claimCost"`
Required int `json:"required"`
PlayerProgress map[string]int `json:"playerProgress"`
Reward []AwardRewardDto `json:"rewards"`
Style *StyleDto `json:"style,omitempty"`
}
// AwardDto represents an award for client consumption
type AwardDto struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
IsFunded bool `json:"isFunded"`
FundedBy *string `json:"fundedBy"`
FundingCost int `json:"fundingCost"`
PlayerProgress map[string]int `json:"playerProgress"`
Rewards []AwardRewardDto `json:"rewards"`
Style *StyleDto `json:"style,omitempty"`
}
// AwardRewardDto represents a placement reward
type AwardRewardDto struct {
Place int `json:"place"`
Outputs []any `json:"outputs" tstype:"ResourceCondition[]"`
}
// AwardResultDto represents the placement results for a single funded award
type AwardResultDto struct {
AwardType string `json:"awardType"`
FirstPlaceIds []string `json:"firstPlaceIds"`
SecondPlaceIds []string `json:"secondPlaceIds"`
}
// PlayerMilestoneDto represents a milestone with player-specific eligibility state
type PlayerMilestoneDto struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
ClaimCost int `json:"claimCost"`
IsClaimed bool `json:"isClaimed"`
ClaimedBy *string `json:"claimedBy"`
Available bool `json:"available"` // Can this player claim this milestone?
Progress int `json:"progress"` // Current progress towards requirement
Required int `json:"required"` // Requirement threshold
Errors []StateErrorDto `json:"errors"` // Reasons why not available
Reward []AwardRewardDto `json:"rewards"`
Style *StyleDto `json:"style,omitempty"`
}
// PlayerAwardDto represents an award with player-specific eligibility state
type PlayerAwardDto struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
FundingCost int `json:"fundingCost"` // Current cost to fund (increases as more are funded)
IsFunded bool `json:"isFunded"`
FundedBy *string `json:"fundedBy"`
Available bool `json:"available"` // Can this player fund this award?
Errors []StateErrorDto `json:"errors"` // Reasons why not available
Style *StyleDto `json:"style,omitempty"`
}
// VPGranterConditionDto represents a single VP condition's computed breakdown for client consumption
type VPGranterConditionDto struct {
Amount int `json:"amount"`
ConditionType string `json:"conditionType"`
PerType *string `json:"perType,omitempty"`
PerAmount *int `json:"perAmount,omitempty"`
AdjacentToSelfTile bool `json:"adjacentToSelfTile"`
Count int `json:"count"`
ComputedVP int `json:"computedVP"`
Explanation string `json:"explanation"`
}
// VPGranterDto represents a VP source from a played card or corporation for client consumption
type VPGranterDto struct {
CardID string `json:"cardId"`
CardName string `json:"cardName"`
Description string `json:"description"`
ComputedValue int `json:"computedValue"`
Conditions []VPGranterConditionDto `json:"conditions"`
}
// CardVPConditionDetailDto represents the detailed calculation of a single VP condition
type CardVPConditionDetailDto struct {
ConditionType string `json:"conditionType"` // "fixed", "per", "once"
Amount int `json:"amount"` // VP amount per trigger or fixed amount
Count int `json:"count"` // Items counted (for "per" conditions)
MaxTrigger *int `json:"maxTrigger"`
ActualTriggers int `json:"actualTriggers"` // Actual triggers after applying max
TotalVP int `json:"totalVP"` // Final VP from this condition
Explanation string `json:"explanation"` // Human-readable breakdown
}
// CardVPDetailDto represents VP calculation for a single card
type CardVPDetailDto struct {
CardID string `json:"cardId"`
CardName string `json:"cardName"`
Conditions []CardVPConditionDetailDto `json:"conditions"`
TotalVP int `json:"totalVP"`
}
// GreeneryVPDetailDto represents VP from a single greenery tile
type GreeneryVPDetailDto struct {
Coordinate string `json:"coordinate"` // Format: "q,r,s"
VP int `json:"vp"` // Always 1 per greenery
}
// CityVPDetailDto represents VP from a single city tile and its adjacent greeneries
type CityVPDetailDto struct {
CityCoordinate string `json:"cityCoordinate"` // Format: "q,r,s"
AdjacentGreeneries []string `json:"adjacentGreeneries"` // Coordinates of adjacent greenery tiles
VP int `json:"vp"` // Number of adjacent greeneries
}
// VPBreakdownDto represents a breakdown of victory points for client consumption
type VPBreakdownDto struct {
TerraformRating int `json:"terraformRating"`
CardVP int `json:"cardVP"`
CardVPDetails []CardVPDetailDto `json:"cardVPDetails"` // Per-card VP breakdown
MilestoneVP int `json:"milestoneVP"`
AwardVP int `json:"awardVP"`
GreeneryVP int `json:"greeneryVP"`
GreeneryVPDetails []GreeneryVPDetailDto `json:"greeneryVPDetails"` // Per-greenery VP breakdown
CityVP int `json:"cityVP"`
CityVPDetails []CityVPDetailDto `json:"cityVPDetails"` // Per-city VP breakdown with adjacencies
TotalVP int `json:"totalVP"`
}
// FinalScoreDto represents a player's final score for client consumption
type FinalScoreDto struct {
PlayerID string `json:"playerId"`
PlayerName string `json:"playerName"`
VPBreakdown VPBreakdownDto `json:"vpBreakdown"`
IsWinner bool `json:"isWinner"`
Placement int `json:"placement"`
}
// TriggeredEffectDto represents a card effect that was triggered for client notification
type TriggeredEffectDto struct {
CardName string `json:"cardName"`
PlayerID string `json:"playerId"`
SourceType string `json:"sourceType"`
Outputs []any `json:"outputs" tstype:"ResourceCondition[]"`
CalculatedOutputs []CalculatedOutputDto `json:"calculatedOutputs,omitempty"`
Behaviors []CardBehaviorDto `json:"behaviors,omitempty"`
VPConditions []VPConditionDto `json:"vpConditions,omitempty"`
}
// GenerationalEvent represents events tracked within a generation for conditional card behaviors
type GenerationalEvent string
const (
GenerationalEventTRRaise GenerationalEvent = "tr-raise"
GenerationalEventOceanPlacement GenerationalEvent = "ocean-placement"
GenerationalEventCityPlacement GenerationalEvent = "city-placement"
GenerationalEventGreeneryPlacement GenerationalEvent = "greenery-placement"
)
// PlayerGenerationalEventEntryDto represents a player's tracked generational event for client consumption
type PlayerGenerationalEventEntryDto struct {
Event GenerationalEvent `json:"event"`
Count int `json:"count"`
}
// GenerationalEventRequirementDto represents a requirement based on generational events for card behaviors
type GenerationalEventRequirementDto struct {
Event GenerationalEvent `json:"event"`
Count *MinMaxValueDto `json:"count,omitempty"`
Target *TargetType `json:"target,omitempty"`
}
package dto
import (
"go.uber.org/zap"
"terraforming-mars-backend/internal/cards"
gamecards "terraforming-mars-backend/internal/game/cards"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
"terraforming-mars-backend/internal/logger"
)
// ToCardDto converts a Card to CardDto
func ToCardDto(card gamecards.Card) CardDto {
return CardDto{
ID: card.ID,
Name: card.Name,
Type: CardType(card.Type),
Cost: card.Cost,
Description: card.Description,
Pack: card.Pack,
Tags: mapSlice(card.Tags, func(t shared.CardTag) CardTag { return CardTag(t) }),
Requirements: toCardRequirementsDto(card.Requirements),
Behaviors: mapSlice(card.Behaviors, toCardBehaviorDto),
ResourceStorage: ptrCast(card.ResourceStorage, toResourceStorageDto),
VPConditions: mapSlice(card.VPConditions, toVPConditionDto),
StartingResources: ptrCast(card.StartingResources, toResourceSetDto),
StartingProduction: ptrCast(card.StartingProduction, toResourceSetDto),
}
}
// toResourceSetDto converts shared.ResourceSet to ResourceSet DTO.
func toResourceSetDto(rs shared.ResourceSet) ResourceSet {
return ResourceSet{
Credits: rs.Credits,
Steel: rs.Steel,
Titanium: rs.Titanium,
Plants: rs.Plants,
Energy: rs.Energy,
Heat: rs.Heat,
}
}
// getCorporationCard fetches the corporation card for a player using the card registry
func getCorporationCard(p *player.Player, cardRegistry cards.CardRegistry) *CardDto {
if p.CorporationID() == "" {
return nil
}
card, err := cardRegistry.GetByID(p.CorporationID())
if err != nil {
log := logger.Get()
log.Warn("Failed to fetch corporation card",
zap.String("player_id", p.ID()),
zap.String("corporation_id", p.CorporationID()),
zap.Error(err))
return nil
}
cardDto := ToCardDto(*card)
return &cardDto
}
// getPlayedCards converts a slice of card IDs to CardDto objects using the card registry
func getPlayedCards(cardIDs []string, cardRegistry cards.CardRegistry) []CardDto {
cardDtos := make([]CardDto, 0, len(cardIDs))
log := logger.Get()
for _, cardID := range cardIDs {
card, err := cardRegistry.GetByID(cardID)
if err != nil {
log.Warn("Failed to fetch played card",
zap.String("card_id", cardID),
zap.Error(err))
continue // Skip cards that can't be found
}
cardDtos = append(cardDtos, ToCardDto(*card))
}
return cardDtos
}
// Card-related helper functions for nested DTO conversions
func toCardRequirementsDto(reqs *gamecards.CardRequirements) *CardRequirementsDto {
if reqs == nil {
return nil
}
return &CardRequirementsDto{
Description: reqs.Description,
Items: mapSlice(reqs.Items, toRequirementDto),
}
}
func toRequirementDto(req gamecards.Requirement) RequirementDto {
return RequirementDto{
Type: RequirementType(req.Type),
Min: req.Min,
Max: req.Max,
Location: ptrCast(req.Location, func(l gamecards.CardApplyLocation) CardApplyLocation { return CardApplyLocation(l) }),
Tag: ptrCast(req.Tag, func(t shared.CardTag) CardTag { return CardTag(t) }),
Resource: ptrCast(req.Resource, func(r shared.ResourceType) ResourceType { return ResourceType(r) }),
}
}
func toCardBehaviorDto(behavior shared.CardBehavior) CardBehaviorDto {
return CardBehaviorDto{
Description: behavior.Description,
Triggers: mapSlice(behavior.Triggers, toTriggerDto),
Inputs: mapSlice(behavior.Inputs, toResourceConditionDto),
Outputs: mapSlice(behavior.Outputs, toResourceConditionDto),
Choices: toChoiceDtos(behavior.Choices),
ChoicePolicy: toChoicePolicyDto(behavior.ChoicePolicy),
GenerationalEventRequirements: mapSlice(behavior.GenerationalEventRequirements, toGenerationalEventRequirementDto),
Group: behavior.Group,
}
}
func toChoicePolicyDto(cp *shared.ChoicePolicy) *ChoicePolicyDto {
if cp == nil {
return nil
}
dto := &ChoicePolicyDto{
Type: string(cp.Type),
Default: cp.Default,
}
if cp.Select != nil {
sel := &ChoicePolicySelectDto{
Option: cp.Select.Option,
MinMax: MinMaxValueDto{Min: cp.Select.MinMax.Min, Max: cp.Select.MinMax.Max},
ResourceType: cp.Select.ResourceType,
}
if cp.Select.Tag != nil {
s := string(*cp.Select.Tag)
sel.Tag = &s
}
dto.Select = sel
}
return dto
}
func toGenerationalEventRequirementDto(req shared.GenerationalEventRequirement) GenerationalEventRequirementDto {
var countDto *MinMaxValueDto
if req.Count != nil {
countDto = &MinMaxValueDto{
Min: req.Count.Min,
Max: req.Count.Max,
}
}
return GenerationalEventRequirementDto{
Event: GenerationalEvent(req.Event),
Count: countDto,
Target: ptrCast(req.Target, func(t string) TargetType { return TargetType(t) }),
}
}
func toTriggerDto(trigger shared.Trigger) TriggerDto {
return TriggerDto{
Type: ResourceTriggerType(trigger.Type),
Condition: ptrCast(trigger.Condition, toResourceTriggerConditionDto),
}
}
func toResourceTriggerConditionDto(cond shared.ResourceTriggerCondition) ResourceTriggerConditionDto {
return ResourceTriggerConditionDto{
Type: TriggerType(cond.Type),
ResourceTypes: mapSlice(cond.ResourceTypes, func(rt shared.ResourceType) ResourceType { return ResourceType(rt) }),
Location: ptrCast(cond.Location, func(l string) CardApplyLocation { return CardApplyLocation(l) }),
Selectors: mapSlice(cond.Selectors, toSelectorDto),
Target: ptrCast(cond.Target, func(t string) TargetType { return TargetType(t) }),
RequiredOriginalCost: ptrCast(cond.RequiredOriginalCost, toMinMaxValueDto),
OnBonusType: cond.OnBonusType,
Unique: cond.Unique,
}
}
func toMinMaxValueDto(v shared.MinMaxValue) MinMaxValueDto {
return MinMaxValueDto{
Min: v.Min,
Max: v.Max,
}
}
func toTileRestrictionsDto(tr shared.TileRestrictions) TileRestrictionsDto {
dto := TileRestrictionsDto{
BoardTags: tr.BoardTags,
Adjacency: tr.Adjacency,
OnTileType: tr.OnTileType,
AdjacentToType: tr.AdjacentToType,
MinAdjacentOfType: tr.MinAdjacentOfType,
}
if tr.AdjacentToOwned {
dto.AdjacentToOwned = &tr.AdjacentToOwned
}
dto.OnBonusType = tr.OnBonusType
return dto
}
func toTargetRestrictionDto(tr shared.TargetRestriction) TargetRestrictionDto {
return TargetRestrictionDto{
Adjacent: tr.Adjacent,
}
}
func toSelectorDto(sel shared.Selector) SelectorDto {
return SelectorDto{
Tags: mapSlice(sel.Tags, func(t shared.CardTag) CardTag { return CardTag(t) }),
CardTypes: mapSlice(sel.CardTypes, func(ct string) CardType { return CardType(ct) }),
Resources: sel.Resources,
StandardProjects: mapSlice(sel.StandardProjects, func(sp shared.StandardProject) StandardProject { return StandardProject(sp) }),
RequiredOriginalCost: ptrCast(sel.RequiredOriginalCost, toMinMaxValueDto),
VP: ptrCast(sel.VP, toMinMaxValueDto),
GlobalParameters: sel.GlobalParameters,
Actions: sel.Actions,
}
}
func toResourceConditionDto(bc shared.BehaviorCondition) any {
rt := string(bc.GetResourceType())
target := TargetType(bc.GetTarget())
amount := bc.GetAmount()
switch c := bc.(type) {
case *shared.BasicResourceCondition:
dto := BasicResourceConditionDto{
Type: rt, Amount: amount, Target: target,
Per: ptrCast(c.Per, toPerConditionDto),
TargetRestriction: ptrCast(c.TargetRestriction, toTargetRestrictionDto),
MaxTrigger: c.MaxTrigger,
}
if c.VariableAmount {
dto.VariableAmount = &c.VariableAmount
}
return dto
case *shared.ProductionCondition:
dto := ProductionConditionDto{
Type: rt, Amount: amount, Target: target,
Per: ptrCast(c.Per, toPerConditionDto),
}
if c.VariableAmount {
dto.VariableAmount = &c.VariableAmount
}
return dto
case *shared.TilePlacementCondition:
return TilePlacementConditionDto{
Type: rt, Amount: amount, Target: target,
TileRestrictions: ptrCast(c.TileRestrictions, toTileRestrictionsDto),
TileType: c.TileType,
}
case *shared.GlobalParameterCondition:
return GlobalParameterConditionDto{
Type: rt, Amount: amount, Target: target,
Per: ptrCast(c.Per, toPerConditionDto),
}
case *shared.CardOperationCondition:
dto := CardOperationConditionDto{
Type: rt, Amount: amount, Target: target,
Selectors: mapSlice(c.Selectors, toSelectorDto),
}
if c.VariableAmount {
dto.VariableAmount = &c.VariableAmount
}
return dto
case *shared.CardStorageCondition:
dto := CardStorageConditionDto{
Type: rt, Amount: amount, Target: target,
Selectors: mapSlice(c.Selectors, toSelectorDto),
Per: ptrCast(c.Per, toPerConditionDto),
}
if c.VariableAmount {
dto.VariableAmount = &c.VariableAmount
}
return dto
case *shared.EffectCondition:
return EffectConditionDto{
Type: rt, Amount: amount, Target: target,
Selectors: mapSlice(c.Selectors, toSelectorDto),
}
case *shared.ColonyCondition:
return ColonyConditionDto{
Type: rt, Amount: amount, Target: target,
}
case *shared.TileModificationCondition:
return TileModificationConditionDto{
Type: rt, Amount: amount, Target: target,
TileType: c.TileType,
}
case *shared.MiscCondition:
return MiscConditionDto{
Type: rt, Amount: amount, Target: target,
Per: ptrCast(c.Per, toPerConditionDto),
Selectors: mapSlice(c.Selectors, toSelectorDto),
}
default:
return BasicResourceConditionDto{
Type: rt, Amount: amount, Target: target,
}
}
}
func toChoiceDtos(choices []shared.Choice) []ChoiceDto {
if len(choices) == 0 {
return nil
}
dtos := make([]ChoiceDto, len(choices))
for i, choice := range choices {
dtos[i] = toChoiceDto(i, choice)
}
return dtos
}
func toChoiceDto(index int, choice shared.Choice) ChoiceDto {
return ChoiceDto{
OriginalIndex: index,
Inputs: mapSlice(choice.Inputs, toResourceConditionDto),
Outputs: mapSlice(choice.Outputs, toResourceConditionDto),
Requirements: toChoiceRequirementsDto(choice.Requirements),
Available: true,
Errors: []StateErrorDto{},
}
}
func toChoiceRequirementsDto(reqs *shared.ChoiceRequirements) *CardRequirementsDto {
if reqs == nil {
return nil
}
return &CardRequirementsDto{
Items: mapSlice(reqs.Items, toChoiceRequirementDto),
}
}
func toChoiceRequirementDto(req shared.ChoiceRequirement) RequirementDto {
return RequirementDto{
Type: RequirementType(req.Type),
Min: req.Min,
Max: req.Max,
Location: ptrCast(req.Location, func(l string) CardApplyLocation { return CardApplyLocation(l) }),
Tag: ptrCast(req.Tag, func(t shared.CardTag) CardTag { return CardTag(t) }),
Resource: ptrCast(req.Resource, func(r shared.ResourceType) ResourceType { return ResourceType(r) }),
}
}
func toPerConditionDto(pc shared.PerCondition) PerConditionDto {
return PerConditionDto{
Type: ResourceType(pc.ResourceType),
Amount: pc.Amount,
Location: ptrCast(pc.Location, func(l string) CardApplyLocation { return CardApplyLocation(l) }),
Target: ptrCast(pc.Target, func(t string) TargetType { return TargetType(t) }),
Tag: ptrCast(pc.Tag, func(t shared.CardTag) CardTag { return CardTag(t) }),
AdjacentToSelfTile: pc.AdjacentToSelfTile,
}
}
func toResourceStorageDto(storage gamecards.ResourceStorage) ResourceStorageDto {
return ResourceStorageDto{
Type: ResourceType(storage.Type),
Capacity: storage.Capacity,
Starting: storage.Starting,
Description: storage.Description,
}
}
func toVPConditionDto(vp gamecards.VictoryPointCondition) VPConditionDto {
return VPConditionDto{
Amount: vp.Amount,
Condition: VPConditionType(vp.Condition),
MaxTrigger: vp.MaxTrigger,
Per: ptrCast(vp.Per, toVPPerConditionDto),
Description: vp.Description,
}
}
func toVPPerConditionDto(pc shared.PerCondition) PerConditionDto {
return toPerConditionDto(pc)
}
package dto
import (
"fmt"
"time"
"slices"
colonyAction "terraforming-mars-backend/internal/action/colony"
"terraforming-mars-backend/internal/awards"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/colonies"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/award"
"terraforming-mars-backend/internal/game/board"
gamecards "terraforming-mars-backend/internal/game/cards"
"terraforming-mars-backend/internal/game/milestone"
"terraforming-mars-backend/internal/game/player"
pfDomain "terraforming-mars-backend/internal/game/projectfunding"
"terraforming-mars-backend/internal/game/shared"
"terraforming-mars-backend/internal/milestones"
pfRegistry "terraforming-mars-backend/internal/projectfunding"
"terraforming-mars-backend/internal/standardprojects"
)
// ToGameDto converts Game to GameDto with personalized view
// The playerID parameter determines which player is "currentPlayer" vs "otherPlayers"
// Registries bundles optional expansion registries for DTO mapping
type Registries struct {
ColonyRegistry colonies.ColonyRegistry
ProjectFundingRegistry pfRegistry.ProjectFundingRegistry
StandardProjectRegistry standardprojects.StandardProjectRegistry
AwardRegistry awards.AwardRegistry
MilestoneRegistry milestones.MilestoneRegistry
}
func ToGameDto(g *game.Game, cardRegistry cards.CardRegistry, playerID string, colonyRegistry ...colonies.ColonyRegistry) GameDto {
return ToGameDtoFull(g, cardRegistry, playerID, Registries{
ColonyRegistry: firstOrNil(colonyRegistry),
})
}
func firstOrNil(regs []colonies.ColonyRegistry) colonies.ColonyRegistry {
if len(regs) > 0 {
return regs[0]
}
return nil
}
// ToGameDtoFull converts Game to GameDto with all expansion registries
func ToGameDtoFull(g *game.Game, cardRegistry cards.CardRegistry, playerID string, registries Registries) GameDto {
players := g.GetAllPlayers()
var currentPlayer PlayerDto
otherPlayers := make([]OtherPlayerDto, 0)
var viewingPlayer *player.Player
for _, p := range players {
if p.ID() == playerID {
viewingPlayer = p
currentPlayer = ToPlayerDto(p, g, cardRegistry, registries.StandardProjectRegistry, registries.AwardRegistry, registries.MilestoneRegistry)
} else {
otherPlayers = append(otherPlayers, ToOtherPlayerDto(p, g, cardRegistry))
}
}
if viewingPlayer == nil && len(players) > 0 {
otherPlayers = make([]OtherPlayerDto, 0)
currentPlayer = ToPlayerDto(players[0], g, cardRegistry, registries.StandardProjectRegistry, registries.AwardRegistry, registries.MilestoneRegistry)
for i := 1; i < len(players); i++ {
otherPlayers = append(otherPlayers, ToOtherPlayerDto(players[i], g, cardRegistry))
}
playerID = players[0].ID()
}
settings := g.Settings()
settingsDto := GameSettingsDto{
MaxPlayers: settings.MaxPlayers,
VenusNextEnabled: settings.VenusNextEnabled,
DevelopmentMode: settings.DevelopmentMode,
DemoGame: settings.DemoGame,
CardPacks: settings.CardPacks,
HasClaudeAPIKey: settings.ClaudeAPIKey != "",
ClaudeModel: settings.ClaudeModel,
AvailablePlayerColors: shared.PlayerColors,
Temperature: settings.Temperature,
Oxygen: settings.Oxygen,
Oceans: settings.Oceans,
Generation: settings.Generation,
}
globalParams := g.GlobalParameters()
globalParamsDto := GlobalParametersDto{
Temperature: globalParams.Temperature(),
Oxygen: globalParams.Oxygen(),
Oceans: globalParams.Oceans(),
MaxOceans: globalParams.GetMaxOceans(),
Venus: globalParams.Venus(),
Bonuses: buildGlobalParameterBonuses(settings.VenusNextEnabled),
}
board := g.Board()
tiles := board.Tiles()
tileDtos := make([]TileDto, len(tiles))
for i, tile := range tiles {
tileDtos[i] = TileDto{
Coordinates: HexPositionDto{
Q: tile.Coordinates.Q,
R: tile.Coordinates.R,
S: tile.Coordinates.S,
},
Type: string(tile.Type),
OwnerID: tile.OwnerID,
Tags: tile.Tags,
Bonuses: convertTileBonuses(tile.Bonuses),
Location: string(tile.Location),
DisplayName: tile.DisplayName,
ReservedBy: tile.ReservedBy,
}
if tile.OccupiedBy != nil {
occupant := &TileOccupantDto{
Type: string(tile.OccupiedBy.Type),
Tags: tile.OccupiedBy.Tags,
}
tileDtos[i].OccupiedBy = occupant
}
}
paymentConstants := PaymentConstantsDto{
SteelValue: 2, // Default steel value
TitaniumValue: 3, // Default titanium value
}
var finalScoreDtos []FinalScoreDto
if g.Status() == shared.GameStatusCompleted {
finalScores := g.GetFinalScores()
if finalScores != nil {
finalScoreDtos = make([]FinalScoreDto, len(finalScores))
for i, fs := range finalScores {
finalScoreDtos[i] = FinalScoreDto{
PlayerID: fs.PlayerID,
PlayerName: fs.PlayerName,
VPBreakdown: ToVPBreakdownDto(fs.Breakdown),
IsWinner: fs.IsWinner,
Placement: fs.Placement,
}
}
}
}
triggeredEffects := g.GetTriggeredEffects()
var triggeredEffectDtos []TriggeredEffectDto
if len(triggeredEffects) > 0 {
triggeredEffectDtos = make([]TriggeredEffectDto, len(triggeredEffects))
for i, effect := range triggeredEffects {
triggeredEffectDtos[i] = ToTriggeredEffectDto(effect)
}
}
var initPhaseDto *InitPhaseDto
phase := g.CurrentPhase()
if phase == shared.GamePhaseInitApplyCorp || phase == shared.GamePhaseInitApplyPrelude {
turnOrder := g.TurnOrder()
idx := g.InitPhasePlayerIndex()
currentInitPlayerID := ""
if idx < len(turnOrder) {
currentInitPlayerID = turnOrder[idx]
}
activePlayers := 0
for _, p := range players {
if !p.HasExited() {
activePlayers++
}
}
hasPendingTiles := false
if currentInitPlayerID != "" {
hasPendingTiles = g.GetPendingTileSelection(currentInitPlayerID) != nil ||
g.GetPendingTileSelectionQueue(currentInitPlayerID) != nil
}
initPhaseDto = &InitPhaseDto{
CurrentPlayerID: currentInitPlayerID,
CurrentPlayerIndex: idx,
TotalPlayers: activePlayers,
WaitingForConfirm: g.InitPhaseWaitingForConfirm(),
ConfirmVersion: g.InitPhaseConfirmVersion(),
HasPreludePhase: g.Settings().HasPrelude(),
HasPendingTiles: hasPendingTiles,
}
}
result := GameDto{
ID: g.ID(),
Status: GameStatus(g.Status()),
Settings: settingsDto,
HostPlayerID: g.HostPlayerID(),
CurrentPhase: GamePhase(g.CurrentPhase()),
GlobalParameters: globalParamsDto,
CurrentPlayer: currentPlayer,
OtherPlayers: otherPlayers,
ViewingPlayerID: playerID,
CurrentTurn: getCurrentTurnPlayerID(g),
Generation: g.Generation(),
PlayerOrder: g.PlayerOrder(),
TurnOrder: g.TurnOrder(),
Board: BoardDto{
Tiles: tileDtos,
},
PaymentConstants: paymentConstants,
Milestones: ToMilestonesDto(g, cardRegistry, registries.MilestoneRegistry),
Awards: ToAwardsDto(g, cardRegistry, registries.AwardRegistry),
AwardResults: ToAwardResultsDto(g, cardRegistry, registries.AwardRegistry),
FinalScores: finalScoreDtos,
TriggeredEffects: triggeredEffectDtos,
PlaceableTileTypes: ToPlaceableTileTypeDtos(),
InitPhase: initPhaseDto,
Spectators: toSpectatorDtos(g),
ChatMessages: toChatMessageDtos(g),
}
result.IsLastRound = g.GlobalParameters().IsMaxed()
if g.HasColonies() && registries.ColonyRegistry != nil {
result.Colonies = toColonyDtos(g, registries.ColonyRegistry, cardRegistry, playerID)
result.TradeFleetAvailable = g.Colonies().GetTradeFleetAvailable(playerID)
fleets := make(map[string]bool)
for _, p := range players {
fleets[p.ID()] = g.Colonies().GetTradeFleetAvailable(p.ID())
}
result.TradeFleets = fleets
}
if g.HasProjectFunding() && registries.ProjectFundingRegistry != nil {
result.ProjectFunding = toProjectFundingDtos(g, registries.ProjectFundingRegistry, playerID)
}
return result
}
func toSpectatorDtos(g *game.Game) []SpectatorDto {
spectators := g.GetAllSpectators()
dtos := make([]SpectatorDto, len(spectators))
for i, s := range spectators {
dtos[i] = SpectatorDto{
ID: s.ID(),
Name: s.Name(),
Color: s.Color(),
}
}
return dtos
}
func toChatMessageDtos(g *game.Game) []ChatMessageDto {
messages := g.GetChatMessages()
dtos := make([]ChatMessageDto, len(messages))
for i, msg := range messages {
dtos[i] = ChatMessageDto{
SenderID: msg.SenderID,
SenderName: msg.SenderName,
SenderColor: msg.SenderColor,
Message: msg.Message,
Timestamp: msg.Timestamp.Format(time.RFC3339),
IsSpectator: msg.IsSpectator,
}
}
return dtos
}
// ToPlaceableTileTypeDtos converts the board PlaceableTileTypes registry to DTOs
func ToPlaceableTileTypeDtos() []PlaceableTileTypeDto {
dtos := make([]PlaceableTileTypeDto, len(board.PlaceableTileTypes))
for i, pt := range board.PlaceableTileTypes {
dtos[i] = PlaceableTileTypeDto{
Type: pt.Type,
Label: pt.Label,
Group: pt.Group,
}
}
return dtos
}
// getCurrentTurnPlayerID extracts the player ID from the current turn
func getCurrentTurnPlayerID(g *game.Game) *string {
turn := g.CurrentTurn()
if turn == nil {
return nil
}
playerID := turn.PlayerID()
return &playerID
}
// convertTileBonuses converts TileBonus to DTO
func convertTileBonuses(bonuses []board.TileBonus) []TileBonusDto {
dtos := make([]TileBonusDto, len(bonuses))
for i, bonus := range bonuses {
dtos[i] = TileBonusDto{
Type: string(bonus.Type),
Amount: bonus.Amount,
}
}
return dtos
}
func filterMilestones(allDefs []milestone.MilestoneDefinition, selectedIDs []string, settings shared.GameSettings) []milestone.MilestoneDefinition {
if len(selectedIDs) > 0 {
selectedSet := make(map[string]bool, len(selectedIDs))
for _, id := range selectedIDs {
selectedSet[id] = true
}
var filtered []milestone.MilestoneDefinition
for _, def := range allDefs {
if selectedSet[def.ID] {
filtered = append(filtered, def)
}
}
return filtered
}
enabledPacks := settings.EnabledPacks()
var filtered []milestone.MilestoneDefinition
for _, def := range allDefs {
if def.Pack != "" && !enabledPacks[def.Pack] {
continue
}
filtered = append(filtered, def)
}
return filtered
}
func filterAwards(allDefs []award.AwardDefinition, selectedIDs []string, settings shared.GameSettings) []award.AwardDefinition {
if len(selectedIDs) > 0 {
selectedSet := make(map[string]bool, len(selectedIDs))
for _, id := range selectedIDs {
selectedSet[id] = true
}
var filtered []award.AwardDefinition
for _, def := range allDefs {
if selectedSet[def.ID] {
filtered = append(filtered, def)
}
}
return filtered
}
enabledPacks := settings.EnabledPacks()
var filtered []award.AwardDefinition
for _, def := range allDefs {
if def.Pack != "" && !enabledPacks[def.Pack] {
continue
}
filtered = append(filtered, def)
}
return filtered
}
// ToMilestonesDto converts all milestones to DTOs including claim status and per-player progress
func ToMilestonesDto(g *game.Game, cardRegistry cards.CardRegistry, milestoneRegistry milestones.MilestoneRegistry) []MilestoneDto {
if milestoneRegistry == nil {
return nil
}
gameMilestones := g.Milestones()
players := g.GetAllPlayers()
b := g.Board()
filteredDefs := filterMilestones(milestoneRegistry.GetAll(), g.SelectedMilestones(), g.Settings())
dtos := make([]MilestoneDto, len(filteredDefs))
for i, def := range filteredDefs {
milestoneType := shared.MilestoneType(def.ID)
var claimedBy *string
isClaimed := gameMilestones.IsClaimed(milestoneType)
if isClaimed {
for _, claimed := range gameMilestones.ClaimedMilestones() {
if claimed.Type == milestoneType {
claimedBy = &claimed.PlayerID
break
}
}
}
playerProgress := make(map[string]int, len(players))
for _, p := range players {
playerProgress[p.ID()] = gamecards.CalculateMilestoneProgress(&def, p, b, cardRegistry)
}
rewardDtos := buildMilestoneRewardDtos(def.Reward)
var styleDtoPtr *StyleDto
if def.Style.Color != "" || def.Style.Icon != "" {
styleDtoPtr = &StyleDto{Color: def.Style.Color, Icon: def.Style.Icon}
}
dtos[i] = MilestoneDto{
Type: def.ID,
Name: def.Name,
Description: def.Description,
IsClaimed: isClaimed,
ClaimedBy: claimedBy,
ClaimCost: def.ClaimCost,
Required: def.GetRequired(),
PlayerProgress: playerProgress,
Reward: rewardDtos,
Style: styleDtoPtr,
}
}
return dtos
}
// buildMilestoneRewardDtos converts milestone reward outputs to AwardRewardDto slice
func buildMilestoneRewardDtos(rewards []award.RewardOutput) []AwardRewardDto {
if len(rewards) == 0 {
return nil
}
outputs := make([]any, len(rewards))
for i, r := range rewards {
outputs[i] = BasicResourceConditionDto{
Type: string(r.Type),
Amount: r.Amount,
Target: "self-player",
}
}
return []AwardRewardDto{
{
Place: 1,
Outputs: outputs,
},
}
}
// ToAwardsDto converts all awards to DTOs including funding status and per-player scores
func ToAwardsDto(g *game.Game, cardRegistry cards.CardRegistry, awardRegistry awards.AwardRegistry) []AwardDto {
if awardRegistry == nil {
return nil
}
gameAwards := g.Awards()
players := g.GetAllPlayers()
b := g.Board()
filteredDefs := filterAwards(awardRegistry.GetAll(), g.SelectedAwards(), g.Settings())
dtos := make([]AwardDto, len(filteredDefs))
fundedCount := gameAwards.FundedCount()
for i, def := range filteredDefs {
awardType := shared.AwardType(def.ID)
var fundedBy *string
isFunded := gameAwards.IsFunded(awardType)
fundingCost := def.GetCostForFundedCount(0)
if isFunded {
for _, funded := range gameAwards.FundedAwards() {
if funded.Type == awardType {
fundedBy = &funded.FundedByPlayer
fundingCost = funded.FundingCost
break
}
}
} else {
if fundedCount < game.MaxFundedAwards {
fundingCost = def.GetCostForFundedCount(fundedCount)
}
}
playerProgress := make(map[string]int, len(players))
for _, p := range players {
playerProgress[p.ID()] = gamecards.CalculateAwardScore(&def, p, b, cardRegistry)
}
rewardDtos := make([]AwardRewardDto, len(def.Rewards))
for ri, r := range def.Rewards {
outputs := make([]any, len(r.Outputs))
for oi, o := range r.Outputs {
outputs[oi] = BasicResourceConditionDto{
Type: string(o.Type),
Amount: o.Amount,
Target: "self-player",
}
}
rewardDtos[ri] = AwardRewardDto{
Place: r.Place,
Outputs: outputs,
}
}
var styleDtoPtr *StyleDto
if def.Style.Color != "" || def.Style.Icon != "" {
styleDtoPtr = &StyleDto{Color: def.Style.Color, Icon: def.Style.Icon}
}
dtos[i] = AwardDto{
Type: def.ID,
Name: def.Name,
Description: def.Description,
IsFunded: isFunded,
FundedBy: fundedBy,
FundingCost: fundingCost,
PlayerProgress: playerProgress,
Rewards: rewardDtos,
Style: styleDtoPtr,
}
}
return dtos
}
// ToAwardResultsDto converts funded awards to placement results
func ToAwardResultsDto(g *game.Game, cardRegistry cards.CardRegistry, awardRegistry awards.AwardRegistry) []AwardResultDto {
if awardRegistry == nil {
return nil
}
fundedAwards := g.Awards().FundedAwards()
results := make([]AwardResultDto, 0, len(fundedAwards))
for _, funded := range fundedAwards {
def, err := awardRegistry.GetByID(string(funded.Type))
if err != nil {
continue
}
placements := gamecards.ScoreAward(def, g.GetAllPlayers(), g.Board(), cardRegistry)
firstPlace := make([]string, 0)
secondPlace := make([]string, 0)
for _, p := range placements {
if p.Placement == 1 {
firstPlace = append(firstPlace, p.PlayerID)
} else if p.Placement == 2 {
secondPlace = append(secondPlace, p.PlayerID)
}
}
results = append(results, AwardResultDto{
AwardType: string(funded.Type),
FirstPlaceIds: firstPlace,
SecondPlaceIds: secondPlace,
})
}
return results
}
// ToCardVPConditionDetailDto converts a card VP condition detail to DTO
func ToCardVPConditionDetailDto(detail shared.CardVPConditionDetail) CardVPConditionDetailDto {
return CardVPConditionDetailDto{
ConditionType: detail.ConditionType,
Amount: detail.Amount,
Count: detail.Count,
MaxTrigger: detail.MaxTrigger,
ActualTriggers: detail.ActualTriggers,
TotalVP: detail.TotalVP,
Explanation: detail.Explanation,
}
}
// ToCardVPDetailDto converts a card VP detail to DTO
func ToCardVPDetailDto(detail shared.CardVPDetail) CardVPDetailDto {
return CardVPDetailDto{
CardID: detail.CardID,
CardName: detail.CardName,
Conditions: mapSlice(detail.Conditions, ToCardVPConditionDetailDto),
TotalVP: detail.TotalVP,
}
}
// ToGreeneryVPDetailDto converts a greenery VP detail to DTO
func ToGreeneryVPDetailDto(detail shared.GreeneryVPDetail) GreeneryVPDetailDto {
return GreeneryVPDetailDto{
Coordinate: detail.Coordinate,
VP: detail.VP,
}
}
// ToCityVPDetailDto converts a city VP detail to DTO
func ToCityVPDetailDto(detail shared.CityVPDetail) CityVPDetailDto {
return CityVPDetailDto{
CityCoordinate: detail.CityCoordinate,
AdjacentGreeneries: detail.AdjacentGreeneries,
VP: detail.VP,
}
}
// ToVPBreakdownDto converts a VP breakdown to DTO
func ToVPBreakdownDto(breakdown shared.VPBreakdown) VPBreakdownDto {
return VPBreakdownDto{
TerraformRating: breakdown.TerraformRating,
CardVP: breakdown.CardVP,
CardVPDetails: mapSlice(breakdown.CardVPDetails, ToCardVPDetailDto),
MilestoneVP: breakdown.MilestoneVP,
AwardVP: breakdown.AwardVP,
GreeneryVP: breakdown.GreeneryVP,
GreeneryVPDetails: mapSlice(breakdown.GreeneryVPDetails, ToGreeneryVPDetailDto),
CityVP: breakdown.CityVP,
CityVPDetails: mapSlice(breakdown.CityVPDetails, ToCityVPDetailDto),
TotalVP: breakdown.TotalVP,
}
}
// ToFinalScoreDto creates a final score DTO for a player
func ToFinalScoreDto(playerID, playerName string, breakdown shared.VPBreakdown, isWinner bool, placement int) FinalScoreDto {
return FinalScoreDto{
PlayerID: playerID,
PlayerName: playerName,
VPBreakdown: ToVPBreakdownDto(breakdown),
IsWinner: isWinner,
Placement: placement,
}
}
// toVPGranterDtos converts a slice of VPGranter to VPGranterDto slice with per-condition breakdown
func toVPGranterDtos(granters []shared.VPGranter) []VPGranterDto {
if len(granters) == 0 {
return []VPGranterDto{}
}
dtos := make([]VPGranterDto, len(granters))
for i, g := range granters {
conditions := make([]VPGranterConditionDto, len(g.VPConditions))
for j, cond := range g.VPConditions {
conditions[j] = toVPGranterConditionDto(cond)
}
dtos[i] = VPGranterDto{
CardID: g.CardID,
CardName: g.CardName,
Description: g.Description,
ComputedValue: g.ComputedValue,
Conditions: conditions,
}
}
return dtos
}
func toVPGranterConditionDto(cond shared.VPCondition) VPGranterConditionDto {
dto := VPGranterConditionDto{
Amount: cond.Amount,
ConditionType: string(cond.Condition),
}
switch cond.Condition {
case shared.VPConditionFixed, shared.VPConditionOnce:
dto.ComputedVP = cond.Amount
dto.Explanation = fmt.Sprintf("%d VP", cond.Amount)
case shared.VPConditionPer:
if cond.Per != nil {
perType := string(cond.Per.ResourceType)
if cond.Per.Tag != nil {
perType = string(*cond.Per.Tag)
}
if cond.Per.Target != nil && *cond.Per.Target == "self-card" {
perType = string(cond.Per.ResourceType)
}
dto.PerType = &perType
dto.PerAmount = &cond.Per.Amount
dto.AdjacentToSelfTile = cond.Per.AdjacentToSelfTile
dto.Explanation = fmt.Sprintf("%d VP per %d %s", cond.Amount, cond.Per.Amount, perType)
}
}
return dto
}
// ToTriggeredEffectDto converts a triggered effect to DTO
func ToTriggeredEffectDto(effect shared.TriggeredEffect) TriggeredEffectDto {
outputDtos := make([]any, len(effect.Outputs))
for i, output := range effect.Outputs {
outputDtos[i] = toResourceConditionDto(output)
}
var calculatedOutputDtos []CalculatedOutputDto
if len(effect.CalculatedOutputs) > 0 {
calculatedOutputDtos = make([]CalculatedOutputDto, len(effect.CalculatedOutputs))
for i, co := range effect.CalculatedOutputs {
calculatedOutputDtos[i] = CalculatedOutputDto{
ResourceType: co.ResourceType,
Amount: co.Amount,
IsScaled: co.IsScaled,
}
}
}
var behaviorDtos []CardBehaviorDto
if len(effect.Behaviors) > 0 {
behaviorDtos = make([]CardBehaviorDto, len(effect.Behaviors))
for i, b := range effect.Behaviors {
behaviorDtos[i] = toCardBehaviorDto(b)
}
}
var vpConditionDtos []VPConditionDto
if len(effect.VPConditions) > 0 {
vpConditionDtos = make([]VPConditionDto, len(effect.VPConditions))
for i, vp := range effect.VPConditions {
vpConditionDtos[i] = VPConditionDto{
Amount: vp.Amount,
Condition: VPConditionType(vp.Condition),
MaxTrigger: vp.MaxTrigger,
Per: ptrCast(vp.Per, toPerConditionDto),
}
}
}
return TriggeredEffectDto{
CardName: effect.CardName,
PlayerID: effect.PlayerID,
SourceType: string(effect.SourceType),
Outputs: outputDtos,
CalculatedOutputs: calculatedOutputDtos,
Behaviors: behaviorDtos,
VPConditions: vpConditionDtos,
}
}
func buildGlobalParameterBonuses(venusEnabled bool) []GlobalParameterBonusDto {
bonuses := []GlobalParameterBonusDto{
{Parameter: "temperature", Threshold: -24, RewardType: "heat-production", RewardAmount: 1},
{Parameter: "temperature", Threshold: -20, RewardType: "heat-production", RewardAmount: 1},
{Parameter: "temperature", Threshold: 0, RewardType: "ocean-placement", RewardAmount: 1},
{Parameter: "oxygen", Threshold: 8, RewardType: "temperature", RewardAmount: 1},
}
if venusEnabled {
bonuses = append(bonuses,
GlobalParameterBonusDto{Parameter: "venus", Threshold: 8, RewardType: "card-draw", RewardAmount: 1},
GlobalParameterBonusDto{Parameter: "venus", Threshold: 16, RewardType: "tr", RewardAmount: 1},
)
}
return bonuses
}
func toColonyDtos(g *game.Game, colonyRegistry colonies.ColonyRegistry, cardRegistry cards.CardRegistry, playerID string) []ColonyDto {
tileStates := g.Colonies().States()
if len(tileStates) == 0 {
return nil
}
playerObj, _ := g.GetPlayer(playerID)
tradeStepBonus := 0
if playerObj != nil && cardRegistry != nil {
tradeStepBonus = colonyAction.CountTradeStepBonus(playerObj, cardRegistry)
}
dtos := make([]ColonyDto, 0, len(tileStates))
for _, state := range tileStates {
def, err := colonyRegistry.GetByID(state.DefinitionID)
if err != nil {
continue
}
steps := make([]ColonyStepDto, len(def.Steps))
for i, s := range def.Steps {
outputs := make([]ColonyOutputDto, len(s.Outputs))
for j, o := range s.Outputs {
outputs[j] = ColonyOutputDto{Type: o.Type, Amount: o.Amount}
}
steps[i] = ColonyStepDto{Outputs: outputs}
}
colonyBonus := make([]ColonyOutputDto, len(def.ColonyBonus))
for i, b := range def.ColonyBonus {
colonyBonus[i] = ColonyOutputDto{Type: b.Type, Amount: b.Amount}
}
colonySlots := make([]ColonySlotDto, len(def.Colonies))
for i, c := range def.Colonies {
reward := make([]ColonyOutputDto, len(c.Reward))
for j, r := range c.Reward {
reward[j] = ColonyOutputDto{Type: r.Type, Amount: r.Amount}
}
colonySlots[i] = ColonySlotDto{Reward: reward}
}
playerColonies := state.PlayerColonies
if playerColonies == nil {
playerColonies = []string{}
}
// Calculate trade availability
tradeAvailable := true
var tradeErrors []StateErrorDto
if state.TradedThisGen {
tradeAvailable = false
tradeErrors = append(tradeErrors, StateErrorDto{
Code: StateErrorCode("colony-already-traded"),
Message: "This colony has already been traded this generation",
})
}
if !g.Colonies().GetTradeFleetAvailable(playerID) {
tradeAvailable = false
tradeErrors = append(tradeErrors, StateErrorDto{
Code: StateErrorCode("fleet-unavailable"),
Message: "Your trade fleet is not available",
})
}
if playerObj != nil {
resources := playerObj.Resources().Get()
canAffordAny := resources.Credits >= 9 || resources.Energy >= 3 || resources.Titanium >= 3
if !canAffordAny {
tradeAvailable = false
tradeErrors = append(tradeErrors, StateErrorDto{
Code: StateErrorCode("insufficient-resources"),
Message: "Cannot afford trade: need 9 MC, 3 energy, or 3 titanium",
})
}
}
// Calculate build availability
buildAvailable := true
var buildErrors []StateErrorDto
maxColonies := len(def.Colonies)
if len(state.PlayerColonies) >= maxColonies {
buildAvailable = false
buildErrors = append(buildErrors, StateErrorDto{
Code: StateErrorCode("colony-full"),
Message: "This colony tile is full",
})
}
if slices.Contains(state.PlayerColonies, playerID) {
buildAvailable = false
buildErrors = append(buildErrors, StateErrorDto{
Code: StateErrorCode("already-has-colony"),
Message: "You already have a colony here",
})
}
if playerObj != nil {
resources := playerObj.Resources().Get()
if resources.Credits < 17 {
buildAvailable = false
buildErrors = append(buildErrors, StateErrorDto{
Code: StateErrorCode("insufficient-credits"),
Message: fmt.Sprintf("Insufficient credits: need 17, have %d", resources.Credits),
})
}
}
dtos = append(dtos, ColonyDto{
ID: def.ID,
Name: def.Name,
Location: def.Location,
Steps: steps,
ColonyBonus: colonyBonus,
Colonies: colonySlots,
MarkerPosition: state.MarkerPosition,
PlayerColonies: playerColonies,
TradedThisGen: state.TradedThisGen,
TraderID: state.TraderID,
Style: StyleDto{
Color: def.Style.Color,
Icon: def.Style.Icon,
},
TradeStepBonus: tradeStepBonus,
TradeAvailable: tradeAvailable,
BuildAvailable: buildAvailable,
TradeErrors: tradeErrors,
BuildErrors: buildErrors,
})
}
return dtos
}
func toProjectFundingDtos(g *game.Game, registry pfRegistry.ProjectFundingRegistry, playerID string) []ProjectFundingDto {
states := g.ProjectFundingStates()
if len(states) == 0 {
return nil
}
playerObj, _ := g.GetPlayer(playerID)
var playerCredits int
if playerObj != nil {
playerCredits = playerObj.Resources().Get().Credits
}
dtos := make([]ProjectFundingDto, 0, len(states))
for _, state := range states {
def, err := registry.GetByID(state.DefinitionID)
if err != nil {
continue
}
// Build seat DTOs
seats := make([]ProjectSeatDto, len(def.Seats))
for i, seatDef := range def.Seats {
subs := make([]ProjectPaymentSubDto, len(seatDef.PaymentSubstitutes))
for j, sub := range seatDef.PaymentSubstitutes {
subs[j] = ProjectPaymentSubDto{
ResourceType: sub.ResourceType,
ConversionRate: sub.ConversionRate,
}
}
seat := ProjectSeatDto{
Cost: seatDef.Cost,
PaymentSubstitutes: subs,
}
if i < len(state.SeatOwners) {
ownerID := state.SeatOwners[i]
seat.IsFilled = true
seat.OwnerID = ownerID
if p, err := g.GetPlayer(ownerID); err == nil {
seat.OwnerName = p.Name()
seat.OwnerColor = p.Color()
}
}
seats[i] = seat
}
// Build seat owners list
seatOwners := make([]ProjectSeatOwnerDto, len(state.SeatOwners))
for i, ownerID := range state.SeatOwners {
owner := ProjectSeatOwnerDto{PlayerID: ownerID}
if p, err := g.GetPlayer(ownerID); err == nil {
owner.Name = p.Name()
owner.Color = p.Color()
}
seatOwners[i] = owner
}
// Next seat info
nextSeatIndex := len(state.SeatOwners)
nextSeatCost := 0
var nextSeatSubs []ProjectPaymentSubDto
if nextSeatIndex < len(def.Seats) {
nextSeatCost = def.Seats[nextSeatIndex].Cost
for _, sub := range def.Seats[nextSeatIndex].PaymentSubstitutes {
nextSeatSubs = append(nextSeatSubs, ProjectPaymentSubDto{
ResourceType: sub.ResourceType,
ConversionRate: sub.ConversionRate,
})
}
}
if nextSeatSubs == nil {
nextSeatSubs = []ProjectPaymentSubDto{}
}
// Buy availability
canBuy := true
var buyErrors []StateErrorDto
if state.IsCompleted {
canBuy = false
buyErrors = append(buyErrors, StateErrorDto{
Code: StateErrorCode("project-completed"),
Message: "This project is already completed",
})
} else if nextSeatIndex >= len(def.Seats) {
canBuy = false
buyErrors = append(buyErrors, StateErrorDto{
Code: StateErrorCode("all-seats-filled"),
Message: "All seats are filled",
})
} else if playerCredits < nextSeatCost {
canBuy = false
buyErrors = append(buyErrors, StateErrorDto{
Code: StateErrorCode("insufficient-credits"),
Message: fmt.Sprintf("Insufficient credits: need %d, have %d", nextSeatCost, playerCredits),
})
}
// Count current player seats and find tier
currentPlayerSeats := 0
for _, ownerID := range state.SeatOwners {
if ownerID == playerID {
currentPlayerSeats++
}
}
var currentPlayerTier *ProjectRewardTierDto
if currentPlayerSeats > 0 {
tier := pfDomain.FindBestTier(def.RewardTiers, currentPlayerSeats)
if tier != nil {
rewards := make([]ColonyOutputDto, len(tier.Rewards))
for j, r := range tier.Rewards {
rewards[j] = ColonyOutputDto{Type: r.Type, Amount: r.Amount}
}
currentPlayerTier = &ProjectRewardTierDto{
SeatsOwned: tier.SeatsOwned,
Rewards: rewards,
}
}
}
// Reward tiers
rewardTiers := make([]ProjectRewardTierDto, len(def.RewardTiers))
for i, tier := range def.RewardTiers {
rewards := make([]ColonyOutputDto, len(tier.Rewards))
for j, r := range tier.Rewards {
rewards[j] = ColonyOutputDto{Type: r.Type, Amount: r.Amount}
}
rewardTiers[i] = ProjectRewardTierDto{
SeatsOwned: tier.SeatsOwned,
Rewards: rewards,
}
}
// Completion effect
completionRewards := make([]ColonyOutputDto, len(def.CompletionEffect.Rewards))
for j, r := range def.CompletionEffect.Rewards {
completionRewards[j] = ColonyOutputDto{Type: r.Type, Amount: r.Amount}
}
var completionGlobalEffects []ProjectGlobalOutputDto
for _, ge := range def.CompletionEffect.GlobalEffects {
completionGlobalEffects = append(completionGlobalEffects, ProjectGlobalOutputDto{
Type: ge.Type,
Amount: ge.Amount,
})
}
dtos = append(dtos, ProjectFundingDto{
ID: def.ID,
Name: def.Name,
Description: def.Description,
Seats: seats,
SeatOwners: seatOwners,
IsCompleted: state.IsCompleted,
NextSeatIndex: nextSeatIndex,
NextSeatCost: nextSeatCost,
CanBuySeat: canBuy,
BuyErrors: buyErrors,
CurrentPlayerSeats: currentPlayerSeats,
CurrentPlayerTier: currentPlayerTier,
PaymentSubstitutes: nextSeatSubs,
RewardTiers: rewardTiers,
CompletionEffect: ProjectCompletionEffectDto{
Description: def.CompletionEffect.Description,
Rewards: completionRewards,
GlobalEffects: completionGlobalEffects,
},
Style: StyleDto{
Color: def.Style.Color,
Icon: def.Style.Icon,
},
})
}
return dtos
}
package dto
import (
"terraforming-mars-backend/internal/game/board"
"terraforming-mars-backend/internal/game/datastore"
"terraforming-mars-backend/internal/game/shared"
)
// ToGameHistoryEntryDtos converts a slice of history entries to DTOs.
func ToGameHistoryEntryDtos(entries []*datastore.GameStateHistoryEntry) []GameHistoryEntryDto {
dtos := make([]GameHistoryEntryDto, len(entries))
for i, entry := range entries {
dtos[i] = toGameHistoryEntryDto(entry)
}
return dtos
}
func toGameHistoryEntryDto(entry *datastore.GameStateHistoryEntry) GameHistoryEntryDto {
s := entry.State
tileDtos := make([]TileDto, len(s.Tiles))
for i, tile := range s.Tiles {
tileDtos[i] = TileDto{
Coordinates: HexPositionDto{
Q: tile.Coordinates.Q,
R: tile.Coordinates.R,
S: tile.Coordinates.S,
},
Type: string(tile.Type),
OwnerID: tile.OwnerID,
Tags: tile.Tags,
Bonuses: convertTileBonuses(tile.Bonuses),
Location: string(tile.Location),
DisplayName: tile.DisplayName,
ReservedBy: tile.ReservedBy,
}
if tile.OccupiedBy != nil {
tileDtos[i].OccupiedBy = &TileOccupantDto{
Type: string(tile.OccupiedBy.Type),
Tags: tile.OccupiedBy.Tags,
}
}
}
players := make(map[string]GameHistoryPlayerDto, len(s.Players))
for id, p := range s.Players {
cardVP := computeHistoryCardVP(p)
greeneryVP := computeHistoryGreeneryVP(s.Tiles, id)
cityVP := computeHistoryCityVP(s.Tiles, id)
milestoneVP := computeHistoryMilestoneVP(s.ClaimedMilestones, id)
totalVP := p.TerraformRating + cardVP + greeneryVP + cityVP + milestoneVP
players[id] = GameHistoryPlayerDto{
ID: p.ID,
Name: p.Name,
Color: p.Color,
TerraformRating: p.TerraformRating,
Credits: p.Resources.Credits,
Steel: p.Resources.Steel,
Titanium: p.Resources.Titanium,
Plants: p.Resources.Plants,
Energy: p.Resources.Energy,
Heat: p.Resources.Heat,
PlayedCardCount: len(p.PlayedCardIDs),
Production: toProductionDto(p.Production),
PlayedCardIDs: p.PlayedCardIDs,
HandCardIDs: p.HandCardIDs,
CorporationID: p.CorporationID,
ResourceStorage: p.ResourceStorage,
TotalVP: totalVP,
}
}
milestones := make([]ClaimedMilestoneDto, len(s.ClaimedMilestones))
for i, m := range s.ClaimedMilestones {
milestones[i] = ClaimedMilestoneDto{
Type: string(m.Type),
PlayerID: m.PlayerID,
}
}
awards := make([]FundedAwardDto, len(s.FundedAwards))
for i, a := range s.FundedAwards {
awards[i] = FundedAwardDto{
Type: string(a.Type),
PlayerID: a.FundedByPlayer,
}
}
return GameHistoryEntryDto{
Sequence: entry.Sequence,
Timestamp: entry.Timestamp,
Generation: s.Generation,
Phase: GamePhase(s.CurrentPhase),
Temperature: s.Temperature,
Oxygen: s.Oxygen,
Oceans: s.Oceans,
Venus: s.Venus,
ActionNumber: s.GlobalActionCounter,
Board: BoardDto{Tiles: tileDtos},
Players: players,
Milestones: milestones,
Awards: awards,
Settings: GameSettingsDto{
MaxPlayers: s.Settings.MaxPlayers,
VenusNextEnabled: s.Settings.VenusNextEnabled,
DevelopmentMode: s.Settings.DevelopmentMode,
DemoGame: s.Settings.DemoGame,
CardPacks: s.Settings.CardPacks,
},
}
}
func computeHistoryCardVP(p *datastore.PlayerState) int {
vp := 0
for _, g := range p.VPGranters {
vp += g.ComputedValue
}
return vp
}
func computeHistoryGreeneryVP(tiles []board.Tile, playerID string) int {
vp := 0
for _, t := range tiles {
if t.OccupiedBy != nil && shared.IsForestTile(t.OccupiedBy.Type) && t.OwnerID != nil && *t.OwnerID == playerID {
vp++
}
}
return vp
}
func computeHistoryCityVP(tiles []board.Tile, playerID string) int {
vp := 0
for _, t := range tiles {
if t.OccupiedBy == nil || t.OccupiedBy.Type != shared.ResourceCityTile || t.OwnerID == nil || *t.OwnerID != playerID {
continue
}
for _, neighbor := range t.Coordinates.GetNeighbors() {
for _, n := range tiles {
if n.Coordinates == neighbor && n.OccupiedBy != nil && shared.IsForestTile(n.OccupiedBy.Type) {
vp++
break
}
}
}
}
return vp
}
func computeHistoryMilestoneVP(milestones []shared.ClaimedMilestone, playerID string) int {
vp := 0
for _, m := range milestones {
if m.PlayerID == playerID {
vp += 5
}
}
return vp
}
package dto
// mapSlice converts a slice of one type to a slice of another type using the provided mapper function.
func mapSlice[T, U any](items []T, mapper func(T) U) []U {
if len(items) == 0 {
return []U{}
}
result := make([]U, len(items))
for i, item := range items {
result[i] = mapper(item)
}
return result
}
// ptrCast converts a pointer of one type to a pointer of another type using the provided converter function.
// Returns nil if the input pointer is nil.
func ptrCast[T, U any](val *T, conv func(T) U) *U {
if val == nil {
return nil
}
converted := conv(*val)
return &converted
}
package dto
import (
"terraforming-mars-backend/internal/action"
"terraforming-mars-backend/internal/awards"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/game"
gamecards "terraforming-mars-backend/internal/game/cards"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
"terraforming-mars-backend/internal/milestones"
"terraforming-mars-backend/internal/standardprojects"
)
// toResourcesDto converts shared.Resources to ResourcesDto.
func toResourcesDto(res shared.Resources) ResourcesDto {
return ResourcesDto{
Credits: res.Credits,
Steel: res.Steel,
Titanium: res.Titanium,
Plants: res.Plants,
Energy: res.Energy,
Heat: res.Heat,
}
}
// toProductionDto converts shared.Production to ProductionDto.
func toProductionDto(prod shared.Production) ProductionDto {
return ProductionDto{
Credits: prod.Credits,
Steel: prod.Steel,
Titanium: prod.Titanium,
Plants: prod.Plants,
Energy: prod.Energy,
Heat: prod.Heat,
}
}
// calculateResourceDelta calculates the difference between two Resources and returns a ResourcesDto.
func calculateResourceDelta(before, after shared.Resources) ResourcesDto {
return ResourcesDto{
Credits: after.Credits - before.Credits,
Steel: after.Steel - before.Steel,
Titanium: after.Titanium - before.Titanium,
Plants: after.Plants - before.Plants,
Energy: after.Energy - before.Energy,
Heat: after.Heat - before.Heat,
}
}
// ToPlayerDto converts Player to PlayerDto
func ToPlayerDto(p *player.Player, g *game.Game, cardRegistry cards.CardRegistry, stdProjRegistry standardprojects.StandardProjectRegistry, awardRegistry awards.AwardRegistry, milestoneRegistry milestones.MilestoneRegistry) PlayerDto {
resourcesComponent := p.Resources()
resources := resourcesComponent.Get()
production := resourcesComponent.Production()
corporation := getCorporationCard(p, cardRegistry)
playedCardIDs := p.PlayedCards().Cards()
playedCards := getPlayedCards(playedCardIDs, cardRegistry)
handCards := mapPlayerCards(p, g, cardRegistry)
standardProjects := mapPlayerStandardProjects(p, g, cardRegistry, stdProjRegistry)
milestones := mapPlayerMilestones(p, g, cardRegistry, milestoneRegistry)
awards := mapPlayerAwards(p, g, awardRegistry)
var pendingTileSelection *PendingTileSelectionDto
var forcedFirstAction *ForcedFirstActionDto
currentTurn := g.CurrentTurn()
if currentTurn != nil && currentTurn.PlayerID() == p.ID() {
pendingTileSelection = convertPendingTileSelection(g.GetPendingTileSelection(p.ID()))
forcedFirstAction = convertForcedFirstAction(g.GetForcedFirstAction(p.ID()))
}
if g.CurrentPhase() == shared.GamePhaseStartingSelection ||
g.CurrentPhase() == shared.GamePhaseInitApplyCorp ||
g.CurrentPhase() == shared.GamePhaseInitApplyPrelude {
pendingTileSelection = convertPendingTileSelection(g.GetPendingTileSelection(p.ID()))
forcedFirstAction = convertForcedFirstAction(g.GetForcedFirstAction(p.ID()))
}
return PlayerDto{
ID: p.ID(),
Name: p.Name(),
PlayerType: string(p.PlayerType()),
BotStatus: string(p.BotStatus()),
BotDifficulty: string(p.BotDifficulty()),
BotSpeed: string(p.BotSpeed()),
Color: p.Color(),
Resources: toResourcesDto(resources),
Production: toProductionDto(production),
TerraformRating: resourcesComponent.TerraformRating(),
Status: playerStatus(p, g),
Corporation: corporation,
Cards: handCards, // PlayerCardDto[] with state
PlayedCards: playedCards,
Passed: p.HasPassed(),
AvailableActions: getAvailableActionsForPlayer(g, p.ID()),
TotalActions: getTotalActionsForPlayer(g, p.ID()),
IsConnected: p.IsConnected(),
IsExited: p.HasExited(),
Effects: convertPlayerEffects(p.Effects().List(), p, g, cardRegistry),
Actions: convertPlayerActions(p.Actions().List(), p, g, cardRegistry),
StandardProjects: standardProjects, // PlayerStandardProjectDto[] with state
Milestones: milestones, // PlayerMilestoneDto[] with eligibility
Awards: awards, // PlayerAwardDto[] with eligibility
DemoReady: p.HasPendingDemoChoices(),
PendingDemoChoices: convertPendingDemoChoices(p.PendingDemoChoices()),
SelectCorporationPhase: convertSelectCorporationPhase(g.GetSelectCorporationPhase(p.ID()), cardRegistry),
SelectStartingCardsPhase: convertSelectStartingCardsPhase(g.GetSelectStartingCardsPhase(p.ID()), cardRegistry),
SelectPreludeCardsPhase: convertSelectPreludeCardsPhase(g.GetSelectPreludeCardsPhase(p.ID()), cardRegistry),
ProductionPhase: convertProductionPhase(g.GetProductionPhase(p.ID()), cardRegistry),
StartingCards: []CardDto{},
PendingTileSelection: pendingTileSelection,
PendingCardSelection: convertPendingCardSelection(p.Selection().GetPendingCardSelection(), p, g, cardRegistry),
PendingCardDrawSelection: convertPendingCardDrawSelection(p.Selection().GetPendingCardDrawSelection(), p, g, cardRegistry),
PendingCardDiscardSelection: convertPendingCardDiscardSelection(p.Selection().GetPendingCardDiscardSelection()),
PendingBehaviorChoiceSelection: convertPendingBehaviorChoiceSelection(p.Selection().GetPendingBehaviorChoiceSelection(), p, g, cardRegistry),
PendingStealTargetSelection: convertPendingStealTargetSelection(p.Selection().GetPendingStealTargetSelection()),
PendingColonyResourceSelection: convertPendingColonyResourceFromQueue(p.Selection().GetPendingColonyResourceQueue()),
PendingAwardFundSelection: convertPendingAwardFundSelection(p.Selection().GetPendingAwardFundSelection()),
PendingColonySelection: convertPendingColonySelection(p.Selection().GetPendingColonySelection()),
PendingFreeTradeSelection: convertPendingFreeTradeSelection(p.Selection().GetPendingFreeTradeSelection()),
ForcedFirstAction: forcedFirstAction,
ResourceStorage: p.Resources().Storage(),
PaymentSubstitutes: convertPaymentSubstitutes(p.Resources().PaymentSubstitutes()),
StoragePaymentSubstitutes: convertStoragePaymentSubstitutes(p.Resources().StoragePaymentSubstitutes()),
GenerationalEvents: convertGenerationalEvents(p.GenerationalEvents().GetAll()),
VPGranters: toVPGranterDtos(p.VPGranters().GetAll()),
BonusTags: convertBonusTags(p.BonusTags()),
ActionCosts: mapPlayerActionCosts(p, g, cardRegistry),
}
}
// ToOtherPlayerDto converts Player to OtherPlayerDto
func ToOtherPlayerDto(p *player.Player, g *game.Game, cardRegistry cards.CardRegistry) OtherPlayerDto {
resourcesComponent := p.Resources()
resources := resourcesComponent.Get()
production := resourcesComponent.Production()
corporation := getCorporationCard(p, cardRegistry)
playedCardIDs := p.PlayedCards().Cards()
playedCards := getPlayedCards(playedCardIDs, cardRegistry)
handCardCount := len(p.Hand().Cards())
return OtherPlayerDto{
ID: p.ID(),
Name: p.Name(),
PlayerType: string(p.PlayerType()),
BotStatus: string(p.BotStatus()),
BotDifficulty: string(p.BotDifficulty()),
BotSpeed: string(p.BotSpeed()),
Color: p.Color(),
Resources: toResourcesDto(resources),
Production: toProductionDto(production),
TerraformRating: resourcesComponent.TerraformRating(),
Status: playerStatus(p, g),
Corporation: corporation,
HandCardCount: handCardCount,
PlayedCards: playedCards,
Passed: p.HasPassed(),
AvailableActions: getAvailableActionsForPlayer(g, p.ID()),
TotalActions: getTotalActionsForPlayer(g, p.ID()),
IsConnected: p.IsConnected(),
IsExited: p.HasExited(),
Effects: convertPlayerEffects(p.Effects().List(), p, g, cardRegistry),
Actions: convertPlayerActions(p.Actions().List(), p, g, cardRegistry),
DemoReady: p.HasPendingDemoChoices(),
SelectCorporationPhase: convertSelectCorporationPhaseForOtherPlayer(g.GetSelectCorporationPhase(p.ID())),
SelectStartingCardsPhase: convertSelectStartingCardsPhaseForOtherPlayer(g.GetSelectStartingCardsPhase(p.ID())),
SelectPreludeCardsPhase: convertSelectPreludeCardsPhaseForOtherPlayer(g.GetSelectPreludeCardsPhase(p.ID())),
ProductionPhase: convertProductionPhaseForOtherPlayer(g.GetProductionPhase(p.ID())),
ResourceStorage: p.Resources().Storage(),
PaymentSubstitutes: convertPaymentSubstitutes(p.Resources().PaymentSubstitutes()),
StoragePaymentSubstitutes: convertStoragePaymentSubstitutes(p.Resources().StoragePaymentSubstitutes()),
VPGranters: toVPGranterDtos(p.VPGranters().GetAll()),
BonusTags: convertBonusTags(p.BonusTags()),
}
}
func convertPendingDemoChoices(choices *shared.PendingDemoChoices) *PendingDemoChoicesDto {
if choices == nil {
return nil
}
return &PendingDemoChoicesDto{
CorporationID: choices.CorporationID,
PreludeIDs: choices.PreludeIDs,
CardIDs: choices.CardIDs,
Resources: toResourcesDto(choices.Resources),
Production: toProductionDto(choices.Production),
TerraformRating: choices.TerraformRating,
}
}
func convertBonusTags(tags map[shared.CardTag]int) map[string]int {
if len(tags) == 0 {
return map[string]int{}
}
result := make(map[string]int, len(tags))
for k, v := range tags {
result[string(k)] = v
}
return result
}
func playerStatus(p *player.Player, g *game.Game) PlayerStatus {
if p.HasExited() {
return PlayerStatusExited
}
if g.GetPendingTileSelection(p.ID()) != nil {
return PlayerStatusTile
}
if p.Selection().HasPendingSelection() {
return PlayerStatusSelection
}
if g.GetSelectCorporationPhase(p.ID()) != nil ||
g.GetSelectStartingCardsPhase(p.ID()) != nil ||
g.GetSelectPreludeCardsPhase(p.ID()) != nil {
return PlayerStatusSelectingStartingCards
}
pp := g.GetProductionPhase(p.ID())
if pp != nil && !pp.SelectionComplete {
return PlayerStatusSelectingProductionCards
}
turn := g.CurrentTurn()
if turn != nil && turn.PlayerID() == p.ID() &&
g.CurrentPhase() == shared.GamePhaseAction {
return PlayerStatusActive
}
return PlayerStatusWaiting
}
func convertSelectCorporationPhase(phase *shared.SelectCorporationPhase, cardRegistry cards.CardRegistry) *SelectCorporationPhaseDto {
if phase == nil {
return nil
}
return &SelectCorporationPhaseDto{
AvailableCorporations: getPlayedCards(phase.AvailableCorporations, cardRegistry),
}
}
func convertSelectCorporationPhaseForOtherPlayer(phase *shared.SelectCorporationPhase) *SelectCorporationOtherPlayerDto {
if phase == nil {
return nil
}
return &SelectCorporationOtherPlayerDto{}
}
func convertSelectStartingCardsPhase(phase *shared.SelectStartingCardsPhase, cardRegistry cards.CardRegistry) *SelectStartingCardsPhaseDto {
if phase == nil {
return nil
}
return &SelectStartingCardsPhaseDto{
AvailableCards: getPlayedCards(phase.AvailableCards, cardRegistry),
}
}
func convertSelectStartingCardsPhaseForOtherPlayer(phase *shared.SelectStartingCardsPhase) *SelectStartingCardsOtherPlayerDto {
if phase == nil {
return nil
}
return &SelectStartingCardsOtherPlayerDto{}
}
// convertSelectPreludeCardsPhase converts SelectPreludeCardsPhase to DTO
func convertSelectPreludeCardsPhase(phase *shared.SelectPreludeCardsPhase, cardRegistry cards.CardRegistry) *SelectPreludeCardsPhaseDto {
if phase == nil {
return nil
}
return &SelectPreludeCardsPhaseDto{
AvailablePreludes: getPlayedCards(phase.AvailablePreludes, cardRegistry),
MaxSelectable: phase.MaxSelectable,
}
}
// convertSelectPreludeCardsPhaseForOtherPlayer converts SelectPreludeCardsPhase to DTO for other players
func convertSelectPreludeCardsPhaseForOtherPlayer(phase *shared.SelectPreludeCardsPhase) *SelectPreludeCardsOtherPlayerDto {
if phase == nil {
return nil
}
return &SelectPreludeCardsOtherPlayerDto{}
}
// convertProductionPhase converts production phase data to DTO for current player
func convertProductionPhase(phase *shared.ProductionPhase, cardRegistry cards.CardRegistry) *ProductionPhaseDto {
if phase == nil {
return nil
}
return &ProductionPhaseDto{
AvailableCards: getPlayedCards(phase.AvailableCards, cardRegistry),
SelectionComplete: phase.SelectionComplete,
BeforeResources: toResourcesDto(phase.BeforeResources),
AfterResources: toResourcesDto(phase.AfterResources),
ResourceDelta: calculateResourceDelta(phase.BeforeResources, phase.AfterResources),
EnergyConverted: phase.EnergyConverted,
CreditsIncome: phase.CreditsIncome,
}
}
// convertProductionPhaseForOtherPlayer converts production phase data to DTO for other players
func convertProductionPhaseForOtherPlayer(phase *shared.ProductionPhase) *ProductionPhaseOtherPlayerDto {
if phase == nil {
return nil
}
return &ProductionPhaseOtherPlayerDto{
SelectionComplete: phase.SelectionComplete,
BeforeResources: toResourcesDto(phase.BeforeResources),
AfterResources: toResourcesDto(phase.AfterResources),
ResourceDelta: calculateResourceDelta(phase.BeforeResources, phase.AfterResources),
EnergyConverted: phase.EnergyConverted,
CreditsIncome: phase.CreditsIncome,
}
}
// convertPlayerEffects converts CardEffect slice to PlayerEffectDto slice
func convertPlayerEffects(effects []shared.CardEffect, p *player.Player, g *game.Game, cardRegistry cards.CardRegistry) []PlayerEffectDto {
if len(effects) == 0 {
return []PlayerEffectDto{}
}
board := g.Board()
allPlayers := g.GetAllPlayers()
dtos := make([]PlayerEffectDto, len(effects))
for i, effect := range effects {
var computedValues []ComputedBehaviorValueDto
var outputs []CalculatedOutputDto
for _, outputBC := range effect.Behavior.Outputs {
per := shared.GetPerCondition(outputBC)
if per == nil {
continue
}
count := gamecards.CountPerCondition(per, effect.CardID, p, board, cardRegistry, allPlayers)
if per.Amount > 0 {
multiplier := count / per.Amount
actualAmount := outputBC.GetAmount() * multiplier
outputs = append(outputs, CalculatedOutputDto{
ResourceType: string(outputBC.GetResourceType()),
Amount: actualAmount,
IsScaled: true,
})
}
}
if len(outputs) > 0 {
computedValues = []ComputedBehaviorValueDto{{
Target: "behaviors::0",
Outputs: outputs,
}}
}
dtos[i] = PlayerEffectDto{
CardID: effect.CardID,
CardName: effect.CardName,
BehaviorIndex: effect.BehaviorIndex,
Behavior: toCardBehaviorDto(effect.Behavior),
ComputedValues: computedValues,
}
}
return dtos
}
// convertPlayerActions converts CardAction slice to PlayerActionDto slice
// Calculates state for each action to determine availability and errors.
func convertPlayerActions(actions []shared.CardAction, p *player.Player, g *game.Game, cardRegistry cards.CardRegistry) []PlayerActionDto {
if len(actions) == 0 {
return []PlayerActionDto{}
}
dtos := make([]PlayerActionDto, len(actions))
for i, act := range actions {
// Calculate state for this action
state := action.CalculatePlayerCardActionState(
act.CardID,
act.Behavior,
act.TimesUsedThisGeneration,
p,
g,
cardRegistry,
)
behaviorDto := toCardBehaviorDto(act.Behavior)
if act.Behavior.ChoicePolicy != nil && len(behaviorDto.Choices) > 0 {
production := p.Resources().Production()
validIndices := shared.FilterChoiceIndicesByPolicy(act.Behavior.Choices, act.Behavior.ChoicePolicy, production)
filtered := make([]ChoiceDto, 0, len(validIndices))
for _, idx := range validIndices {
choice := behaviorDto.Choices[idx]
choice.OriginalIndex = idx
filtered = append(filtered, choice)
}
behaviorDto.Choices = filtered
}
dtos[i] = PlayerActionDto{
CardID: act.CardID,
CardName: act.CardName,
BehaviorIndex: act.BehaviorIndex,
Behavior: behaviorDto,
TimesUsedThisTurn: act.TimesUsedThisTurn,
TimesUsedThisGeneration: act.TimesUsedThisGeneration,
Available: state.Available(),
Errors: convertStateErrors(state.Errors),
Warnings: convertStateWarnings(state.Warnings),
ComputedValues: convertComputedValues(state.ComputedValues),
}
}
return dtos
}
// convertComputedValues converts ComputedBehaviorValue slice to DTO slice
func convertComputedValues(values []player.ComputedBehaviorValue) []ComputedBehaviorValueDto {
if len(values) == 0 {
return nil
}
dtos := make([]ComputedBehaviorValueDto, len(values))
for i, v := range values {
outputs := make([]CalculatedOutputDto, len(v.Outputs))
for j, o := range v.Outputs {
outputs[j] = CalculatedOutputDto{
ResourceType: o.ResourceType,
Amount: o.Amount,
IsScaled: o.IsScaled,
}
}
dtos[i] = ComputedBehaviorValueDto{
Target: v.Target,
Outputs: outputs,
}
}
return dtos
}
// convertPaymentSubstitutes converts PaymentSubstitute slice to PaymentSubstituteDto slice
func convertPaymentSubstitutes(substitutes []shared.PaymentSubstitute) []PaymentSubstituteDto {
if len(substitutes) == 0 {
return []PaymentSubstituteDto{}
}
dtos := make([]PaymentSubstituteDto, len(substitutes))
for i, sub := range substitutes {
dtos[i] = PaymentSubstituteDto{
ResourceType: ResourceType(sub.ResourceType),
ConversionRate: sub.ConversionRate,
}
}
return dtos
}
// convertStoragePaymentSubstitutes converts StoragePaymentSubstitute slice to DTO slice
func convertStoragePaymentSubstitutes(substitutes []shared.StoragePaymentSubstitute) []StoragePaymentSubstituteDto {
if len(substitutes) == 0 {
return []StoragePaymentSubstituteDto{}
}
dtos := make([]StoragePaymentSubstituteDto, len(substitutes))
for i, sub := range substitutes {
dtos[i] = StoragePaymentSubstituteDto{
CardID: sub.CardID,
ResourceType: ResourceType(sub.ResourceType),
ConversionRate: sub.ConversionRate,
TargetResource: ResourceType(sub.TargetResource),
Selectors: mapSlice(sub.Selectors, toSelectorDto),
}
}
return dtos
}
// convertPendingCardSelection converts PendingCardSelection to DTO with playability state
func convertPendingCardSelection(selection *shared.PendingCardSelection, p *player.Player, g *game.Game, cardRegistry cards.CardRegistry) *PendingCardSelectionDto {
if selection == nil {
return nil
}
availableCards := make([]PlayerCardDto, 0, len(selection.AvailableCards))
for _, cardID := range selection.AvailableCards {
card, err := cardRegistry.GetByID(cardID)
if err != nil {
continue
}
state := action.CalculatePendingCardPlayability(card, p, g, cardRegistry)
availableCards = append(availableCards, ToPlayerCardDto(card, state))
}
return &PendingCardSelectionDto{
AvailableCards: availableCards,
CardCosts: selection.CardCosts,
CardRewards: selection.CardRewards,
Source: selection.Source,
MinCards: selection.MinCards,
MaxCards: selection.MaxCards,
}
}
// convertPendingCardDrawSelection converts PendingCardDrawSelection to DTO with playability state
func convertPendingCardDrawSelection(selection *shared.PendingCardDrawSelection, p *player.Player, g *game.Game, cardRegistry cards.CardRegistry) *PendingCardDrawSelectionDto {
if selection == nil {
return nil
}
availableCards := make([]PlayerCardDto, 0, len(selection.AvailableCards))
for _, cardID := range selection.AvailableCards {
card, err := cardRegistry.GetByID(cardID)
if err != nil {
continue
}
state := action.CalculatePendingCardPlayability(card, p, g, cardRegistry)
availableCards = append(availableCards, ToPlayerCardDto(card, state))
}
return &PendingCardDrawSelectionDto{
AvailableCards: availableCards,
FreeTakeCount: selection.FreeTakeCount,
MaxBuyCount: selection.MaxBuyCount,
CardBuyCost: selection.CardBuyCost,
Source: selection.Source,
PlayAsPrelude: selection.PlayAsPrelude,
}
}
// convertPendingCardDiscardSelection converts PendingCardDiscardSelection to DTO
func convertPendingCardDiscardSelection(selection *shared.PendingCardDiscardSelection) *PendingCardDiscardSelectionDto {
if selection == nil {
return nil
}
return &PendingCardDiscardSelectionDto{
MinCards: selection.MinCards,
MaxCards: selection.MaxCards,
Source: selection.Source,
SourceCardID: selection.SourceCardID,
}
}
func convertPendingBehaviorChoiceSelection(selection *shared.PendingBehaviorChoiceSelection, p *player.Player, g *game.Game, cardRegistry cards.CardRegistry) *PendingBehaviorChoiceSelectionDto {
if selection == nil {
return nil
}
choices := make([]ChoiceDto, len(selection.Choices))
for i, choice := range selection.Choices {
choices[i] = toChoiceDtoWithState(i, choice, p, g, cardRegistry)
}
return &PendingBehaviorChoiceSelectionDto{
Choices: choices,
Source: selection.Source,
SourceCardID: selection.SourceCardID,
}
}
func convertPendingStealTargetSelection(selection *shared.PendingStealTargetSelection) *PendingStealTargetSelectionDto {
if selection == nil {
return nil
}
return &PendingStealTargetSelectionDto{
EligiblePlayerIDs: selection.EligiblePlayerIDs,
ResourceType: string(selection.ResourceType),
Amount: selection.Amount,
Source: selection.Source,
SourceCardID: selection.SourceCardID,
}
}
func convertPendingColonyResourceFromQueue(queue []shared.PendingColonyResourceSelection) *PendingColonyResourceSelectionDto {
if len(queue) == 0 {
return nil
}
selection := queue[0]
return &PendingColonyResourceSelectionDto{
ResourceType: selection.ResourceType,
Amount: selection.Amount,
Source: selection.Source,
ColonyID: selection.ColonyID,
Reason: ColonyResourceReason(selection.Reason),
}
}
// toChoiceDtoWithState maps a choice to DTO with computed errors from the state calculator.
func toChoiceDtoWithState(index int, choice shared.Choice, p *player.Player, g *game.Game, cardRegistry cards.CardRegistry) ChoiceDto {
errors := action.CalculateChoiceErrors(choice, p, g, cardRegistry)
return ChoiceDto{
OriginalIndex: index,
Inputs: mapSlice(choice.Inputs, toResourceConditionDto),
Outputs: mapSlice(choice.Outputs, toResourceConditionDto),
Requirements: toChoiceRequirementsDto(choice.Requirements),
Available: len(errors) == 0,
Errors: convertStateErrors(errors),
}
}
func convertPendingAwardFundSelection(selection *shared.PendingAwardFundSelection) *PendingAwardFundSelectionDto {
if selection == nil {
return nil
}
return &PendingAwardFundSelectionDto{
AvailableAwards: selection.AvailableAwards,
Source: selection.Source,
}
}
func convertPendingColonySelection(selection *shared.PendingColonySelection) *PendingColonySelectionDto {
if selection == nil {
return nil
}
return &PendingColonySelectionDto{
AvailableColonyIDs: selection.AvailableColonyIDs,
AllowDuplicatePlayerColony: selection.AllowDuplicatePlayerColony,
Source: selection.Source,
SourceCardID: selection.SourceCardID,
}
}
func convertPendingFreeTradeSelection(selection *shared.PendingFreeTradeSelection) *PendingFreeTradeSelectionDto {
if selection == nil {
return nil
}
return &PendingFreeTradeSelectionDto{
AvailableColonyIDs: selection.AvailableColonyIDs,
Source: selection.Source,
SourceCardID: selection.SourceCardID,
}
}
// convertForcedFirstAction converts ForcedFirstAction to DTO
func convertForcedFirstAction(action *shared.ForcedFirstAction) *ForcedFirstActionDto {
if action == nil {
return nil
}
return &ForcedFirstActionDto{
ActionType: action.ActionType,
CorporationID: action.CorporationID,
Completed: action.Completed,
Description: action.Description,
}
}
// convertPendingTileSelection converts PendingTileSelection to DTO
func convertPendingTileSelection(selection *shared.PendingTileSelection) *PendingTileSelectionDto {
if selection == nil {
return nil
}
return &PendingTileSelectionDto{
TileType: selection.TileType,
AvailableHexes: selection.AvailableHexes,
Source: selection.Source,
}
}
// getAvailableActionsForPlayer returns the available actions for a player
// Actions are now at game level, so only the current player has actions
func getAvailableActionsForPlayer(g *game.Game, playerID string) int {
currentTurn := g.CurrentTurn()
if currentTurn == nil {
return 0
}
if currentTurn.PlayerID() == playerID {
return currentTurn.ActionsRemaining()
}
return 0
}
// getTotalActionsForPlayer returns the total actions granted this turn
func getTotalActionsForPlayer(g *game.Game, playerID string) int {
currentTurn := g.CurrentTurn()
if currentTurn == nil {
return 0
}
if currentTurn.PlayerID() == playerID {
return currentTurn.TotalActions()
}
return 0
}
// convertStateErrors converts EntityState errors to DTOs.
// Since domain and DTO enums have identical string values, we cast between them.
func convertStateErrors(errors []player.StateError) []StateErrorDto {
result := make([]StateErrorDto, len(errors))
for i, err := range errors {
result[i] = StateErrorDto{
Code: StateErrorCode(err.Code),
Category: StateErrorCategory(err.Category),
Message: err.Message,
}
}
return result
}
// convertStateWarnings converts EntityState warnings to DTOs.
// Since domain and DTO enums have identical string values, we cast between them.
func convertStateWarnings(warnings []player.StateWarning) []StateWarningDto {
if len(warnings) == 0 {
return nil
}
result := make([]StateWarningDto, len(warnings))
for i, warn := range warnings {
result[i] = StateWarningDto{
Code: StateWarningCode(warn.Code),
Message: warn.Message,
}
}
return result
}
// ToPlayerCardDto converts a card and its computed state to a PlayerCardDto.
func ToPlayerCardDto(card *gamecards.Card, state player.EntityState) PlayerCardDto {
discounts := make(map[string]int)
if discountData, ok := state.Metadata["discounts"].(map[string]int); ok {
discounts = discountData
}
tags := make([]CardTag, len(card.Tags))
for i, tag := range card.Tags {
tags[i] = CardTag(tag)
}
requirements := toCardRequirementsDto(card.Requirements)
var behaviors []CardBehaviorDto
if len(card.Behaviors) > 0 {
behaviors = make([]CardBehaviorDto, len(card.Behaviors))
for i, behavior := range card.Behaviors {
behaviors[i] = toCardBehaviorDto(behavior)
}
}
var resourceStorage *ResourceStorageDto
if card.ResourceStorage != nil {
storage := toResourceStorageDto(*card.ResourceStorage)
resourceStorage = &storage
}
var vpConditions []VPConditionDto
if len(card.VPConditions) > 0 {
vpConditions = make([]VPConditionDto, len(card.VPConditions))
for i, vp := range card.VPConditions {
vpConditions[i] = toVPConditionDto(vp)
}
}
effectiveCost := 0
if state.Cost != nil {
if credits, ok := state.Cost[string(shared.ResourceCredit)]; ok {
effectiveCost = credits
}
}
return PlayerCardDto{
ID: card.ID,
Name: card.Name,
Type: CardType(card.Type),
Cost: card.Cost,
Description: card.Description,
Pack: card.Pack,
Tags: tags,
Requirements: requirements,
Behaviors: behaviors,
ResourceStorage: resourceStorage,
VPConditions: vpConditions,
Available: state.Available(),
Errors: convertStateErrors(state.Errors),
Warnings: convertStateWarnings(state.Warnings),
EffectiveCost: effectiveCost,
Discounts: discounts,
ComputedValues: convertComputedValues(state.ComputedValues),
}
}
// mapPlayerCards converts hand cards to DTOs using CardStateStore for cached state.
// Enriches behavior choices with computed errors from the state calculator.
func mapPlayerCards(p *player.Player, g *game.Game, cardRegistry cards.CardRegistry) []PlayerCardDto {
handCardIDs := p.Hand().Cards()
result := make([]PlayerCardDto, 0, len(handCardIDs))
for _, cardID := range handCardIDs {
state, exists := p.CardStateStore().GetState(cardID)
if !exists {
continue
}
card, err := cardRegistry.GetByID(cardID)
if err != nil {
continue
}
dto := ToPlayerCardDto(card, state)
// Enrich choices with computed errors and apply choice policy filtering
for bi, behavior := range card.Behaviors {
if bi < len(dto.Behaviors) {
if behavior.ChoicePolicy != nil && len(dto.Behaviors[bi].Choices) > 0 {
production := p.Resources().Production()
validIndices := shared.FilterChoiceIndicesByPolicy(behavior.Choices, behavior.ChoicePolicy, production)
filtered := make([]ChoiceDto, 0, len(validIndices))
for _, idx := range validIndices {
choiceDto := dto.Behaviors[bi].Choices[idx]
choiceDto.OriginalIndex = idx
choiceErrors := action.CalculateChoiceErrors(behavior.Choices[idx], p, g, cardRegistry)
choiceDto.Available = len(choiceErrors) == 0
choiceDto.Errors = convertStateErrors(choiceErrors)
filtered = append(filtered, choiceDto)
}
dto.Behaviors[bi].Choices = filtered
} else {
for ci, choice := range behavior.Choices {
if ci < len(dto.Behaviors[bi].Choices) {
choiceErrors := action.CalculateChoiceErrors(choice, p, g, cardRegistry)
dto.Behaviors[bi].Choices[ci].Available = len(choiceErrors) == 0
dto.Behaviors[bi].Choices[ci].Errors = convertStateErrors(choiceErrors)
}
}
}
}
}
result = append(result, dto)
}
return result
}
// mapPlayerStandardProjects calculates state for all standard projects and converts to DTOs.
// Uses the state calculator to compute availability and effective costs for each project.
// NOTE: Conversion projects (plants→greenery, heat→temperature) are NOT included here - they
// are handled separately via resource buttons in the bottom bar.
func mapPlayerStandardProjects(p *player.Player, g *game.Game, cardRegistry cards.CardRegistry, stdProjRegistry standardprojects.StandardProjectRegistry) []PlayerStandardProjectDto {
if stdProjRegistry == nil {
return nil
}
allDefinitions := stdProjRegistry.GetAll()
settings := g.Settings()
enabledPacks := make(map[string]bool, len(settings.CardPacks))
for _, pack := range settings.CardPacks {
enabledPacks[pack] = true
}
if settings.VenusNextEnabled {
enabledPacks[shared.PackVenus] = true
}
result := make([]PlayerStandardProjectDto, 0, len(allDefinitions))
for _, def := range allDefinitions {
if def.Pack != "" && !enabledPacks[def.Pack] {
continue
}
projectType := shared.StandardProject(def.ID)
state := action.CalculatePlayerStandardProjectState(projectType, p, g, cardRegistry)
baseCost := map[string]int{}
if creditCost := def.CreditCost(); creditCost > 0 {
baseCost[string(shared.ResourceCredit)] = creditCost
}
discounts := make(map[string]int)
if discountData, ok := state.Metadata["discounts"].(map[string]int); ok {
discounts = discountData
}
behaviorDtos := make([]CardBehaviorDto, 0, len(def.Behaviors))
for _, b := range def.Behaviors {
behaviorDtos = append(behaviorDtos, toCardBehaviorDto(b))
}
dto := PlayerStandardProjectDto{
ProjectType: def.ID,
Name: def.Name,
Description: def.Description,
Behaviors: behaviorDtos,
Style: &StyleDto{Color: def.Style.Color, Icon: def.Style.Icon},
BaseCost: baseCost,
Available: state.Available(),
Errors: convertStateErrors(state.Errors),
Warnings: convertStateWarnings(state.Warnings),
EffectiveCost: state.Cost,
Discounts: discounts,
Metadata: state.Metadata,
}
result = append(result, dto)
}
return result
}
// mapPlayerMilestones calculates state for all milestones and converts to DTOs.
// Uses the state calculator to compute availability on-the-fly (same pattern as standard projects).
func mapPlayerMilestones(p *player.Player, g *game.Game, cardRegistry cards.CardRegistry, milestoneRegistry milestones.MilestoneRegistry) []PlayerMilestoneDto {
if milestoneRegistry == nil {
return nil
}
filteredDefs := filterMilestones(milestoneRegistry.GetAll(), g.SelectedMilestones(), g.Settings())
result := make([]PlayerMilestoneDto, 0, len(filteredDefs))
gameMilestones := g.Milestones()
for _, def := range filteredDefs {
milestoneType := shared.MilestoneType(def.ID)
state := action.CalculateMilestoneState(milestoneType, p, g, cardRegistry, milestoneRegistry)
isClaimed := gameMilestones.IsClaimed(milestoneType)
var claimedBy *string
for _, claimed := range gameMilestones.ClaimedMilestones() {
if claimed.Type == milestoneType {
claimedBy = &claimed.PlayerID
break
}
}
progress := 0
if prog, ok := state.Metadata["progress"].(int); ok {
progress = prog
}
rewardDtos := buildMilestoneRewardDtos(def.Reward)
var styleDtoPtr *StyleDto
if def.Style.Color != "" || def.Style.Icon != "" {
styleDtoPtr = &StyleDto{Color: def.Style.Color, Icon: def.Style.Icon}
}
dto := PlayerMilestoneDto{
Type: def.ID,
Name: def.Name,
Description: def.Description,
ClaimCost: def.ClaimCost,
IsClaimed: isClaimed,
ClaimedBy: claimedBy,
Available: state.Available(),
Progress: progress,
Required: def.GetRequired(),
Errors: convertStateErrors(state.Errors),
Reward: rewardDtos,
Style: styleDtoPtr,
}
result = append(result, dto)
}
return result
}
// convertGenerationalEvents converts PlayerGenerationalEventEntry slice to DTO slice
func convertGenerationalEvents(entries []shared.PlayerGenerationalEventEntry) []PlayerGenerationalEventEntryDto {
if len(entries) == 0 {
return []PlayerGenerationalEventEntryDto{}
}
dtos := make([]PlayerGenerationalEventEntryDto, len(entries))
for i, entry := range entries {
dtos[i] = PlayerGenerationalEventEntryDto{
Event: GenerationalEvent(entry.Event),
Count: entry.Count,
}
}
return dtos
}
// mapPlayerAwards calculates state for all awards and converts to DTOs.
// Uses the state calculator to compute availability on-the-fly (same pattern as standard projects).
func mapPlayerAwards(p *player.Player, g *game.Game, awardRegistry awards.AwardRegistry) []PlayerAwardDto {
if awardRegistry == nil {
return nil
}
filteredDefs := filterAwards(awardRegistry.GetAll(), g.SelectedAwards(), g.Settings())
result := make([]PlayerAwardDto, 0, len(filteredDefs))
gameAwards := g.Awards()
fundedCount := gameAwards.FundedCount()
for _, def := range filteredDefs {
awardType := shared.AwardType(def.ID)
state := action.CalculateAwardState(awardType, p, g, awardRegistry)
isFunded := gameAwards.IsFunded(awardType)
var fundedBy *string
for _, funded := range gameAwards.FundedAwards() {
if funded.Type == awardType {
fundedBy = &funded.FundedByPlayer
break
}
}
var styleDtoPtr *StyleDto
if def.Style.Color != "" || def.Style.Icon != "" {
styleDtoPtr = &StyleDto{Color: def.Style.Color, Icon: def.Style.Icon}
}
dto := PlayerAwardDto{
Type: def.ID,
Name: def.Name,
Description: def.Description,
FundingCost: def.GetCostForFundedCount(fundedCount),
IsFunded: isFunded,
FundedBy: fundedBy,
Available: state.Available(),
Errors: convertStateErrors(state.Errors),
Style: styleDtoPtr,
}
result = append(result, dto)
}
return result
}
func mapPlayerActionCosts(p *player.Player, g *game.Game, cardRegistry cards.CardRegistry) []ActionCostDto {
calc := gamecards.NewRequirementModifierCalculator(cardRegistry)
cardBuyDiscounts := calc.CalculateActionDiscounts(p, shared.ActionCardBuying)
creditDiscount := cardBuyDiscounts[shared.ResourceCredit]
cardBuyBase := 3
cardBuyEffective := cardBuyBase - creditDiscount
if cardBuyEffective < 0 {
cardBuyEffective = 0
}
result := []ActionCostDto{
{
ActionType: shared.ActionCardBuying,
Costs: []ActionCostEntryDto{
{
Resource: string(shared.ResourceCredit),
BaseCost: cardBuyBase,
EffectiveCost: cardBuyEffective,
Discount: creditDiscount,
},
},
},
}
if g.HasColonies() {
tradeDiscounts := calc.CalculateActionDiscounts(p, shared.ActionColonyTrade)
tradeCosts := []ActionCostEntryDto{
{
Resource: string(shared.ResourceCredit),
BaseCost: 9,
EffectiveCost: max(9-tradeDiscounts[shared.ResourceCredit], 0),
Discount: tradeDiscounts[shared.ResourceCredit],
},
{
Resource: string(shared.ResourceEnergy),
BaseCost: 3,
EffectiveCost: max(3-tradeDiscounts[shared.ResourceEnergy], 0),
Discount: tradeDiscounts[shared.ResourceEnergy],
},
{
Resource: string(shared.ResourceTitanium),
BaseCost: 3,
EffectiveCost: max(3-tradeDiscounts[shared.ResourceTitanium], 0),
Discount: tradeDiscounts[shared.ResourceTitanium],
},
}
result = append(result, ActionCostDto{
ActionType: shared.ActionColonyTrade,
Costs: tradeCosts,
})
}
return result
}
package dto
import (
"terraforming-mars-backend/internal/awards"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/board"
"terraforming-mars-backend/internal/game/shared"
"terraforming-mars-backend/internal/milestones"
)
// ToSpectatorGameDto creates a GameDto for spectators where all players are shown
// as OtherPlayerDto (no hidden information like hand cards or pending selections).
func ToSpectatorGameDto(g *game.Game, cardRegistry cards.CardRegistry, awardRegistry awards.AwardRegistry, milestoneRegistry milestones.MilestoneRegistry) GameDto {
players := g.GetAllPlayers()
otherPlayers := make([]OtherPlayerDto, 0, len(players))
for _, p := range players {
otherPlayers = append(otherPlayers, ToOtherPlayerDto(p, g, cardRegistry))
}
settings := g.Settings()
settingsDto := GameSettingsDto{
MaxPlayers: settings.MaxPlayers,
VenusNextEnabled: settings.VenusNextEnabled,
DevelopmentMode: settings.DevelopmentMode,
DemoGame: settings.DemoGame,
CardPacks: settings.CardPacks,
HasClaudeAPIKey: settings.ClaudeAPIKey != "",
ClaudeModel: settings.ClaudeModel,
AvailablePlayerColors: shared.PlayerColors,
}
globalParams := g.GlobalParameters()
globalParamsDto := GlobalParametersDto{
Temperature: globalParams.Temperature(),
Oxygen: globalParams.Oxygen(),
Oceans: globalParams.Oceans(),
MaxOceans: globalParams.GetMaxOceans(),
Venus: globalParams.Venus(),
Bonuses: buildGlobalParameterBonuses(settings.VenusNextEnabled),
}
tiles := g.Board().Tiles()
tileDtos := make([]TileDto, len(tiles))
for i, tile := range tiles {
tileDtos[i] = TileDto{
Coordinates: HexPositionDto{
Q: tile.Coordinates.Q,
R: tile.Coordinates.R,
S: tile.Coordinates.S,
},
Type: string(tile.Type),
OwnerID: tile.OwnerID,
Tags: tile.Tags,
Bonuses: convertTileBonuses(tile.Bonuses),
Location: string(tile.Location),
DisplayName: tile.DisplayName,
ReservedBy: tile.ReservedBy,
}
if tile.OccupiedBy != nil {
tileDtos[i].OccupiedBy = &TileOccupantDto{
Type: string(tile.OccupiedBy.Type),
Tags: tile.OccupiedBy.Tags,
}
}
}
paymentConstants := PaymentConstantsDto{
SteelValue: 2,
TitaniumValue: 3,
}
var finalScoreDtos []FinalScoreDto
if g.Status() == shared.GameStatusCompleted {
finalScores := g.GetFinalScores()
if finalScores != nil {
finalScoreDtos = make([]FinalScoreDto, len(finalScores))
for i, fs := range finalScores {
finalScoreDtos[i] = FinalScoreDto{
PlayerID: fs.PlayerID,
PlayerName: fs.PlayerName,
VPBreakdown: ToVPBreakdownDto(fs.Breakdown),
IsWinner: fs.IsWinner,
Placement: fs.Placement,
}
}
}
}
var initPhaseDto *InitPhaseDto
phase := g.CurrentPhase()
if phase == shared.GamePhaseInitApplyCorp || phase == shared.GamePhaseInitApplyPrelude {
turnOrder := g.TurnOrder()
idx := g.InitPhasePlayerIndex()
currentInitPlayerID := ""
if idx < len(turnOrder) {
currentInitPlayerID = turnOrder[idx]
}
activePlayers := 0
for _, p := range players {
if !p.HasExited() {
activePlayers++
}
}
hasPendingTiles := false
if currentInitPlayerID != "" {
hasPendingTiles = g.GetPendingTileSelection(currentInitPlayerID) != nil ||
g.GetPendingTileSelectionQueue(currentInitPlayerID) != nil
}
initPhaseDto = &InitPhaseDto{
CurrentPlayerID: currentInitPlayerID,
CurrentPlayerIndex: idx,
TotalPlayers: activePlayers,
WaitingForConfirm: g.InitPhaseWaitingForConfirm(),
ConfirmVersion: g.InitPhaseConfirmVersion(),
HasPreludePhase: g.Settings().HasPrelude(),
HasPendingTiles: hasPendingTiles,
}
}
triggeredEffects := g.GetTriggeredEffects()
var triggeredEffectDtos []TriggeredEffectDto
if len(triggeredEffects) > 0 {
triggeredEffectDtos = make([]TriggeredEffectDto, len(triggeredEffects))
for i, effect := range triggeredEffects {
triggeredEffectDtos[i] = ToTriggeredEffectDto(effect)
}
}
return GameDto{
ID: g.ID(),
Status: GameStatus(g.Status()),
Settings: settingsDto,
HostPlayerID: g.HostPlayerID(),
CurrentPhase: GamePhase(g.CurrentPhase()),
GlobalParameters: globalParamsDto,
CurrentPlayer: PlayerDto{},
OtherPlayers: otherPlayers,
ViewingPlayerID: "",
IsSpectator: true,
CurrentTurn: getCurrentTurnPlayerID(g),
Generation: g.Generation(),
TurnOrder: g.TurnOrder(),
Board: BoardDto{
Tiles: tileDtos,
},
PaymentConstants: paymentConstants,
Milestones: ToMilestonesDto(g, cardRegistry, milestoneRegistry),
Awards: ToAwardsDto(g, cardRegistry, awardRegistry),
AwardResults: ToAwardResultsDto(g, cardRegistry, awardRegistry),
FinalScores: finalScoreDtos,
TriggeredEffects: triggeredEffectDtos,
PlaceableTileTypes: ToPlaceableTileTypeDtos(),
InitPhase: initPhaseDto,
Spectators: toSpectatorDtos(g),
ChatMessages: toChatMessageDtos(g),
}
}
func convertTileBonusesForSpectator(bonuses []board.TileBonus) []TileBonusDto {
return convertTileBonuses(bonuses)
}
package dto
import (
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/shared"
)
// ToStateDiffDto converts a domain StateDiff to a DTO
func ToStateDiffDto(diff *game.StateDiff) StateDiffDto {
var calculatedOutputs []CalculatedOutputDto
if len(diff.CalculatedOutputs) > 0 {
calculatedOutputs = make([]CalculatedOutputDto, len(diff.CalculatedOutputs))
for i, output := range diff.CalculatedOutputs {
calculatedOutputs[i] = CalculatedOutputDto{
ResourceType: output.ResourceType,
Amount: output.Amount,
IsScaled: output.IsScaled,
}
}
}
return StateDiffDto{
SequenceNumber: diff.SequenceNumber,
Timestamp: diff.Timestamp.Format("2006-01-02T15:04:05.000Z"),
GameID: diff.GameID,
Changes: toGameChangesDto(diff.Changes),
Source: diff.Source,
SourceType: string(diff.SourceType),
PlayerID: diff.PlayerID,
Description: diff.Description,
ChoiceIndex: diff.ChoiceIndex,
CalculatedOutputs: calculatedOutputs,
DisplayData: toLogDisplayDataDto(diff.DisplayData),
}
}
// ToStateDiffDtos converts a slice of domain StateDiffs to DTOs
func ToStateDiffDtos(diffs []game.StateDiff) []StateDiffDto {
result := make([]StateDiffDto, len(diffs))
for i, diff := range diffs {
result[i] = ToStateDiffDto(&diff)
}
return result
}
// ToDiffLogDto converts a domain DiffLog to a DTO
func ToDiffLogDto(log *game.DiffLog) DiffLogDto {
return DiffLogDto{
GameID: log.GameID,
Diffs: ToStateDiffDtos(log.Diffs),
CurrentSequence: log.CurrentSequence,
}
}
func toGameChangesDto(changes *game.GameChanges) *GameChangesDto {
if changes == nil {
return nil
}
dto := &GameChangesDto{}
if changes.Status != nil {
dto.Status = &DiffValueStringDto{Old: changes.Status.Old, New: changes.Status.New}
}
if changes.Phase != nil {
dto.Phase = &DiffValueStringDto{Old: changes.Phase.Old, New: changes.Phase.New}
}
if changes.Generation != nil {
dto.Generation = &DiffValueIntDto{Old: changes.Generation.Old, New: changes.Generation.New}
}
if changes.CurrentTurnPlayerID != nil {
dto.CurrentTurnPlayerID = &DiffValueStringDto{Old: changes.CurrentTurnPlayerID.Old, New: changes.CurrentTurnPlayerID.New}
}
if changes.Temperature != nil {
dto.Temperature = &DiffValueIntDto{Old: changes.Temperature.Old, New: changes.Temperature.New}
}
if changes.Oxygen != nil {
dto.Oxygen = &DiffValueIntDto{Old: changes.Oxygen.Old, New: changes.Oxygen.New}
}
if changes.Oceans != nil {
dto.Oceans = &DiffValueIntDto{Old: changes.Oceans.Old, New: changes.Oceans.New}
}
if len(changes.PlayerChanges) > 0 {
dto.PlayerChanges = make(map[string]*PlayerChangesDto)
for playerID, pc := range changes.PlayerChanges {
dto.PlayerChanges[playerID] = toPlayerChangesDto(pc)
}
}
if changes.BoardChanges != nil {
dto.BoardChanges = toBoardChangesDto(changes.BoardChanges)
}
return dto
}
func toPlayerChangesDto(changes *game.PlayerChanges) *PlayerChangesDto {
if changes == nil {
return nil
}
dto := &PlayerChangesDto{}
if changes.Credits != nil {
dto.Credits = &DiffValueIntDto{Old: changes.Credits.Old, New: changes.Credits.New}
}
if changes.Steel != nil {
dto.Steel = &DiffValueIntDto{Old: changes.Steel.Old, New: changes.Steel.New}
}
if changes.Titanium != nil {
dto.Titanium = &DiffValueIntDto{Old: changes.Titanium.Old, New: changes.Titanium.New}
}
if changes.Plants != nil {
dto.Plants = &DiffValueIntDto{Old: changes.Plants.Old, New: changes.Plants.New}
}
if changes.Energy != nil {
dto.Energy = &DiffValueIntDto{Old: changes.Energy.Old, New: changes.Energy.New}
}
if changes.Heat != nil {
dto.Heat = &DiffValueIntDto{Old: changes.Heat.Old, New: changes.Heat.New}
}
if changes.TerraformRating != nil {
dto.TerraformRating = &DiffValueIntDto{Old: changes.TerraformRating.Old, New: changes.TerraformRating.New}
}
if changes.CreditsProduction != nil {
dto.CreditsProduction = &DiffValueIntDto{Old: changes.CreditsProduction.Old, New: changes.CreditsProduction.New}
}
if changes.SteelProduction != nil {
dto.SteelProduction = &DiffValueIntDto{Old: changes.SteelProduction.Old, New: changes.SteelProduction.New}
}
if changes.TitaniumProduction != nil {
dto.TitaniumProduction = &DiffValueIntDto{Old: changes.TitaniumProduction.Old, New: changes.TitaniumProduction.New}
}
if changes.PlantsProduction != nil {
dto.PlantsProduction = &DiffValueIntDto{Old: changes.PlantsProduction.Old, New: changes.PlantsProduction.New}
}
if changes.EnergyProduction != nil {
dto.EnergyProduction = &DiffValueIntDto{Old: changes.EnergyProduction.Old, New: changes.EnergyProduction.New}
}
if changes.HeatProduction != nil {
dto.HeatProduction = &DiffValueIntDto{Old: changes.HeatProduction.Old, New: changes.HeatProduction.New}
}
if len(changes.CardsAdded) > 0 {
dto.CardsAdded = changes.CardsAdded
}
if len(changes.CardsRemoved) > 0 {
dto.CardsRemoved = changes.CardsRemoved
}
if len(changes.CardsPlayed) > 0 {
dto.CardsPlayed = changes.CardsPlayed
}
if changes.Corporation != nil {
dto.Corporation = &DiffValueStringDto{Old: changes.Corporation.Old, New: changes.Corporation.New}
}
if changes.Passed != nil {
dto.Passed = &DiffValueBoolDto{Old: changes.Passed.Old, New: changes.Passed.New}
}
return dto
}
func toBoardChangesDto(changes *game.BoardChanges) *BoardChangesDto {
if changes == nil || len(changes.TilesPlaced) == 0 {
return nil
}
placements := make([]TilePlacementDto, len(changes.TilesPlaced))
for i, tp := range changes.TilesPlaced {
placements[i] = TilePlacementDto{
HexID: tp.HexID,
TileType: tp.TileType,
OwnerID: tp.OwnerID,
}
}
return &BoardChangesDto{TilesPlaced: placements}
}
func toLogDisplayDataDto(data *game.LogDisplayData) *LogDisplayDataDto {
if data == nil {
return nil
}
return &LogDisplayDataDto{
Behaviors: mapSlice(data.Behaviors, toCardBehaviorDto),
Tags: mapSlice(data.Tags, func(t shared.CardTag) CardTag { return CardTag(t) }),
VPConditions: mapSlice(data.VPConditions, toVPConditionForLogDto),
}
}
func toVPConditionForLogDto(vp shared.VPConditionForLog) VPConditionDto {
return VPConditionDto{
Amount: vp.Amount,
Condition: VPConditionType(vp.Condition),
MaxTrigger: vp.MaxTrigger,
Per: ptrCast(vp.Per, toPerConditionDto),
}
}
package core
import (
"context"
)
// ActionHandler defines the interface that all action handlers must implement
type ActionHandler interface {
Handle(ctx context.Context, gameID, playerID string, actionRequest interface{}) error
}
// ActionHandlerFunc is a function adapter that allows ordinary functions to be used as ActionHandlers
type ActionHandlerFunc func(ctx context.Context, gameID, playerID string, actionRequest interface{}) error
// Handle implements the ActionHandler interface for ActionHandlerFunc
func (f ActionHandlerFunc) Handle(ctx context.Context, gameID, playerID string, actionRequest interface{}) error {
return f(ctx, gameID, playerID, actionRequest)
}
package core
import (
"fmt"
"sync"
"terraforming-mars-backend/internal/delivery/dto"
)
// ActionRegistry manages the registration and lookup of action handlers
type ActionRegistry struct {
mu sync.RWMutex
handlers map[dto.ActionType]ActionHandler
}
// NewActionRegistry creates a new action registry
func NewActionRegistry() *ActionRegistry {
return &ActionRegistry{
handlers: make(map[dto.ActionType]ActionHandler),
}
}
// Register registers an action handler for a specific action type
func (ar *ActionRegistry) Register(actionType dto.ActionType, handler ActionHandler) {
ar.mu.Lock()
defer ar.mu.Unlock()
ar.handlers[actionType] = handler
}
// GetHandler retrieves a handler for the specified action type
func (ar *ActionRegistry) GetHandler(actionType dto.ActionType) (ActionHandler, error) {
ar.mu.RLock()
defer ar.mu.RUnlock()
handler, exists := ar.handlers[actionType]
if !exists {
return nil, fmt.Errorf("no handler registered for action type: %s", actionType)
}
return handler, nil
}
package core
import (
"sync"
"time"
"terraforming-mars-backend/internal/delivery/dto"
"terraforming-mars-backend/internal/logger"
"github.com/gorilla/websocket"
"go.uber.org/zap"
)
const (
// Time allowed to write a message to the peer
writeWait = 10 * time.Second
// Time allowed to read the next pong message from the peer
pongWait = 60 * time.Second
// Send pings to peer with this period (must be less than pongWait)
pingPeriod = (pongWait * 9) / 10
// Maximum message size allowed from peer (64KB for game state updates)
maxMessageSize = 64 * 1024
)
// ConnectionType distinguishes player connections from spectator connections.
type ConnectionType string
const (
ConnectionTypePlayer ConnectionType = "player"
ConnectionTypeSpectator ConnectionType = "spectator"
)
// Connection represents a WebSocket connection
type Connection struct {
ID string
PlayerID string
SpectatorID string
GameID string
ConnType ConnectionType
Conn *websocket.Conn
Send chan dto.WebSocketMessage
// Callbacks for hub communication
onMessage func(HubMessage)
onDisconnect func(*Connection)
// Direct reference to manager for game association
manager *Manager
// Synchronization
mu sync.RWMutex
logger *zap.Logger
Done chan struct{}
closeOnce sync.Once
sendClosed bool
}
// NewConnection creates a new WebSocket connection
func NewConnection(id string, conn *websocket.Conn, manager *Manager, onMessage func(HubMessage), onDisconnect func(*Connection)) *Connection {
return &Connection{
ID: id,
ConnType: ConnectionTypePlayer,
Conn: conn,
Send: make(chan dto.WebSocketMessage, 256),
onMessage: onMessage,
onDisconnect: onDisconnect,
manager: manager,
logger: logger.Get(),
Done: make(chan struct{}),
}
}
// SetPlayer associates this connection with a player
func (c *Connection) SetPlayer(playerID, gameID string) {
c.mu.Lock()
c.PlayerID = playerID
c.GameID = gameID
c.ConnType = ConnectionTypePlayer
c.mu.Unlock()
if c.manager != nil && gameID != "" {
c.manager.AddToGame(c, gameID)
}
}
// SetSpectator associates this connection with a spectator.
func (c *Connection) SetSpectator(spectatorID, gameID string) {
c.mu.Lock()
c.SpectatorID = spectatorID
c.GameID = gameID
c.ConnType = ConnectionTypeSpectator
c.mu.Unlock()
if c.manager != nil && gameID != "" {
c.manager.AddToGame(c, gameID)
}
}
// IsSpectator returns true if this connection is a spectator.
func (c *Connection) IsSpectator() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.ConnType == ConnectionTypeSpectator
}
// GetPlayer returns the player and game IDs for this connection
func (c *Connection) GetPlayer() (playerID, gameID string) {
c.mu.RLock()
defer c.mu.RUnlock()
return c.PlayerID, c.GameID
}
// CloseSend closes the send channel
func (c *Connection) CloseSend() {
c.mu.Lock()
defer c.mu.Unlock()
if !c.sendClosed {
close(c.Send)
c.sendClosed = true
}
}
// Close closes the connection and signals all associated goroutines
func (c *Connection) Close() {
c.closeOnce.Do(func() {
close(c.Done)
if err := c.Conn.Close(); err != nil {
c.logger.Debug("Best-effort connection close", zap.Error(err), zap.String("connection_id", c.ID))
}
})
}
// ReadPump pumps messages from the websocket connection to the hub
func (c *Connection) ReadPump() {
defer func() {
if c.onDisconnect != nil {
c.onDisconnect(c)
}
c.Close()
}()
c.Conn.SetReadLimit(maxMessageSize)
if err := c.Conn.SetReadDeadline(time.Now().Add(pongWait)); err != nil {
c.logger.Warn("Failed to set initial read deadline", zap.Error(err), zap.String("connection_id", c.ID))
}
c.Conn.SetPongHandler(func(string) error {
if err := c.Conn.SetReadDeadline(time.Now().Add(pongWait)); err != nil {
c.logger.Warn("Failed to set read deadline in pong handler", zap.Error(err), zap.String("connection_id", c.ID))
}
return nil
})
c.logger.Debug("Starting ReadPump for connection", zap.String("connection_id", c.ID))
for {
select {
case <-c.Done:
return
default:
var message dto.WebSocketMessage
if err := c.Conn.ReadJSON(&message); err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure, websocket.CloseNoStatusReceived) {
c.logger.Error("WebSocket read error", zap.Error(err), zap.String("connection_id", c.ID))
}
return
}
c.logger.Debug("Received WebSocket message",
zap.String("connection_id", c.ID),
zap.String("message_type", string(message.Type)))
// Send message to hub for processing via callback
if c.onMessage != nil {
c.onMessage(HubMessage{Connection: c, Message: message})
}
}
}
}
// WritePump pumps messages from the hub to the websocket connection
func (c *Connection) WritePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
if err := c.Conn.Close(); err != nil {
c.logger.Debug("Best-effort connection close in WritePump", zap.Error(err), zap.String("connection_id", c.ID))
}
}()
for {
select {
case message, ok := <-c.Send:
if err := c.Conn.SetWriteDeadline(time.Now().Add(writeWait)); err != nil {
c.logger.Warn("Failed to set write deadline", zap.Error(err), zap.String("connection_id", c.ID))
}
if !ok {
if err := c.Conn.WriteMessage(websocket.CloseMessage, []byte{}); err != nil {
c.logger.Debug("Best-effort close message write", zap.Error(err), zap.String("connection_id", c.ID))
}
return
}
if err := c.Conn.WriteJSON(message); err != nil {
c.logger.Error("WebSocket write error", zap.Error(err), zap.String("connection_id", c.ID))
return
}
case <-ticker.C:
if err := c.Conn.SetWriteDeadline(time.Now().Add(writeWait)); err != nil {
c.logger.Warn("Failed to set write deadline for ping", zap.Error(err), zap.String("connection_id", c.ID))
}
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
case <-c.Done:
return
}
}
}
// SendMessage sends a message to this connection
func (c *Connection) SendMessage(message dto.WebSocketMessage) {
c.mu.RLock()
sendClosed := c.sendClosed
c.mu.RUnlock()
if sendClosed {
c.logger.Debug("Attempted to send message to closed connection", zap.String("connection_id", c.ID))
return
}
select {
case c.Send <- message:
c.logger.Debug("Message queued for client",
zap.String("connection_id", c.ID),
zap.String("message_type", string(message.Type)))
case <-c.Done:
c.logger.Debug("Connection closing, message not sent", zap.String("connection_id", c.ID))
default:
c.logger.Warn("Message channel full, dropping message",
zap.String("connection_id", c.ID),
zap.String("message_type", string(message.Type)))
}
}
package core
import (
"net/http"
"time"
"terraforming-mars-backend/internal/logger"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"go.uber.org/zap"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
// Allow all origins in development - should be restricted in production
return true
},
}
// Handler handles WebSocket HTTP upgrade requests
type Handler struct {
hub *Hub
logger *zap.Logger
}
// NewHandler creates a new WebSocket handler
func NewHandler(hub *Hub) *Handler {
return &Handler{
hub: hub,
logger: logger.Get(),
}
}
// ServeWS handles WebSocket upgrade requests from clients
func (h *Handler) ServeWS(w http.ResponseWriter, r *http.Request) {
h.logger.Debug("WebSocket connection request received", zap.String("remote_addr", r.RemoteAddr))
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
h.logger.Error("Failed to upgrade connection to WebSocket", zap.Error(err))
return
}
// Create connection ID and connection object
connectionID := uuid.New().String()
connection := NewConnection(connectionID, conn,
h.hub.GetManager(), // Direct manager reference
func(msg HubMessage) { h.hub.Messages <- msg }, // onMessage callback
func(conn *Connection) { h.hub.Unregister <- conn }) // onDisconnect callback
h.logger.Debug("New WebSocket connection established",
zap.String("connection_id", connectionID),
zap.String("remote_addr", r.RemoteAddr))
h.hub.Register <- connection
if err := conn.SetReadDeadline(time.Now().Add(60 * time.Second)); err != nil {
h.logger.Warn("Failed to set initial read deadline", zap.Error(err), zap.String("connection_id", connectionID))
}
if err := conn.SetWriteDeadline(time.Now().Add(10 * time.Second)); err != nil {
h.logger.Warn("Failed to set initial write deadline", zap.Error(err), zap.String("connection_id", connectionID))
}
conn.SetPongHandler(func(string) error {
if err := conn.SetReadDeadline(time.Now().Add(60 * time.Second)); err != nil {
h.logger.Warn("Failed to set read deadline in pong handler", zap.Error(err), zap.String("connection_id", connectionID))
}
return nil
})
go connection.WritePump()
go connection.ReadPump()
h.logger.Debug("WebSocket connection fully initialized", zap.String("connection_id", connectionID))
}
package core
import (
"context"
"terraforming-mars-backend/internal/delivery/dto"
"terraforming-mars-backend/internal/logger"
"go.uber.org/zap"
)
// MessageHandler defines the interface for handling different message types
type MessageHandler interface {
HandleMessage(ctx context.Context, connection *Connection, message dto.WebSocketMessage)
}
// HubMessage represents a message to be processed by the hub
type HubMessage struct {
Connection *Connection
Message dto.WebSocketMessage
}
// EventHandler interface for handling domain events
type EventHandler interface {
}
// Hub manages WebSocket connections and message routing
type Hub struct {
Register chan *Connection
Unregister chan *Connection
Messages chan HubMessage
manager *Manager
logger *zap.Logger
handlers map[dto.MessageType]MessageHandler
}
// NewHub creates a new WebSocket hub with clean architecture
func NewHub() *Hub {
manager := NewManager()
return &Hub{
Register: make(chan *Connection),
Unregister: make(chan *Connection),
Messages: make(chan HubMessage),
manager: manager,
logger: logger.Get(),
handlers: make(map[dto.MessageType]MessageHandler),
}
}
// Run starts the hub's main event loop
func (h *Hub) Run(ctx context.Context) {
h.logger.Debug("Starting WebSocket hub")
h.logger.Debug("WebSocket hub ready to process messages")
for {
select {
case <-ctx.Done():
h.logger.Debug("WebSocket hub shutting down")
h.manager.CloseAllConnections()
return
case connection := <-h.Register:
h.manager.RegisterConnection(connection)
// Session registration will happen when first message is received
case connection := <-h.Unregister:
playerID, spectatorID, gameID, connType, shouldBroadcast := h.manager.UnregisterConnection(connection)
if shouldBroadcast {
var disconnectMessage dto.WebSocketMessage
if connType == ConnectionTypeSpectator {
disconnectMessage = dto.WebSocketMessage{
Type: dto.MessageTypeSpectatorDisconnected,
GameID: gameID,
Payload: dto.SpectatorDisconnectedPayload{
SpectatorID: spectatorID,
GameID: gameID,
},
}
} else {
disconnectMessage = dto.WebSocketMessage{
Type: dto.MessageTypePlayerDisconnected,
GameID: gameID,
Payload: dto.PlayerDisconnectedPayload{
PlayerID: playerID,
GameID: gameID,
},
}
}
hubMessage := HubMessage{
Connection: connection,
Message: disconnectMessage,
}
h.routeMessage(ctx, hubMessage)
}
case hubMessage := <-h.Messages:
// Route message to appropriate handler
h.routeMessage(ctx, hubMessage)
}
}
}
// RegisterHandler registers a message handler for a specific message type
func (h *Hub) RegisterHandler(messageType dto.MessageType, handler MessageHandler) {
h.handlers[messageType] = handler
}
// GetManager returns the connection manager
func (h *Hub) GetManager() *Manager {
return h.manager
}
// SendToPlayer sends a message to a specific player via their connection
func (h *Hub) SendToPlayer(gameID, playerID string, message dto.WebSocketMessage) error {
connection := h.manager.GetConnectionByPlayerID(gameID, playerID)
if connection == nil {
h.logger.Debug("No connection found for player",
zap.String("game_id", gameID),
zap.String("player_id", playerID))
return nil // Don't error, just skip sending (player might be disconnected)
}
connection.SendMessage(message)
h.logger.Debug("Message sent to player via Hub",
zap.String("game_id", gameID),
zap.String("player_id", playerID),
zap.String("message_type", string(message.Type)))
return nil
}
// SendToSpectator sends a message to a specific spectator via their connection.
func (h *Hub) SendToSpectator(gameID, spectatorID string, message dto.WebSocketMessage) error {
connection := h.manager.GetConnectionBySpectatorID(gameID, spectatorID)
if connection == nil {
return nil
}
connection.SendMessage(message)
return nil
}
// RegisterConnectionWithGame registers a connection with a game after player ID is set
func (h *Hub) RegisterConnectionWithGame(connection *Connection, gameID string) {
h.manager.AddToGame(connection, gameID)
h.logger.Debug("Connection registered with game",
zap.String("connection_id", connection.ID),
zap.String("game_id", gameID),
zap.String("player_id", connection.PlayerID))
}
// routeMessage routes incoming messages to appropriate handlers
func (h *Hub) routeMessage(ctx context.Context, hubMessage HubMessage) {
connection := hubMessage.Connection
message := hubMessage.Message
h.logger.Debug("Routing WebSocket message",
zap.String("connection_id", connection.ID),
zap.String("message_type", string(message.Type)))
if handler, exists := h.handlers[message.Type]; exists {
h.logger.Debug("Routing to registered message handler",
zap.String("message_type", string(message.Type)))
handler.HandleMessage(ctx, connection, message)
} else {
h.logger.Warn("Unknown message type",
zap.String("message_type", string(message.Type)))
h.sendError(connection, ErrUnknownMessageType)
}
}
// sendError sends an error message to a connection
func (h *Hub) sendError(connection *Connection, errorMessage string) {
_, gameID := connection.GetPlayer()
message := dto.WebSocketMessage{
Type: dto.MessageTypeError,
Payload: dto.ErrorPayload{
Message: errorMessage,
},
GameID: gameID,
}
connection.SendMessage(message)
}
// Hub no longer provides SessionManager - they're now separate components
// ClearConnections closes all active connections and clears the connection state
func (h *Hub) ClearConnections() {
h.manager.CloseAllConnections()
}
// Standard error messages for hub operations
const (
ErrHandlerNotAvailable = "Handler not available"
ErrUnknownMessageType = "Unknown message type"
)
package core
import (
"sync"
"terraforming-mars-backend/internal/logger"
"unsafe"
"go.uber.org/zap"
)
// Manager handles WebSocket connection lifecycle and organization
type Manager struct {
connections map[*Connection]bool
gameConnections map[string]map[*Connection]bool
mu sync.RWMutex
logger *zap.Logger
}
// NewManager creates a new connection manager
func NewManager() *Manager {
return &Manager{
connections: make(map[*Connection]bool),
gameConnections: make(map[string]map[*Connection]bool),
logger: logger.Get(),
}
}
// RegisterConnection registers a new connection
func (m *Manager) RegisterConnection(connection *Connection) {
m.mu.Lock()
defer m.mu.Unlock()
m.connections[connection] = true
m.logger.Debug("Client connected to server", zap.String("connection_id", connection.ID))
}
// UnregisterConnection unregisters a connection and handles cleanup.
// Returns connection metadata including whether it was a spectator.
func (m *Manager) UnregisterConnection(connection *Connection) (playerID, spectatorID, gameID string, connType ConnectionType, shouldBroadcast bool) {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.connections[connection]; !exists {
return "", "", "", ConnectionTypePlayer, false
}
delete(m.connections, connection)
connection.CloseSend()
playerID, gameID = connection.GetPlayer()
spectatorID = connection.SpectatorID
connType = connection.ConnType
if connType == ConnectionTypeSpectator {
shouldBroadcast = gameID != "" && spectatorID != ""
} else {
shouldBroadcast = gameID != "" && playerID != ""
}
// Remove from game connections
if gameConns, exists := m.gameConnections[gameID]; exists {
delete(gameConns, connection)
if len(gameConns) == 0 {
delete(m.gameConnections, gameID)
m.logger.Debug("Removed empty game connections map", zap.String("game_id", gameID))
}
}
connection.Close()
m.logger.Debug("Client disconnected from server",
zap.String("connection_id", connection.ID),
zap.String("player_id", playerID),
zap.String("spectator_id", spectatorID),
zap.String("game_id", gameID),
zap.String("conn_type", string(connType)))
return playerID, spectatorID, gameID, connType, shouldBroadcast
}
// AddToGame adds a connection to a game group
func (m *Manager) AddToGame(connection *Connection, gameID string) {
m.mu.Lock()
defer m.mu.Unlock()
if m.gameConnections[gameID] == nil {
m.gameConnections[gameID] = make(map[*Connection]bool)
}
m.gameConnections[gameID][connection] = true
}
// GetGameConnections returns all connections for a specific game (read-only copy)
func (m *Manager) GetGameConnections(gameID string) map[*Connection]bool {
m.mu.RLock()
defer m.mu.RUnlock()
gameConns := m.gameConnections[gameID]
if gameConns == nil {
return nil
}
connections := make(map[*Connection]bool, len(gameConns))
for conn := range gameConns {
connections[conn] = true
}
return connections
}
// GetConnectionCount returns the total number of registered connections
func (m *Manager) GetConnectionCount() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.connections)
}
// RemoveExistingPlayerConnection removes any existing connection for the given player
// This is used during reconnection to clean up old connections before adding new ones
// CRITICAL: excludeConnection should be the current connection making the request to avoid cleaning it up
func (m *Manager) RemoveExistingPlayerConnection(playerID, gameID string, excludeConnection *Connection) *Connection {
m.mu.Lock()
defer m.mu.Unlock()
var existingConnection *Connection
var matchingConnections []*Connection
m.logger.Debug("Starting connection cleanup search",
zap.String("player_id", playerID),
zap.String("game_id", gameID),
zap.String("exclude_connection_id", excludeConnection.ID),
zap.Uintptr("exclude_connection_ptr", uintptr(unsafe.Pointer(excludeConnection))))
for connection := range m.connections {
existingPlayerID, existingGameID := connection.GetPlayer()
if existingPlayerID == playerID && existingGameID == gameID {
matchingConnections = append(matchingConnections, connection)
m.logger.Debug("Found matching connection",
zap.String("connection_id", connection.ID),
zap.Uintptr("connection_ptr", uintptr(unsafe.Pointer(connection))),
zap.Bool("is_excluded", connection == excludeConnection),
zap.String("player_id", existingPlayerID),
zap.String("game_id", existingGameID))
if connection != excludeConnection {
existingConnection = connection
break
}
}
}
m.logger.Debug("Connection search complete",
zap.Int("total_matching", len(matchingConnections)),
zap.Bool("found_to_cleanup", existingConnection != nil))
if existingConnection == nil {
m.logger.Debug("No existing connection to clean up for reconnecting player",
zap.String("player_id", playerID),
zap.String("game_id", gameID),
zap.String("current_connection_id", excludeConnection.ID))
return nil
}
m.logger.Debug("Cleaning up existing connection for reconnecting player",
zap.String("existing_connection_id", existingConnection.ID),
zap.String("current_connection_id", excludeConnection.ID),
zap.String("player_id", playerID),
zap.String("game_id", gameID),
zap.Uintptr("existing_connection_ptr", uintptr(unsafe.Pointer(existingConnection))),
zap.Uintptr("current_connection_ptr", uintptr(unsafe.Pointer(excludeConnection))))
delete(m.connections, existingConnection)
existingConnection.CloseSend()
// Remove from game connections
if gameConns, exists := m.gameConnections[gameID]; exists {
delete(gameConns, existingConnection)
if len(gameConns) == 0 {
delete(m.gameConnections, gameID)
m.logger.Debug("Removed empty game connections map after cleanup", zap.String("game_id", gameID))
}
}
existingConnection.Close()
m.logger.Debug("Existing connection cleaned up for reconnecting player",
zap.String("old_connection_id", existingConnection.ID),
zap.String("current_connection_id", excludeConnection.ID),
zap.String("player_id", playerID))
return existingConnection
}
// CloseAllConnections closes all active connections
func (m *Manager) CloseAllConnections() {
m.mu.Lock()
defer m.mu.Unlock()
m.logger.Debug("Closing all active connections", zap.Int("connection_count", len(m.connections)))
for connection := range m.connections {
connection.Close()
}
m.connections = make(map[*Connection]bool)
m.gameConnections = make(map[string]map[*Connection]bool)
m.logger.Debug("All client connections closed by server")
}
// GetConnectionBySpectatorID finds a connection for a specific spectator in a game.
func (m *Manager) GetConnectionBySpectatorID(gameID, spectatorID string) *Connection {
m.mu.RLock()
defer m.mu.RUnlock()
gameConnections, exists := m.gameConnections[gameID]
if !exists {
return nil
}
for connection := range gameConnections {
if connection.SpectatorID == spectatorID && connection.IsSpectator() {
return connection
}
}
return nil
}
// GetSpectatorConnections returns all spectator connections for a game.
func (m *Manager) GetSpectatorConnections(gameID string) []*Connection {
m.mu.RLock()
defer m.mu.RUnlock()
var spectators []*Connection
for conn := range m.gameConnections[gameID] {
if conn.IsSpectator() {
spectators = append(spectators, conn)
}
}
return spectators
}
// GetConnectionByPlayerID finds a connection for a specific player in a game
func (m *Manager) GetConnectionByPlayerID(gameID, playerID string) *Connection {
m.mu.RLock()
defer m.mu.RUnlock()
gameConnections, exists := m.gameConnections[gameID]
if !exists {
return nil
}
for connection := range gameConnections {
if connection.PlayerID == playerID {
return connection
}
}
return nil
}
package connection
import (
"context"
"time"
connaction "terraforming-mars-backend/internal/action/connection"
"terraforming-mars-backend/internal/delivery/dto"
"terraforming-mars-backend/internal/delivery/websocket/core"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/logger"
"go.uber.org/zap"
)
// ChatMessageHandler handles chat message requests from players and spectators.
type ChatMessageHandler struct {
action *connaction.SendChatMessageAction
broadcaster ChatBroadcaster
gameRepo game.GameRepository
logger *zap.Logger
}
// ChatBroadcaster defines the broadcasting interface needed by the chat handler.
type ChatBroadcaster interface {
BroadcastChatMessage(gameID string, chatMsg dto.ChatMessageDto)
}
// NewChatMessageHandler creates a new chat message handler.
func NewChatMessageHandler(
action *connaction.SendChatMessageAction,
broadcaster ChatBroadcaster,
gameRepo game.GameRepository,
) *ChatMessageHandler {
return &ChatMessageHandler{
action: action,
broadcaster: broadcaster,
gameRepo: gameRepo,
logger: logger.Get(),
}
}
// HandleMessage processes a chat-message from a player or spectator.
func (h *ChatMessageHandler) HandleMessage(ctx context.Context, connection *core.Connection, message dto.WebSocketMessage) {
log := h.logger.With(
zap.String("connection_id", connection.ID),
zap.String("message_type", string(message.Type)),
)
gameID := connection.GameID
if gameID == "" {
h.sendError(connection, "not connected to game")
return
}
payloadMap, ok := message.Payload.(map[string]interface{})
if !ok {
log.Error("Invalid payload format")
h.sendError(connection, "invalid payload format")
return
}
msgText, _ := payloadMap["message"].(string)
if msgText == "" {
h.sendError(connection, "message cannot be empty")
return
}
isSpectator := connection.IsSpectator()
var senderID, senderName, senderColor string
g, err := h.gameRepo.Get(ctx, gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
h.sendError(connection, "game not found")
return
}
if isSpectator {
s, err := g.GetSpectator(connection.SpectatorID)
if err != nil {
log.Error("Spectator not found", zap.Error(err))
h.sendError(connection, "spectator not found")
return
}
senderID = s.ID()
senderName = s.Name()
senderColor = s.Color()
} else {
p, err := g.GetPlayer(connection.PlayerID)
if err != nil {
log.Error("Player not found", zap.Error(err))
h.sendError(connection, "player not found")
return
}
senderID = p.ID()
senderName = p.Name()
senderColor = p.Color()
}
chatMsg, err := h.action.Execute(ctx, gameID, senderID, senderName, senderColor, msgText, isSpectator)
if err != nil {
log.Error("Failed to send chat message", zap.Error(err))
h.sendError(connection, err.Error())
return
}
chatDto := dto.ChatMessageDto{
SenderID: chatMsg.SenderID,
SenderName: chatMsg.SenderName,
SenderColor: chatMsg.SenderColor,
Message: chatMsg.Message,
Timestamp: chatMsg.Timestamp.Format(time.RFC3339),
IsSpectator: chatMsg.IsSpectator,
}
h.broadcaster.BroadcastChatMessage(gameID, chatDto)
log.Debug("Chat message broadcast")
}
func (h *ChatMessageHandler) sendError(connection *core.Connection, errorMessage string) {
connection.SendMessage(dto.WebSocketMessage{
Type: dto.MessageTypeError,
Payload: map[string]interface{}{
"error": errorMessage,
},
})
}
package connection
import (
"context"
"time"
connaction "terraforming-mars-backend/internal/action/connection"
"terraforming-mars-backend/internal/delivery/dto"
"terraforming-mars-backend/internal/delivery/websocket/core"
"terraforming-mars-backend/internal/logger"
"go.uber.org/zap"
)
// EndGameHandler handles WebSocket messages to end a game.
type EndGameHandler struct {
action *connaction.EndGameAction
hub *core.Hub
logger *zap.Logger
}
// NewEndGameHandler creates a new EndGameHandler.
func NewEndGameHandler(action *connaction.EndGameAction, hub *core.Hub) *EndGameHandler {
return &EndGameHandler{
action: action,
hub: hub,
logger: logger.Get(),
}
}
// HandleMessage processes an end-game WebSocket message.
func (h *EndGameHandler) HandleMessage(ctx context.Context, connection *core.Connection, message dto.WebSocketMessage) {
log := h.logger.With(
zap.String("connection_id", connection.ID),
zap.String("message_type", string(message.Type)),
)
log.Debug("Processing end game request")
if connection.GameID == "" || connection.PlayerID == "" {
log.Error("Missing connection context")
h.sendError(connection, "not connected to game")
return
}
gameID := connection.GameID
gameConnections := h.hub.GetManager().GetGameConnections(gameID)
err := h.action.Execute(ctx, gameID, connection.PlayerID)
if err != nil {
log.Error("Failed to execute end game action", zap.Error(err))
h.sendError(connection, err.Error())
return
}
log.Debug("Game ended")
gameEndedMessage := dto.WebSocketMessage{
Type: dto.MessageTypeGameEnded,
GameID: gameID,
Payload: map[string]any{"reason": "The host ended the game"},
}
for conn := range gameConnections {
conn.SendMessage(gameEndedMessage)
}
go func() {
time.Sleep(100 * time.Millisecond)
for conn := range gameConnections {
conn.Close()
}
log.Debug("Closed all connections for ended game", zap.String("game_id", gameID))
}()
}
func (h *EndGameHandler) sendError(connection *core.Connection, errorMessage string) {
connection.Send <- dto.WebSocketMessage{
Type: dto.MessageTypeError,
Payload: map[string]any{
"error": errorMessage,
},
}
}
package connection
import (
"context"
"time"
connaction "terraforming-mars-backend/internal/action/connection"
"terraforming-mars-backend/internal/delivery/dto"
"terraforming-mars-backend/internal/delivery/websocket/core"
"terraforming-mars-backend/internal/logger"
"go.uber.org/zap"
)
type KickPlayerHandler struct {
action *connaction.KickPlayerAction
broadcaster Broadcaster
hub *core.Hub
logger *zap.Logger
}
func NewKickPlayerHandler(action *connaction.KickPlayerAction, broadcaster Broadcaster, hub *core.Hub) *KickPlayerHandler {
return &KickPlayerHandler{
action: action,
broadcaster: broadcaster,
hub: hub,
logger: logger.Get(),
}
}
func (h *KickPlayerHandler) HandleMessage(ctx context.Context, connection *core.Connection, message dto.WebSocketMessage) {
log := h.logger.With(
zap.String("connection_id", connection.ID),
zap.String("message_type", string(message.Type)),
)
log.Debug("Processing kick player request")
if connection.GameID == "" || connection.PlayerID == "" {
log.Error("Missing connection context")
h.sendError(connection, "not connected to game")
return
}
payloadMap, ok := message.Payload.(map[string]any)
if !ok {
log.Error("Invalid payload format")
h.sendError(connection, "invalid payload format")
return
}
targetPlayerID, _ := payloadMap["targetPlayerId"].(string)
if targetPlayerID == "" {
log.Error("Missing targetPlayerId in payload")
h.sendError(connection, "targetPlayerId is required")
return
}
err := h.action.Execute(ctx, connection.GameID, connection.PlayerID, targetPlayerID)
if err != nil {
log.Error("Failed to execute kick player action", zap.Error(err))
h.sendError(connection, err.Error())
return
}
log.Debug("Player kicked")
// Send player-kicked message to the kicked player before closing their connection
kickedConnection := h.hub.GetManager().GetConnectionByPlayerID(connection.GameID, targetPlayerID)
if kickedConnection != nil {
kickedMessage := dto.WebSocketMessage{
Type: dto.MessageTypePlayerKicked,
GameID: connection.GameID,
Payload: map[string]any{"reason": "You were kicked from the game"},
}
kickedConnection.SendMessage(kickedMessage)
log.Debug("Sent player-kicked message to kicked player", zap.String("target_player_id", targetPlayerID))
// Close the kicked player's connection after a short delay to ensure the message is sent
go func() {
time.Sleep(100 * time.Millisecond)
kickedConnection.Close()
log.Debug("Closed kicked player's connection", zap.String("target_player_id", targetPlayerID))
}()
}
h.broadcaster.BroadcastGameState(connection.GameID, nil)
log.Debug("Broadcasted game state to all players")
}
func (h *KickPlayerHandler) sendError(connection *core.Connection, errorMessage string) {
connection.Send <- dto.WebSocketMessage{
Type: dto.MessageTypeError,
Payload: map[string]any{
"error": errorMessage,
},
}
}
package connection
import (
"context"
"time"
connaction "terraforming-mars-backend/internal/action/connection"
"terraforming-mars-backend/internal/delivery/dto"
"terraforming-mars-backend/internal/delivery/websocket/core"
"terraforming-mars-backend/internal/logger"
"go.uber.org/zap"
)
// KickSpectatorHandler handles kicking a spectator from a game.
type KickSpectatorHandler struct {
action *connaction.KickSpectatorAction
broadcaster Broadcaster
hub *core.Hub
logger *zap.Logger
}
// NewKickSpectatorHandler creates a new kick spectator handler.
func NewKickSpectatorHandler(action *connaction.KickSpectatorAction, broadcaster Broadcaster, hub *core.Hub) *KickSpectatorHandler {
return &KickSpectatorHandler{
action: action,
broadcaster: broadcaster,
hub: hub,
logger: logger.Get(),
}
}
// HandleMessage processes a kick-spectator message.
func (h *KickSpectatorHandler) HandleMessage(ctx context.Context, connection *core.Connection, message dto.WebSocketMessage) {
log := h.logger.With(
zap.String("connection_id", connection.ID),
zap.String("message_type", string(message.Type)),
)
log.Debug("Processing kick spectator request")
if connection.GameID == "" || connection.PlayerID == "" {
log.Error("Missing connection context")
h.sendError(connection, "not connected to game")
return
}
payloadMap, ok := message.Payload.(map[string]any)
if !ok {
log.Error("Invalid payload format")
h.sendError(connection, "invalid payload format")
return
}
targetSpectatorID, _ := payloadMap["targetSpectatorId"].(string)
if targetSpectatorID == "" {
log.Error("Missing targetSpectatorId in payload")
h.sendError(connection, "targetSpectatorId is required")
return
}
err := h.action.Execute(ctx, connection.GameID, connection.PlayerID, targetSpectatorID)
if err != nil {
log.Error("Failed to kick spectator", zap.Error(err))
h.sendError(connection, err.Error())
return
}
log.Debug("Spectator kicked")
kickedConn := h.hub.GetManager().GetConnectionBySpectatorID(connection.GameID, targetSpectatorID)
if kickedConn != nil {
kickedMessage := dto.WebSocketMessage{
Type: dto.MessageTypeSpectatorKicked,
GameID: connection.GameID,
Payload: map[string]any{"reason": "You were kicked from the game"},
}
kickedConn.SendMessage(kickedMessage)
go func() {
time.Sleep(100 * time.Millisecond)
kickedConn.Close()
}()
}
h.broadcaster.BroadcastGameState(connection.GameID, nil)
}
func (h *KickSpectatorHandler) sendError(connection *core.Connection, errorMessage string) {
connection.SendMessage(dto.WebSocketMessage{
Type: dto.MessageTypeError,
Payload: map[string]any{
"error": errorMessage,
},
})
}
package connection
import (
"context"
connaction "terraforming-mars-backend/internal/action/connection"
"terraforming-mars-backend/internal/delivery/dto"
"terraforming-mars-backend/internal/delivery/websocket/core"
"terraforming-mars-backend/internal/logger"
"go.uber.org/zap"
)
// PlayerDisconnectedHandler handles player disconnection requests
type PlayerDisconnectedHandler struct {
action *connaction.PlayerDisconnectedAction
broadcaster Broadcaster
logger *zap.Logger
}
// Broadcaster interface for explicit broadcasting
type Broadcaster interface {
BroadcastGameState(gameID string, playerIDs []string)
SendInitialLogs(gameID string, playerID string)
SendInitialLogsToSpectator(gameID string, spectatorID string)
}
// NewPlayerDisconnectedHandler creates a new player disconnected handler
func NewPlayerDisconnectedHandler(action *connaction.PlayerDisconnectedAction, broadcaster Broadcaster) *PlayerDisconnectedHandler {
return &PlayerDisconnectedHandler{
action: action,
broadcaster: broadcaster,
logger: logger.Get(),
}
}
// HandleMessage implements the MessageHandler interface
func (h *PlayerDisconnectedHandler) HandleMessage(ctx context.Context, connection *core.Connection, message dto.WebSocketMessage) {
log := h.logger.With(
zap.String("connection_id", connection.ID),
zap.String("message_type", string(message.Type)),
)
log.Debug("Processing player disconnected request")
if connection.GameID == "" || connection.PlayerID == "" {
log.Error("Missing connection context - connection closing anyway")
return
}
err := h.action.Execute(ctx, connection.GameID, connection.PlayerID)
if err != nil {
log.Error("Failed to execute player disconnected action (connection closing anyway)", zap.Error(err))
return
}
log.Debug("Player disconnect completed")
h.broadcaster.BroadcastGameState(connection.GameID, nil)
log.Debug("Broadcasted game state to all players")
// NOTE: Do NOT send response on connection.Send - the connection is being closed
}
package connection
import (
"context"
connaction "terraforming-mars-backend/internal/action/connection"
"terraforming-mars-backend/internal/delivery/dto"
"terraforming-mars-backend/internal/delivery/websocket/core"
"terraforming-mars-backend/internal/logger"
"go.uber.org/zap"
)
// PlayerTakeoverHandler handles player takeover requests
type PlayerTakeoverHandler struct {
action *connaction.PlayerTakeoverAction
broadcaster Broadcaster
logger *zap.Logger
}
// NewPlayerTakeoverHandler creates a new player takeover handler
func NewPlayerTakeoverHandler(action *connaction.PlayerTakeoverAction, broadcaster Broadcaster) *PlayerTakeoverHandler {
return &PlayerTakeoverHandler{
action: action,
broadcaster: broadcaster,
logger: logger.Get(),
}
}
// HandleMessage implements the MessageHandler interface
func (h *PlayerTakeoverHandler) HandleMessage(ctx context.Context, connection *core.Connection, message dto.WebSocketMessage) {
log := h.logger.With(
zap.String("connection_id", connection.ID),
zap.String("message_type", string(message.Type)),
)
log.Debug("Processing player takeover request")
payloadMap, ok := message.Payload.(map[string]any)
if !ok {
log.Error("Invalid payload format")
h.sendError(connection, "Invalid payload format")
return
}
gameID, _ := payloadMap["gameId"].(string)
targetPlayerID, _ := payloadMap["targetPlayerId"].(string)
if gameID == "" {
log.Error("Missing gameId")
h.sendError(connection, "Missing gameId")
return
}
if targetPlayerID == "" {
log.Error("Missing targetPlayerId")
h.sendError(connection, "Missing targetPlayerId")
return
}
log.Debug("Parsed takeover request",
zap.String("game_id", gameID),
zap.String("target_player_id", targetPlayerID))
result, err := h.action.Execute(ctx, gameID, targetPlayerID)
if err != nil {
log.Error("Failed to execute player takeover action", zap.Error(err))
h.sendError(connection, err.Error())
return
}
connection.SetPlayer(targetPlayerID, gameID)
log.Debug("Player takeover completed",
zap.String("player_id", result.PlayerID),
zap.String("player_name", result.PlayerName))
h.broadcaster.BroadcastGameState(gameID, nil)
log.Debug("Broadcasted game state to all players")
response := dto.WebSocketMessage{
Type: dto.MessageTypePlayerConnected,
GameID: gameID,
Payload: map[string]any{
"playerID": result.PlayerID,
"playerName": result.PlayerName,
"success": true,
},
}
connection.Send <- response
log.Debug("Sent player takeover confirmation")
}
// sendError sends an error message to the client
func (h *PlayerTakeoverHandler) sendError(connection *core.Connection, errorMessage string) {
connection.Send <- dto.WebSocketMessage{
Type: dto.MessageTypeError,
Payload: map[string]any{
"error": errorMessage,
},
}
}
package connection
import (
"context"
"terraforming-mars-backend/internal/delivery/dto"
"terraforming-mars-backend/internal/delivery/websocket/core"
"terraforming-mars-backend/internal/logger"
"go.uber.org/zap"
)
// RequestLogsHandler handles requests to resend all game logs
type RequestLogsHandler struct {
broadcaster Broadcaster
logger *zap.Logger
}
// NewRequestLogsHandler creates a new request logs handler
func NewRequestLogsHandler(broadcaster Broadcaster) *RequestLogsHandler {
return &RequestLogsHandler{
broadcaster: broadcaster,
logger: logger.Get(),
}
}
// HandleMessage implements the MessageHandler interface
func (h *RequestLogsHandler) HandleMessage(_ context.Context, connection *core.Connection, message dto.WebSocketMessage) {
log := h.logger.With(
zap.String("connection_id", connection.ID),
zap.String("message_type", string(message.Type)),
)
log.Debug("Processing request-logs")
if connection.GameID == "" {
log.Error("Missing connection context")
connection.Send <- dto.WebSocketMessage{
Type: dto.MessageTypeError,
Payload: map[string]any{
"error": "Not connected to a game",
},
}
return
}
if connection.SpectatorID != "" {
h.broadcaster.SendInitialLogsToSpectator(connection.GameID, connection.SpectatorID)
log.Debug("Sent initial logs to spectator",
zap.String("game_id", connection.GameID),
zap.String("spectator_id", connection.SpectatorID))
return
}
if connection.PlayerID == "" {
log.Error("Missing connection context")
connection.Send <- dto.WebSocketMessage{
Type: dto.MessageTypeError,
Payload: map[string]any{
"error": "Not connected to a game",
},
}
return
}
h.broadcaster.SendInitialLogs(connection.GameID, connection.PlayerID)
log.Debug("Sent initial logs to player",
zap.String("game_id", connection.GameID),
zap.String("player_id", connection.PlayerID))
}
package connection
import (
"context"
connaction "terraforming-mars-backend/internal/action/connection"
"terraforming-mars-backend/internal/delivery/dto"
"terraforming-mars-backend/internal/delivery/websocket/core"
"terraforming-mars-backend/internal/logger"
"go.uber.org/zap"
)
// SetPlayerColorHandler handles set-player-color messages.
type SetPlayerColorHandler struct {
action *connaction.SetPlayerColorAction
broadcaster Broadcaster
logger *zap.Logger
}
// NewSetPlayerColorHandler creates a new set player color handler.
func NewSetPlayerColorHandler(action *connaction.SetPlayerColorAction, broadcaster Broadcaster) *SetPlayerColorHandler {
return &SetPlayerColorHandler{
action: action,
broadcaster: broadcaster,
logger: logger.Get(),
}
}
// HandleMessage processes a set-player-color message.
func (h *SetPlayerColorHandler) HandleMessage(ctx context.Context, connection *core.Connection, message dto.WebSocketMessage) {
log := h.logger.With(
zap.String("connection_id", connection.ID),
zap.String("message_type", string(message.Type)),
)
gameID := connection.GameID
playerID := connection.PlayerID
if gameID == "" || playerID == "" {
h.sendError(connection, "not connected to game as a player")
return
}
payloadMap, ok := message.Payload.(map[string]interface{})
if !ok {
log.Error("Invalid payload format")
h.sendError(connection, "invalid payload format")
return
}
color, _ := payloadMap["color"].(string)
if color == "" {
h.sendError(connection, "missing color")
return
}
targetPlayerID := playerID
if tid, ok := payloadMap["targetPlayerId"].(string); ok && tid != "" {
targetPlayerID = tid
}
if err := h.action.Execute(ctx, gameID, playerID, targetPlayerID, color); err != nil {
log.Error("Failed to set player color", zap.Error(err))
h.sendError(connection, err.Error())
return
}
h.broadcaster.BroadcastGameState(gameID, nil)
}
func (h *SetPlayerColorHandler) sendError(connection *core.Connection, errorMessage string) {
connection.SendMessage(dto.WebSocketMessage{
Type: dto.MessageTypeError,
Payload: map[string]interface{}{
"error": errorMessage,
},
})
}
package connection
import (
"context"
connaction "terraforming-mars-backend/internal/action/connection"
"terraforming-mars-backend/internal/delivery/dto"
"terraforming-mars-backend/internal/delivery/websocket/core"
"terraforming-mars-backend/internal/logger"
"github.com/google/uuid"
"go.uber.org/zap"
)
// SpectateGameHandler handles spectator connection requests.
type SpectateGameHandler struct {
action *connaction.SpectateGameAction
broadcaster Broadcaster
logger *zap.Logger
}
// NewSpectateGameHandler creates a new spectate game handler.
func NewSpectateGameHandler(action *connaction.SpectateGameAction, broadcaster Broadcaster) *SpectateGameHandler {
return &SpectateGameHandler{
action: action,
broadcaster: broadcaster,
logger: logger.Get(),
}
}
// HandleMessage processes a spectator-connect message.
func (h *SpectateGameHandler) HandleMessage(ctx context.Context, connection *core.Connection, message dto.WebSocketMessage) {
log := h.logger.With(
zap.String("connection_id", connection.ID),
zap.String("message_type", string(message.Type)),
)
log.Debug("Processing spectate game request")
payloadMap, ok := message.Payload.(map[string]interface{})
if !ok {
log.Error("Invalid payload format")
h.sendError(connection, "invalid payload format")
return
}
gameID, _ := payloadMap["gameId"].(string)
spectatorName, _ := payloadMap["spectatorName"].(string)
if gameID == "" {
log.Error("Missing gameId")
h.sendError(connection, "missing gameId")
return
}
if spectatorName == "" {
log.Error("Missing spectatorName")
h.sendError(connection, "missing spectatorName")
return
}
spectatorID := uuid.New().String()
connection.SetSpectator(spectatorID, gameID)
result, err := h.action.Execute(ctx, gameID, spectatorName, spectatorID)
if err != nil {
log.Error("Failed to execute spectate game action", zap.Error(err))
h.sendError(connection, err.Error())
return
}
log.Debug("Spectator joined", zap.String("spectator_id", result.SpectatorID))
h.broadcaster.BroadcastGameState(gameID, nil)
h.broadcaster.SendInitialLogsToSpectator(gameID, spectatorID)
response := dto.WebSocketMessage{
Type: dto.MessageTypeSpectatorConnected,
GameID: gameID,
Payload: map[string]interface{}{
"spectatorId": result.SpectatorID,
"success": true,
},
}
connection.SendMessage(response)
}
func (h *SpectateGameHandler) sendError(connection *core.Connection, errorMessage string) {
connection.SendMessage(dto.WebSocketMessage{
Type: dto.MessageTypeError,
Payload: map[string]interface{}{
"error": errorMessage,
},
})
}
package connection
import (
"context"
connaction "terraforming-mars-backend/internal/action/connection"
"terraforming-mars-backend/internal/delivery/dto"
"terraforming-mars-backend/internal/delivery/websocket/core"
"terraforming-mars-backend/internal/logger"
"go.uber.org/zap"
)
// SpectatorDisconnectedHandler handles spectator disconnect events.
type SpectatorDisconnectedHandler struct {
action *connaction.SpectatorDisconnectedAction
broadcaster Broadcaster
logger *zap.Logger
}
// NewSpectatorDisconnectedHandler creates a new spectator disconnected handler.
func NewSpectatorDisconnectedHandler(action *connaction.SpectatorDisconnectedAction, broadcaster Broadcaster) *SpectatorDisconnectedHandler {
return &SpectatorDisconnectedHandler{
action: action,
broadcaster: broadcaster,
logger: logger.Get(),
}
}
// HandleMessage processes a spectator-disconnected message.
func (h *SpectatorDisconnectedHandler) HandleMessage(ctx context.Context, connection *core.Connection, message dto.WebSocketMessage) {
log := h.logger.With(
zap.String("connection_id", connection.ID),
zap.String("message_type", string(message.Type)),
)
payloadMap, ok := message.Payload.(map[string]interface{})
if !ok {
payload, ok2 := message.Payload.(dto.SpectatorDisconnectedPayload)
if !ok2 {
log.Error("Invalid payload format for spectator disconnect")
return
}
payloadMap = map[string]interface{}{
"spectatorId": payload.SpectatorID,
"gameId": payload.GameID,
}
}
spectatorID, _ := payloadMap["spectatorId"].(string)
gameID, _ := payloadMap["gameId"].(string)
if spectatorID == "" || gameID == "" {
log.Debug("Missing spectator/game ID in disconnect payload")
return
}
log = log.With(
zap.String("spectator_id", spectatorID),
zap.String("game_id", gameID),
)
if err := h.action.Execute(ctx, gameID, spectatorID); err != nil {
log.Error("Failed to handle spectator disconnect", zap.Error(err))
return
}
h.broadcaster.BroadcastGameState(gameID, nil)
log.Debug("Broadcasted updated state after spectator disconnect")
}
package events
import (
"fmt"
"sync"
"terraforming-mars-backend/internal/logger"
"go.uber.org/zap"
)
// SubscriptionID represents a unique subscription identifier
type SubscriptionID string
// EventHandler is a type-safe event handler function
type EventHandler[T any] func(event T)
// subscription wraps a handler with its type information
type subscription struct {
id SubscriptionID
handler interface{} // The actual typed handler
eventType string // Type name for matching
handlerFunc func(event any) // Type-erased execution wrapper
}
// EventBusImpl implements EventBus with thread-safe operations
type EventBusImpl struct {
subscriptions map[SubscriptionID]*subscription
nextID uint64
mutex sync.RWMutex
logger *zap.Logger
}
// NewEventBus creates a new type-safe event bus for synchronous event handling
func NewEventBus() *EventBusImpl {
return &EventBusImpl{
subscriptions: make(map[SubscriptionID]*subscription),
nextID: 1,
logger: logger.Get(),
}
}
// Subscribe registers a type-safe event handler
func Subscribe[T any](eb *EventBusImpl, handler EventHandler[T]) SubscriptionID {
eb.mutex.Lock()
defer eb.mutex.Unlock()
id := SubscriptionID(fmt.Sprintf("sub-%d", eb.nextID))
eb.nextID++
var zero T
eventType := fmt.Sprintf("%T", zero)
handlerFunc := func(event any) {
if typedEvent, ok := event.(T); ok {
handler(typedEvent)
}
}
sub := &subscription{
id: id,
handler: handler,
eventType: eventType,
handlerFunc: handlerFunc,
}
eb.subscriptions[id] = sub
eb.logger.Debug("Event handler subscribed",
zap.String("subscription_id", string(id)),
zap.String("event_type", eventType))
return id
}
// Publish publishes a type-safe event to all matching subscribers synchronously.
// Handlers are collected under the read lock, then executed after releasing it
// so that handlers may safely call Subscribe/Unsubscribe without deadlocking.
func Publish[T any](eb *EventBusImpl, event T) {
eventType := fmt.Sprintf("%T", event)
// Collect matching handlers under read lock
eb.mutex.RLock()
var matchingHandlers []func(any)
for _, sub := range eb.subscriptions {
if sub.eventType == eventType {
matchingHandlers = append(matchingHandlers, sub.handlerFunc)
}
}
eb.mutex.RUnlock()
if len(matchingHandlers) == 0 {
eb.logger.Debug("No subscribers for event",
zap.String("event_type", eventType))
} else {
eb.logger.Debug("Publishing event to subscribers",
zap.String("event_type", eventType),
zap.Int("subscriber_count", len(matchingHandlers)))
// Execute handlers without holding the lock
for _, handlerFunc := range matchingHandlers {
handlerFunc(event)
}
}
}
// Unsubscribe removes a subscription by ID
func (eb *EventBusImpl) Unsubscribe(id SubscriptionID) {
eb.mutex.Lock()
defer eb.mutex.Unlock()
if sub, exists := eb.subscriptions[id]; exists {
delete(eb.subscriptions, id)
eb.logger.Debug("Event handler unsubscribed",
zap.String("subscription_id", string(id)),
zap.String("event_type", sub.eventType))
}
}
// Clear removes all subscriptions from the event bus
func (eb *EventBusImpl) Clear() {
eb.mutex.Lock()
defer eb.mutex.Unlock()
eb.subscriptions = make(map[SubscriptionID]*subscription)
eb.nextID = 1
}
package award
import (
"sort"
"terraforming-mars-backend/internal/game/shared"
)
// AwardDefinition is the static template loaded from JSON
type AwardDefinition struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Pack string `json:"pack"`
Costs []AwardCost `json:"costs"`
Rewards []AwardReward `json:"rewards"`
Quantifier []shared.PerCondition `json:"quantifier"`
Style shared.Style `json:"style"`
}
// AwardCost defines the funding cost at a given number of already-funded awards
type AwardCost struct {
AwardsBought int `json:"awardsBought"`
Cost int `json:"cost"`
}
// AwardReward defines VP output for a placement
type AwardReward struct {
Place int `json:"place"`
Outputs []RewardOutput `json:"outputs"`
}
// RewardOutput represents a single reward (e.g., VP)
type RewardOutput struct {
Type string `json:"type"`
Amount int `json:"amount"`
}
// GetCostForFundedCount returns the funding cost given the number of currently funded awards.
// Finds the cost entry with the highest awardsBought <= fundedCount.
func (d *AwardDefinition) GetCostForFundedCount(fundedCount int) int {
if len(d.Costs) == 0 {
return 0
}
sorted := make([]AwardCost, len(d.Costs))
copy(sorted, d.Costs)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].AwardsBought < sorted[j].AwardsBought
})
result := sorted[0].Cost
for _, c := range sorted {
if c.AwardsBought <= fundedCount {
result = c.Cost
} else {
break
}
}
return result
}
// GetRewardVP returns the total VP for a given placement from the rewards array
func (d *AwardDefinition) GetRewardVP(place int) int {
for _, r := range d.Rewards {
if r.Place == place {
vp := 0
for _, o := range r.Outputs {
if o.Type == "vp" {
vp += o.Amount
}
}
return vp
}
}
return 0
}
package game
import (
"context"
"fmt"
"time"
"go.uber.org/zap"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game/datastore"
"terraforming-mars-backend/internal/game/shared"
"terraforming-mars-backend/internal/logger"
)
// MaxFundedAwards is the maximum number of awards that can be funded in a game
const MaxFundedAwards = 3
// Awards manages award funding state for a game
type Awards struct {
ds *datastore.DataStore
gameID string
eventBus *events.EventBusImpl
}
// NewAwards creates a new Awards state tracker
func NewAwards(ds *datastore.DataStore, gameID string, eventBus *events.EventBusImpl) *Awards {
return &Awards{
ds: ds,
gameID: gameID,
eventBus: eventBus,
}
}
func (a *Awards) update(fn func(s *datastore.GameState)) {
if err := a.ds.UpdateGame(a.gameID, fn); err != nil {
logger.Get().Warn("Failed to update game state", zap.String("game_id", a.gameID), zap.Error(err))
}
}
func (a *Awards) read(fn func(s *datastore.GameState)) {
if err := a.ds.ReadGame(a.gameID, fn); err != nil {
logger.Get().Warn("Failed to read game state", zap.String("game_id", a.gameID), zap.Error(err))
}
}
// FundedAwards returns a copy of all funded awards
func (a *Awards) FundedAwards() []shared.FundedAward {
var result []shared.FundedAward
a.read(func(s *datastore.GameState) {
result = make([]shared.FundedAward, len(s.FundedAwards))
copy(result, s.FundedAwards)
})
return result
}
// IsFunded returns true if the given award has been funded
func (a *Awards) IsFunded(awardType shared.AwardType) bool {
var funded bool
a.read(func(s *datastore.GameState) {
for _, f := range s.FundedAwards {
if f.Type == awardType {
funded = true
return
}
}
})
return funded
}
// IsFundedBy returns true if the given award was funded by the specified player
func (a *Awards) IsFundedBy(awardType shared.AwardType, playerID string) bool {
var funded bool
a.read(func(s *datastore.GameState) {
for _, f := range s.FundedAwards {
if f.Type == awardType && f.FundedByPlayer == playerID {
funded = true
return
}
}
})
return funded
}
// CanFundMore returns true if fewer than MaxFundedAwards have been funded
func (a *Awards) CanFundMore() bool {
var can bool
a.read(func(s *datastore.GameState) {
can = len(s.FundedAwards) < MaxFundedAwards
})
return can
}
// FundedCount returns the number of currently funded awards
func (a *Awards) FundedCount() int {
var count int
a.read(func(s *datastore.GameState) { count = len(s.FundedAwards) })
return count
}
// FundAward funds an award for a player at the given cost
func (a *Awards) FundAward(ctx context.Context, awardType shared.AwardType, playerID string, fundingCost int) error {
if err := ctx.Err(); err != nil {
return err
}
var fundErr error
a.update(func(s *datastore.GameState) {
if len(s.FundedAwards) >= MaxFundedAwards {
fundErr = fmt.Errorf("maximum awards (%d) already funded", MaxFundedAwards)
return
}
for _, f := range s.FundedAwards {
if f.Type == awardType {
fundErr = fmt.Errorf("award %s is already funded", awardType)
return
}
}
s.FundedAwards = append(s.FundedAwards, shared.FundedAward{
Type: awardType,
FundedByPlayer: playerID,
FundingOrder: len(s.FundedAwards),
FundingCost: fundingCost,
FundedAt: time.Now(),
})
})
if fundErr != nil {
return fundErr
}
if a.eventBus != nil {
events.Publish(a.eventBus, events.AwardFundedEvent{
GameID: a.gameID,
PlayerID: playerID,
AwardType: string(awardType),
FundingCost: fundingCost,
Timestamp: time.Now(),
})
}
return nil
}
package board
import (
"context"
"fmt"
"time"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game/shared"
)
// Tile type string constants for placement operations
const (
TileTypeCity = "city"
TileTypeGreenery = "greenery"
TileTypeOcean = "ocean"
TileTypeNaturalPreserve = "natural-preserve"
TileTypeMining = "mining"
TileTypeNuclearZone = "nuclear-zone"
TileTypeEcologicalZone = "ecological-zone"
TileTypeMohole = "mohole"
TileTypeRestricted = "restricted"
TileTypeVolcano = "volcano"
TileTypeColony = "colony"
TileTypeLandClaim = "land-claim"
TileTypeClear = "clear"
TileTypeWorldTree = "world-tree"
)
// PlaceableTileType describes a tile type available in the demo tile picker
type PlaceableTileType struct {
Type string
Label string
Group string
}
// PlaceableTileTypes is the single registry of all tile types available for placement.
// Adding a new entry here automatically updates backend validation and frontend UI.
var PlaceableTileTypes = []PlaceableTileType{
{Type: TileTypeCity, Label: "City", Group: "Base"},
{Type: TileTypeGreenery, Label: "Greenery", Group: "Base"},
{Type: TileTypeOcean, Label: "Ocean", Group: "Base"},
{Type: TileTypeNaturalPreserve, Label: "Natural Preserve", Group: "Special"},
{Type: TileTypeEcologicalZone, Label: "Ecological Zone", Group: "Special"},
{Type: TileTypeMining, Label: "Mining", Group: "Special"},
{Type: TileTypeColony, Label: "Colony", Group: "Special"},
{Type: TileTypeVolcano, Label: "Volcano", Group: "Industrial"},
{Type: TileTypeNuclearZone, Label: "Nuclear Zone", Group: "Industrial"},
{Type: TileTypeMohole, Label: "Mohole", Group: "Industrial"},
{Type: TileTypeRestricted, Label: "Restricted", Group: "Industrial"},
{Type: TileTypeWorldTree, Label: "World Tree", Group: "Special"},
{Type: TileTypeLandClaim, Label: "Land Claim", Group: "Tools"},
{Type: TileTypeClear, Label: "Clear", Group: "Tools"},
}
// ValidPlaceableTileType returns true if the given tile type is in the PlaceableTileTypes registry
func ValidPlaceableTileType(tileType string) bool {
for _, pt := range PlaceableTileTypes {
if pt.Type == tileType {
return true
}
}
return false
}
// BoardTag represents a tag on a board tile for reserved areas
type BoardTag = string
const (
BoardTagNoctisCity BoardTag = "noctis-city"
BoardTagGanymedeColony BoardTag = "ganymede-colony"
BoardTagVolcanic BoardTag = "volcanic"
BoardTagPhobosSpaceHaven BoardTag = "phobos-space-haven"
BoardTagDawnCity BoardTag = "dawn-city"
BoardTagMaxwellBase BoardTag = "maxwell-base"
BoardTagStratopolis BoardTag = "stratopolis"
BoardTagLunaMetropolis BoardTag = "luna-metropolis"
)
// TileLocation represents the celestial body where tiles are located
type TileLocation string
const (
TileLocationMars TileLocation = "mars"
TileLocationVenus TileLocation = "venus"
TileLocationJupiter TileLocation = "jupiter"
TileLocationGanymede TileLocation = "ganymede"
TileLocationEarth TileLocation = "earth"
TileLocationLuna TileLocation = "luna"
TileLocationMercury TileLocation = "mercury"
TileLocationPhobos TileLocation = "phobos"
)
// TileBonus represents a resource bonus provided by a tile when occupied
type TileBonus struct {
Type shared.ResourceType `json:"type"`
Amount int `json:"amount"`
}
// TileOccupant represents what currently occupies a tile
type TileOccupant struct {
Type shared.ResourceType `json:"type"`
Tags []string `json:"tags"`
}
// Tile represents a single hexagonal tile on the game board
type Tile struct {
Coordinates shared.HexPosition `json:"coordinates"`
Tags []string `json:"tags"`
Type shared.ResourceType `json:"type"`
Location TileLocation `json:"location"`
DisplayName *string `json:"displayName,omitempty"`
Bonuses []TileBonus `json:"bonuses"`
OccupiedBy *TileOccupant `json:"occupiedBy,omitempty"`
OwnerID *string `json:"ownerId,omitempty"`
ReservedBy *string `json:"reservedBy,omitempty" ts:"reservedBy"`
}
// TilesPtr is a pointer to a tile slice, used by Board as its backing store.
// This allows Board to be a view into GameState's Tiles field.
type TilesPtr = *[]Tile
// Board represents the complete game board state.
type Board struct {
tiles TilesPtr
gameID string
eventBus *events.EventBusImpl
}
// NewBoard creates a new Board view backed by the given tiles pointer.
func NewBoard(tiles TilesPtr, gameID string, eventBus *events.EventBusImpl) *Board {
return &Board{
tiles: tiles,
gameID: gameID,
eventBus: eventBus,
}
}
// NewBoardWithTiles creates a new Board view and initializes the tiles.
func NewBoardWithTiles(tiles TilesPtr, gameID string, initialTiles []Tile, eventBus *events.EventBusImpl) *Board {
tilesCopy := make([]Tile, len(initialTiles))
copy(tilesCopy, initialTiles)
*tiles = tilesCopy
return &Board{
tiles: tiles,
gameID: gameID,
eventBus: eventBus,
}
}
// GenerateMarsBoard creates the standard Terraforming Mars board layout
// Returns a hexagonal grid with ocean spaces, bonus tiles, and land tiles.
// When includeVenus is true, Venus tiles are also included.
func GenerateMarsBoard(includeVenus bool) []Tile {
tiles := []Tile{}
// Official Tharsis map ocean-reserved spaces (12 total, 9 ocean tiles placed during game)
oceanSpaces := map[shared.HexPosition]bool{
// Row 0 (top)
{Q: 1, R: -4, S: 3}: true,
{Q: 3, R: -4, S: 1}: true,
{Q: 4, R: -4, S: 0}: true,
// Row 1
{Q: 4, R: -3, S: -1}: true,
// Row 3
{Q: 4, R: -1, S: -3}: true,
// Row 4 (middle)
{Q: -1, R: 0, S: 1}: true,
{Q: 0, R: 0, S: 0}: true,
{Q: 1, R: 0, S: -1}: true,
// Row 5
{Q: 1, R: 1, S: -2}: true,
{Q: 2, R: 1, S: -3}: true,
{Q: 3, R: 1, S: -4}: true,
// Row 8 (bottom)
{Q: 0, R: 4, S: -4}: true,
}
// Official Tharsis map placement bonuses
bonusTiles := map[shared.HexPosition][]TileBonus{
// Row 0: steel in upper-left
{Q: 0, R: -4, S: 4}: {{Type: shared.ResourceSteel, Amount: 2}},
{Q: 1, R: -4, S: 3}: {{Type: shared.ResourceSteel, Amount: 2}},
{Q: 3, R: -4, S: 1}: {{Type: shared.ResourceCardDraw, Amount: 1}},
// Row 1: Tharsis Tholus steel, rightmost ocean has 2 card draws
{Q: 0, R: -3, S: 3}: {{Type: shared.ResourceSteel, Amount: 1}},
{Q: 4, R: -3, S: -1}: {{Type: shared.ResourceCardDraw, Amount: 2}},
// Row 2: Ascraeus Mons card draw, rightmost steel
{Q: -2, R: -2, S: 4}: {{Type: shared.ResourceCardDraw, Amount: 1}},
{Q: 4, R: -2, S: -2}: {{Type: shared.ResourceSteel, Amount: 1}},
// Row 3: Pavonis Mons plant+titanium, plant bonuses across equator
{Q: -3, R: -1, S: 4}: {{Type: shared.ResourcePlant, Amount: 1}, {Type: shared.ResourceTitanium, Amount: 1}},
{Q: -2, R: -1, S: 3}: {{Type: shared.ResourcePlant, Amount: 1}},
{Q: -1, R: -1, S: 2}: {{Type: shared.ResourcePlant, Amount: 1}},
{Q: 0, R: -1, S: 1}: {{Type: shared.ResourcePlant, Amount: 1}},
{Q: 1, R: -1, S: 0}: {{Type: shared.ResourcePlant, Amount: 2}},
{Q: 2, R: -1, S: -1}: {{Type: shared.ResourcePlant, Amount: 1}},
{Q: 3, R: -1, S: -2}: {{Type: shared.ResourcePlant, Amount: 1}},
{Q: 4, R: -1, S: -3}: {{Type: shared.ResourcePlant, Amount: 2}},
// Row 4: all tiles have 2 plants (equatorial belt)
{Q: -4, R: 0, S: 4}: {{Type: shared.ResourcePlant, Amount: 2}},
{Q: -3, R: 0, S: 3}: {{Type: shared.ResourcePlant, Amount: 2}},
{Q: -2, R: 0, S: 2}: {{Type: shared.ResourcePlant, Amount: 2}},
{Q: -1, R: 0, S: 1}: {{Type: shared.ResourcePlant, Amount: 2}},
{Q: 0, R: 0, S: 0}: {{Type: shared.ResourcePlant, Amount: 2}},
{Q: 1, R: 0, S: -1}: {{Type: shared.ResourcePlant, Amount: 2}},
{Q: 2, R: 0, S: -2}: {{Type: shared.ResourcePlant, Amount: 2}},
{Q: 3, R: 0, S: -3}: {{Type: shared.ResourcePlant, Amount: 2}},
{Q: 4, R: 0, S: -4}: {{Type: shared.ResourcePlant, Amount: 2}},
// Row 5: plant bonuses
{Q: -4, R: 1, S: 3}: {{Type: shared.ResourcePlant, Amount: 1}},
{Q: -3, R: 1, S: 2}: {{Type: shared.ResourcePlant, Amount: 2}},
{Q: -2, R: 1, S: 1}: {{Type: shared.ResourcePlant, Amount: 1}},
{Q: -1, R: 1, S: 0}: {{Type: shared.ResourcePlant, Amount: 1}},
{Q: 0, R: 1, S: -1}: {{Type: shared.ResourcePlant, Amount: 1}},
{Q: 1, R: 1, S: -2}: {{Type: shared.ResourcePlant, Amount: 1}},
{Q: 2, R: 1, S: -3}: {{Type: shared.ResourcePlant, Amount: 1}},
{Q: 3, R: 1, S: -4}: {{Type: shared.ResourcePlant, Amount: 1}},
// Row 6: single plant on one tile
{Q: 1, R: 2, S: -3}: {{Type: shared.ResourcePlant, Amount: 1}},
// Row 7: steel, card draws, titanium
{Q: -4, R: 3, S: 1}: {{Type: shared.ResourceSteel, Amount: 2}},
{Q: -2, R: 3, S: -1}: {{Type: shared.ResourceCardDraw, Amount: 1}},
{Q: -1, R: 3, S: -2}: {{Type: shared.ResourceCardDraw, Amount: 1}},
{Q: 1, R: 3, S: -4}: {{Type: shared.ResourceTitanium, Amount: 1}},
// Row 8: steel and titanium
{Q: -4, R: 4, S: 0}: {{Type: shared.ResourceSteel, Amount: 1}},
{Q: -3, R: 4, S: -1}: {{Type: shared.ResourceSteel, Amount: 2}},
{Q: 0, R: 4, S: -4}: {{Type: shared.ResourceTitanium, Amount: 2}},
}
type taggedTileInfo struct {
Tags []string
DisplayName string
}
taggedTiles := map[shared.HexPosition]taggedTileInfo{
{Q: 0, R: -3, S: 3}: {Tags: []string{BoardTagVolcanic}, DisplayName: "Tharsis Tholus"},
{Q: -2, R: -2, S: 4}: {Tags: []string{BoardTagVolcanic}, DisplayName: "Ascraeus Mons"},
{Q: -3, R: -1, S: 4}: {Tags: []string{BoardTagVolcanic}, DisplayName: "Pavonis Mons"},
{Q: -4, R: 0, S: 4}: {Tags: []string{BoardTagVolcanic}, DisplayName: "Arsia Mons"},
{Q: -2, R: 0, S: 2}: {Tags: []string{BoardTagNoctisCity}, DisplayName: "Noctis City"},
}
radius := 4
for q := -radius; q <= radius; q++ {
r1 := max(-radius, -q-radius)
r2 := min(radius, -q+radius)
for r := r1; r <= r2; r++ {
s := -q - r
pos := shared.HexPosition{Q: q, R: r, S: s}
var tileType shared.ResourceType
var bonuses []TileBonus
if oceanSpaces[pos] {
tileType = shared.ResourceOceanSpace
} else {
tileType = shared.ResourceLandTile
}
if tileBonuses, hasBonus := bonusTiles[pos]; hasBonus {
bonuses = append(bonuses, tileBonuses...)
}
var tags []string
var displayName *string
if tagInfo, hasTag := taggedTiles[pos]; hasTag {
tags = tagInfo.Tags
displayName = &tagInfo.DisplayName
} else {
tags = []string{}
}
tile := Tile{
Coordinates: pos,
Type: tileType,
Location: TileLocationMars,
Tags: tags,
DisplayName: displayName,
Bonuses: bonuses,
OccupiedBy: nil,
OwnerID: nil,
}
tiles = append(tiles, tile)
}
}
// Add celestial body tiles (planets and moons outside Mars)
celestialTiles := []struct {
Pos shared.HexPosition
Tags []string
DisplayName string
Location TileLocation
}{
{shared.HexPosition{Q: 500, R: 0, S: -500}, []string{BoardTagPhobosSpaceHaven}, "Phobos Space Haven", TileLocationPhobos},
{shared.HexPosition{Q: 400, R: 0, S: -400}, []string{BoardTagDawnCity}, "Dawn City", TileLocationMercury},
{shared.HexPosition{Q: 200, R: 0, S: -200}, []string{BoardTagGanymedeColony}, "Ganymede Colony", TileLocationGanymede},
{shared.HexPosition{Q: 300, R: 0, S: -300}, []string{BoardTagLunaMetropolis}, "Luna Metropolis", TileLocationLuna},
}
for _, ct := range celestialTiles {
displayName := ct.DisplayName
tiles = append(tiles, Tile{
Coordinates: ct.Pos,
Type: shared.ResourceLandTile,
Location: ct.Location,
Tags: ct.Tags,
DisplayName: &displayName,
Bonuses: nil,
OccupiedBy: nil,
OwnerID: nil,
})
}
if !includeVenus {
return tiles
}
// Venus tiles (non-adjacent coordinates so cities can't neighbor each other)
venusTiles := []struct {
Pos shared.HexPosition
Tags []string
DisplayName string
}{
{shared.HexPosition{Q: 100, R: 0, S: -100}, []string{BoardTagMaxwellBase}, "Maxwell Base"},
{shared.HexPosition{Q: 102, R: 0, S: -102}, []string{BoardTagStratopolis}, "Stratopolis"},
}
for _, vt := range venusTiles {
displayName := vt.DisplayName
tiles = append(tiles, Tile{
Coordinates: vt.Pos,
Type: shared.ResourceLandTile,
Location: TileLocationVenus,
Tags: vt.Tags,
DisplayName: &displayName,
Bonuses: nil,
OccupiedBy: nil,
OwnerID: nil,
})
}
return tiles
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
// FreeOceanSpaces returns the count of unoccupied ocean-space tiles on the board
func (b *Board) FreeOceanSpaces() int {
count := 0
for _, tile := range *b.tiles {
if tile.Type == shared.ResourceOceanSpace && tile.OccupiedBy == nil {
count++
}
}
return count
}
// Tiles returns a deep copy of all tiles to prevent external mutation
func (b *Board) Tiles() []Tile {
return b.deepCopyTiles()
}
// GetTile returns a copy of a specific tile by coordinates
func (b *Board) GetTile(coords shared.HexPosition) (*Tile, error) {
for i := range *b.tiles {
if (*b.tiles)[i].Coordinates == coords {
tileCopy := b.deepCopyTile(&(*b.tiles)[i])
return tileCopy, nil
}
}
return nil, fmt.Errorf("tile not found at coordinates %v", coords)
}
// SetTiles replaces all tiles (used for board generation)
func (b *Board) SetTiles(ctx context.Context, tiles []Tile) error {
if err := ctx.Err(); err != nil {
return err
}
newTiles := make([]Tile, len(tiles))
copy(newTiles, tiles)
*b.tiles = newTiles
return nil
}
// UpdateTileOccupancy updates a tile's occupancy state and publishes TilePlacedEvent
func (b *Board) UpdateTileOccupancy(ctx context.Context, coords shared.HexPosition, occupant TileOccupant, ownerID string) error {
if err := ctx.Err(); err != nil {
return err
}
var found bool
for i := range *b.tiles {
if (*b.tiles)[i].Coordinates == coords {
(*b.tiles)[i].OccupiedBy = &occupant
(*b.tiles)[i].OwnerID = &ownerID
(*b.tiles)[i].ReservedBy = nil
found = true
break
}
}
if !found {
return fmt.Errorf("tile not found at coordinates %v", coords)
}
if b.eventBus != nil {
events.Publish(b.eventBus, events.TilePlacedEvent{
GameID: b.gameID,
PlayerID: ownerID,
TileType: string(occupant.Type),
Q: coords.Q,
R: coords.R,
S: coords.S,
})
}
return nil
}
// ClearTileOccupant removes the occupant and owner from a tile (admin debug tool)
func (b *Board) ClearTileOccupant(ctx context.Context, coords shared.HexPosition) error {
if err := ctx.Err(); err != nil {
return err
}
var found bool
for i := range *b.tiles {
if (*b.tiles)[i].Coordinates == coords {
(*b.tiles)[i].OccupiedBy = nil
(*b.tiles)[i].OwnerID = nil
(*b.tiles)[i].ReservedBy = nil
found = true
break
}
}
if !found {
return fmt.Errorf("tile not found at coordinates %v", coords)
}
if b.eventBus != nil {
events.Publish(b.eventBus, events.GameStateChangedEvent{
GameID: b.gameID,
Timestamp: time.Now(),
})
}
return nil
}
// ClearTileBonuses removes all bonuses from a tile after they have been claimed
func (b *Board) ClearTileBonuses(ctx context.Context, coords shared.HexPosition) error {
if err := ctx.Err(); err != nil {
return err
}
var found bool
for i := range *b.tiles {
if (*b.tiles)[i].Coordinates == coords {
(*b.tiles)[i].Bonuses = nil
found = true
break
}
}
if !found {
return fmt.Errorf("tile not found at coordinates %v", coords)
}
return nil
}
// ReserveTile reserves a tile for exclusive future placement by a player
func (b *Board) ReserveTile(ctx context.Context, coords shared.HexPosition, playerID string) error {
if err := ctx.Err(); err != nil {
return err
}
for i := range *b.tiles {
if (*b.tiles)[i].Coordinates == coords {
if (*b.tiles)[i].OccupiedBy != nil {
return fmt.Errorf("cannot reserve tile at %v: already occupied", coords)
}
if (*b.tiles)[i].ReservedBy != nil {
return fmt.Errorf("cannot reserve tile at %v: already reserved by another player", coords)
}
(*b.tiles)[i].ReservedBy = &playerID
if b.eventBus != nil {
events.Publish(b.eventBus, events.GameStateChangedEvent{
GameID: b.gameID,
Timestamp: time.Now(),
})
}
return nil
}
}
return fmt.Errorf("tile not found at coordinates %v", coords)
}
// deepCopyTiles creates a deep copy of all tiles
func (b *Board) deepCopyTiles() []Tile {
tiles := make([]Tile, len(*b.tiles))
for i := range *b.tiles {
tiles[i] = *b.deepCopyTile(&(*b.tiles)[i])
}
return tiles
}
// deepCopyTile creates a deep copy of a single tile
func (b *Board) deepCopyTile(tile *Tile) *Tile {
tileCopy := *tile
tileCopy.Tags = make([]string, len(tile.Tags))
copy(tileCopy.Tags, tile.Tags)
tileCopy.Bonuses = make([]TileBonus, len(tile.Bonuses))
copy(tileCopy.Bonuses, tile.Bonuses)
if tile.DisplayName != nil {
displayNameCopy := *tile.DisplayName
tileCopy.DisplayName = &displayNameCopy
}
if tile.OccupiedBy != nil {
occupantCopy := *tile.OccupiedBy
occupantCopy.Tags = make([]string, len(tile.OccupiedBy.Tags))
copy(occupantCopy.Tags, tile.OccupiedBy.Tags)
tileCopy.OccupiedBy = &occupantCopy
}
if tile.OwnerID != nil {
ownerIDCopy := *tile.OwnerID
tileCopy.OwnerID = &ownerIDCopy
}
if tile.ReservedBy != nil {
reservedByCopy := *tile.ReservedBy
tileCopy.ReservedBy = &reservedByCopy
}
return &tileCopy
}
package cards
import (
"sort"
"terraforming-mars-backend/internal/game/award"
"terraforming-mars-backend/internal/game/board"
"terraforming-mars-backend/internal/game/player"
)
// AwardPlacement represents a player's placement in an award
type AwardPlacement struct {
PlayerID string
Score int
Placement int // 1 = first place, 2 = second place, 0 = no placement
}
// CalculateAwardScore calculates a player's score for an award using its quantifier definition
func CalculateAwardScore(
def *award.AwardDefinition,
p *player.Player,
b *board.Board,
cardRegistry CardRegistryInterface,
) int {
total := 0
for _, q := range def.Quantifier {
total += CountPerCondition(&q, "", p, b, cardRegistry, nil)
}
return total
}
// ScoreAward calculates placements for all players for an award
func ScoreAward(
def *award.AwardDefinition,
players []*player.Player,
b *board.Board,
cardRegistry CardRegistryInterface,
) []AwardPlacement {
placements := make([]AwardPlacement, len(players))
for i, p := range players {
placements[i] = AwardPlacement{
PlayerID: p.ID(),
Score: CalculateAwardScore(def, p, b, cardRegistry),
}
}
sort.Slice(placements, func(i, j int) bool {
return placements[i].Score > placements[j].Score
})
if len(placements) == 0 {
return placements
}
firstPlaceScore := placements[0].Score
for i := range placements {
if placements[i].Score == firstPlaceScore {
placements[i].Placement = 1
} else {
break
}
}
firstPlaceCount := 0
for _, p := range placements {
if p.Placement == 1 {
firstPlaceCount++
}
}
if firstPlaceCount < len(placements) {
var secondPlaceScore int
foundSecond := false
for _, p := range placements {
if p.Placement != 1 {
secondPlaceScore = p.Score
foundSecond = true
break
}
}
if foundSecond {
for i := range placements {
if placements[i].Placement == 0 && placements[i].Score == secondPlaceScore {
placements[i].Placement = 2
}
}
}
}
return placements
}
// GetAwardVP returns the VP for a specific placement using the award definition
func GetAwardVP(def *award.AwardDefinition, placement int) int {
return def.GetRewardVP(placement)
}
package cards
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/board"
"terraforming-mars-backend/internal/game/colony"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
)
// ColonyBonusLookup provides colony definition lookup for colony-bonus output handling.
type ColonyBonusLookup interface {
GetByID(colonyID string) (*colony.ColonyDefinition, error)
}
// BehaviorApplier handles applying card behavior inputs and outputs
// This is the single source of truth for all input/output application
type BehaviorApplier struct {
player *player.Player // Player affected by the behavior (may be nil for game-only effects)
game *game.Game // Game context for global params/tiles (may be nil for player-only effects)
source string // Source identifier for logging (card name, action name, etc.)
sourceCardID string // Card ID for self-card targeting (optional)
targetCardIDs []string // Card IDs for any-card targeting (positional, one per any-card output)
anyCardTargetIdx int // Index into targetCardIDs, incremented each time an any-card output is processed
targetPlayerID string // Player ID for any-player targeting (optional, set by caller)
stealSourceCardID string // Card ID to steal resources from for steal-from-any-card outputs (optional)
sourceBehaviorIdx int // Behavior index for card draw selection tracking
selectedAmount int // Player-selected amount for variable-amount behaviors (0 = not applicable)
actionPayment *CardPayment // Optional payment for action inputs with paymentAllowed (e.g., titanium for Water Import From Europa)
cardRegistry CardRegistryInterface // Card registry for tag counting in per conditions (optional)
sourceType shared.SourceType // Source type for triggered effect classification
colonyBonusLookup ColonyBonusLookup
deferredSteal shared.BehaviorCondition
logger *zap.Logger
}
// DeferredSteal returns the deferred steal output, if any (for post-tile-placement processing)
func (a *BehaviorApplier) DeferredSteal() shared.BehaviorCondition {
return a.deferredSteal
}
// NewBehaviorApplier creates a new behavior applier
// player and game can be nil if not needed for the specific operations
func NewBehaviorApplier(
p *player.Player,
g *game.Game,
source string,
logger *zap.Logger,
) *BehaviorApplier {
return &BehaviorApplier{
player: p,
game: g,
source: source,
logger: logger,
}
}
// WithSourceCardID sets the source card ID for self-card targeting
func (a *BehaviorApplier) WithSourceCardID(cardID string) *BehaviorApplier {
a.sourceCardID = cardID
return a
}
// WithTargetCardIDs sets the target card IDs for any-card resource placement (positional, one per any-card output)
func (a *BehaviorApplier) WithTargetCardIDs(cardIDs []string) *BehaviorApplier {
a.targetCardIDs = cardIDs
a.anyCardTargetIdx = 0
return a
}
// nextTargetCardID returns the next target card ID for any-card outputs and advances the index
func (a *BehaviorApplier) nextTargetCardID() string {
if a.anyCardTargetIdx >= len(a.targetCardIDs) {
return ""
}
id := a.targetCardIDs[a.anyCardTargetIdx]
a.anyCardTargetIdx++
return id
}
// WithCardRegistry sets the card registry for tag counting in scaled outputs
func (a *BehaviorApplier) WithCardRegistry(registry CardRegistryInterface) *BehaviorApplier {
a.cardRegistry = registry
return a
}
// WithTargetPlayerID sets the target player ID for any-player resource/production removal
func (a *BehaviorApplier) WithTargetPlayerID(playerID string) *BehaviorApplier {
a.targetPlayerID = playerID
return a
}
// WithStealSourceCardID sets the source card ID for steal-from-any-card outputs
func (a *BehaviorApplier) WithStealSourceCardID(cardID string) *BehaviorApplier {
a.stealSourceCardID = cardID
return a
}
// WithSourceBehaviorIndex sets the source behavior index for card draw selection tracking
func (a *BehaviorApplier) WithSourceBehaviorIndex(behaviorIndex int) *BehaviorApplier {
a.sourceBehaviorIdx = behaviorIndex
return a
}
// WithSelectedAmount sets the player-selected amount for variable-amount behaviors
func (a *BehaviorApplier) WithSelectedAmount(amount int) *BehaviorApplier {
a.selectedAmount = amount
return a
}
// WithActionPayment sets the payment for action inputs that have paymentAllowed
// (e.g., Water Import From Europa allows titanium as payment for the 12 M€ action cost)
func (a *BehaviorApplier) WithActionPayment(payment *CardPayment) *BehaviorApplier {
a.actionPayment = payment
return a
}
// WithSourceType sets the source type for triggered effect classification
func (a *BehaviorApplier) WithSourceType(sourceType shared.SourceType) *BehaviorApplier {
a.sourceType = sourceType
return a
}
// WithColonyBonusLookup sets the colony definition lookup for colony-bonus outputs
func (a *BehaviorApplier) WithColonyBonusLookup(lookup ColonyBonusLookup) *BehaviorApplier {
a.colonyBonusLookup = lookup
return a
}
// ApplyInputs validates player has required resources and deducts them
// Returns error if player is nil or insufficient resources
func (a *BehaviorApplier) ApplyInputs(
ctx context.Context,
inputs []shared.BehaviorCondition,
) error {
if len(inputs) == 0 {
return nil
}
if a.player == nil {
return fmt.Errorf("cannot apply inputs: no player context")
}
log := a.logger.With(
zap.String("source", a.source),
zap.Int("input_count", len(inputs)),
)
log.Debug("Processing behavior inputs")
resources := a.player.Resources().Get()
// Pass 1: Validate all inputs before deducting anything
for _, input := range inputs {
rt := input.GetResourceType()
effectiveAmount := input.GetAmount()
if shared.IsVariableAmount(input) {
effectiveAmount = input.GetAmount() * a.selectedAmount
}
// Storage resource inputs (target: "self-card") deduct from card storage
if input.GetTarget() == "self-card" && IsStorageResourceType(rt) {
if a.sourceCardID == "" {
return fmt.Errorf("cannot deduct from self-card: no source card ID")
}
storage := a.player.Resources().GetCardStorage(a.sourceCardID)
if storage < effectiveAmount {
return fmt.Errorf("insufficient %s on card: need %d, have %d", rt, effectiveAmount, storage)
}
continue
}
// Credit inputs with paymentAllowed use CardPayment-style validation
if paymentAllowed := shared.GetPaymentAllowed(input); rt == shared.ResourceCredit && len(paymentAllowed) > 0 {
if err := a.validateActionPayment(effectiveAmount, paymentAllowed, log); err != nil {
return err
}
continue
}
if err := a.validateInputAmount(rt, effectiveAmount, resources); err != nil {
return err
}
}
// Pass 2: Deduct resources
for _, input := range inputs {
rt := input.GetResourceType()
effectiveAmount := input.GetAmount()
if shared.IsVariableAmount(input) {
effectiveAmount = input.GetAmount() * a.selectedAmount
}
if effectiveAmount == 0 {
continue
}
// Storage resource inputs (target: "self-card") deduct from card storage
if input.GetTarget() == "self-card" && IsStorageResourceType(rt) {
a.player.Resources().AddToStorage(a.sourceCardID, -effectiveAmount)
log.Debug("Deducted from card storage",
zap.String("card_id", a.sourceCardID),
zap.String("resource_type", string(rt)),
zap.Int("amount", effectiveAmount))
continue
}
// Credit inputs with paymentAllowed use CardPayment-style deduction
if paymentAllowed := shared.GetPaymentAllowed(input); rt == shared.ResourceCredit && len(paymentAllowed) > 0 {
a.applyActionPayment(effectiveAmount, log)
continue
}
if shared.IsProductionResourceType(rt) {
a.player.Resources().AddProduction(map[shared.ResourceType]int{rt: -effectiveAmount})
log.Debug("Deducted production", zap.String("type", string(rt)), zap.Int("amount", effectiveAmount))
} else {
a.player.Resources().Add(map[shared.ResourceType]int{rt: -effectiveAmount})
log.Debug("Deducted resource", zap.String("type", string(rt)), zap.Int("amount", effectiveAmount))
}
}
return nil
}
// validateInputAmount checks the player has enough of the given resource type.
func (a *BehaviorApplier) validateInputAmount(rt shared.ResourceType, amount int, resources shared.Resources) error {
switch rt {
case shared.ResourceCredit:
if resources.Credits < amount {
return fmt.Errorf("insufficient credits: need %d, have %d", amount, resources.Credits)
}
case shared.ResourceSteel:
if resources.Steel < amount {
return fmt.Errorf("insufficient steel: need %d, have %d", amount, resources.Steel)
}
case shared.ResourceTitanium:
if resources.Titanium < amount {
return fmt.Errorf("insufficient titanium: need %d, have %d", amount, resources.Titanium)
}
case shared.ResourcePlant:
if resources.Plants < amount {
return fmt.Errorf("insufficient plants: need %d, have %d", amount, resources.Plants)
}
case shared.ResourceEnergy:
if resources.Energy < amount {
return fmt.Errorf("insufficient energy: need %d, have %d", amount, resources.Energy)
}
case shared.ResourceHeat:
if resources.Heat < amount {
return fmt.Errorf("insufficient heat: need %d, have %d", amount, resources.Heat)
}
default:
if shared.IsProductionResourceType(rt) {
production := a.player.Resources().Production()
available := production.GetAmount(rt)
if available < amount {
return fmt.Errorf("insufficient %s: need %d, have %d", rt, amount, available)
}
}
}
return nil
}
// validateActionPayment validates that the action payment covers the required cost
func (a *BehaviorApplier) validateActionPayment(
requiredAmount int,
paymentAllowed []shared.ResourceType,
log *zap.Logger,
) error {
if a.actionPayment == nil {
// No payment provided — fall back to checking if player has enough credits
resources := a.player.Resources().Get()
if resources.Credits < requiredAmount {
return fmt.Errorf("insufficient credits: need %d, have %d", requiredAmount, resources.Credits)
}
return nil
}
payment := a.actionPayment
if err := payment.Validate(); err != nil {
return fmt.Errorf("invalid action payment: %w", err)
}
// Verify player has the resources
resources := a.player.Resources().Get()
if resources.Credits < payment.Credits {
return fmt.Errorf("insufficient credits: need %d, have %d", payment.Credits, resources.Credits)
}
// Build allowed resource set
allowed := make(map[shared.ResourceType]bool)
for _, rt := range paymentAllowed {
allowed[rt] = true
}
// Validate titanium usage
if payment.Titanium > 0 {
if !allowed[shared.ResourceTitanium] {
return fmt.Errorf("titanium is not allowed as payment for this action")
}
if resources.Titanium < payment.Titanium {
return fmt.Errorf("insufficient titanium: need %d, have %d", payment.Titanium, resources.Titanium)
}
}
// Validate steel usage
if payment.Steel > 0 {
if !allowed[shared.ResourceSteel] {
return fmt.Errorf("steel is not allowed as payment for this action")
}
if resources.Steel < payment.Steel {
return fmt.Errorf("insufficient steel: need %d, have %d", payment.Steel, resources.Steel)
}
}
// Calculate total payment value using player's substitution rates
playerSubstitutes := a.player.Resources().PaymentSubstitutes()
totalValue := payment.TotalValue(playerSubstitutes, nil)
if totalValue < requiredAmount {
return fmt.Errorf("payment insufficient: action costs %d MC, payment provides %d MC", requiredAmount, totalValue)
}
log.Debug("Validated action payment",
zap.Int("required", requiredAmount),
zap.Int("credits", payment.Credits),
zap.Int("titanium", payment.Titanium),
zap.Int("steel", payment.Steel),
zap.Int("total_value", totalValue))
return nil
}
// applyActionPayment deducts resources according to the action payment
func (a *BehaviorApplier) applyActionPayment(
requiredAmount int,
log *zap.Logger,
) {
if a.actionPayment == nil {
// No payment struct — just deduct credits
a.player.Resources().Add(map[shared.ResourceType]int{
shared.ResourceCredit: -requiredAmount,
})
log.Debug("Deducted credits (no action payment)", zap.Int("amount", requiredAmount))
return
}
payment := a.actionPayment
if payment.Credits > 0 {
a.player.Resources().Add(map[shared.ResourceType]int{
shared.ResourceCredit: -payment.Credits,
})
log.Debug("Deducted credits from action payment", zap.Int("amount", payment.Credits))
}
if payment.Titanium > 0 {
a.player.Resources().Add(map[shared.ResourceType]int{
shared.ResourceTitanium: -payment.Titanium,
})
log.Debug("Deducted titanium from action payment", zap.Int("amount", payment.Titanium))
}
if payment.Steel > 0 {
a.player.Resources().Add(map[shared.ResourceType]int{
shared.ResourceSteel: -payment.Steel,
})
log.Debug("Deducted steel from action payment", zap.Int("amount", payment.Steel))
}
}
// isStorageResourceType returns true for resource types that are stored on cards
func IsStorageResourceType(rt shared.ResourceType) bool {
switch rt {
case shared.ResourceMicrobe, shared.ResourceAnimal, shared.ResourceFloater,
shared.ResourceScience, shared.ResourceAsteroid, shared.ResourceFighter, shared.ResourceDisease:
return true
}
return false
}
// isEffectOutputType returns true for output types that represent persistent effects
// rather than immediate resource gains (these get their own "Effect:" notification)
func isEffectOutputType(rt shared.ResourceType) bool {
switch rt {
case shared.ResourceDiscount, shared.ResourcePaymentSubstitute, shared.ResourceValueModifier,
shared.ResourceGlobalParameterLenience, shared.ResourceIgnoreGlobalRequirements,
shared.ResourceStoragePaymentSubstitute, shared.ResourceOceanAdjacencyBonus,
shared.ResourceDefense, shared.ResourceActionReuse:
return true
}
return false
}
// ApplyOutputs applies resource gains, production changes, global params, tile placements
// Returns error if required context (player/game) is missing for the operation
func (a *BehaviorApplier) ApplyOutputs(
ctx context.Context,
outputs []shared.BehaviorCondition,
) error {
_, err := a.ApplyOutputsAndGetCalculated(ctx, outputs)
return err
}
// ApplyOutputsAndGetCalculated applies outputs and returns the calculated values
// This is useful for logging scaled outputs (e.g., "+1 MC per 2 plant tags" becomes "+3 MC")
func (a *BehaviorApplier) ApplyOutputsAndGetCalculated(
ctx context.Context,
outputs []shared.BehaviorCondition,
) ([]shared.CalculatedOutput, error) {
if len(outputs) == 0 {
return nil, nil
}
log := a.logger.With(
zap.String("source", a.source),
zap.Int("output_count", len(outputs)),
)
log.Debug("Processing behavior outputs")
var calculatedOutputs []shared.CalculatedOutput
var notificationOutputs []shared.CalculatedOutput
for _, output := range outputs {
rt := output.GetResourceType()
baseAmount := output.GetAmount()
// Calculate the actual amount if this output has a Per condition
actualAmount := baseAmount
isScaled := false
if per := shared.GetPerCondition(output); per != nil && a.player != nil && a.game != nil {
count := a.countPerCondition(per)
if per.Amount > 0 {
multiplier := count / per.Amount
actualAmount = baseAmount * multiplier
isScaled = true
log.Debug("Calculated scaled output",
zap.String("resource_type", string(rt)),
zap.Int("base_amount", baseAmount),
zap.Int("count", count),
zap.Int("per_amount", per.Amount),
zap.Int("calculated_amount", actualAmount))
}
}
// Apply variable amount multiplier (player-selected amount)
if shared.IsVariableAmount(output) {
actualAmount = baseAmount * a.selectedAmount
isScaled = true
log.Debug("Applied variable amount",
zap.String("resource_type", string(rt)),
zap.Int("base_amount", baseAmount),
zap.Int("selected_amount", a.selectedAmount),
zap.Int("calculated_amount", actualAmount))
}
if err := a.applyOutput(ctx, output, actualAmount, log); err != nil {
return calculatedOutputs, err
}
// Colony-bonus outputs expand into the actual resources gained
if rt == shared.ResourceColonyBonus {
bonusOutputs := a.collectColonyBonusOutputs(log)
calculatedOutputs = append(calculatedOutputs, bonusOutputs...)
notificationOutputs = append(notificationOutputs, bonusOutputs...)
continue
}
// Track for state diff log (existing behavior)
if isScaled || actualAmount != 0 {
calculatedOutputs = append(calculatedOutputs, shared.CalculatedOutput{
ResourceType: string(rt),
Amount: actualAmount,
IsScaled: isScaled,
})
}
// Track non-zero resource outputs for triggered effect notifications
// Skip effect-type outputs (discount, payment-substitute, etc.) since they get
// their own "Effect:" notification via SourceTypeEffectAdded
if actualAmount != 0 && !isEffectOutputType(rt) {
resourceType := string(rt)
if resourceType == string(shared.ResourceCardResource) {
resourceType = a.resolveCardResourceType()
}
notificationOutputs = append(notificationOutputs, shared.CalculatedOutput{
ResourceType: resourceType,
Amount: actualAmount,
IsScaled: isScaled,
})
}
}
if a.game != nil && a.player != nil && len(outputs) > 0 {
effect := shared.TriggeredEffect{
CardName: a.source,
PlayerID: a.player.ID(),
SourceType: a.sourceType,
Outputs: outputs,
CalculatedOutputs: notificationOutputs,
}
if a.sourceType == shared.SourceTypePassiveEffect {
a.game.AddOrMergeTriggeredEffect(effect)
} else {
a.game.AddTriggeredEffect(effect)
}
}
return calculatedOutputs, nil
}
// resolveCardResourceType resolves "card-resource" to the actual storage type of the last consumed target card
func (a *BehaviorApplier) resolveCardResourceType() string {
if a.anyCardTargetIdx == 0 || a.cardRegistry == nil {
return string(shared.ResourceCardResource)
}
lastTargetID := a.targetCardIDs[a.anyCardTargetIdx-1]
targetCard, err := a.cardRegistry.GetByID(lastTargetID)
if err != nil || targetCard.ResourceStorage == nil {
return string(shared.ResourceCardResource)
}
return string(targetCard.ResourceStorage.Type)
}
func (a *BehaviorApplier) countPerCondition(per *shared.PerCondition) int {
if per != nil && per.ResourceType == shared.ResourceColonyCount && a.game != nil {
return a.game.Colonies().CountAllColonies()
}
var b *board.Board
var allPlayers []*player.Player
if a.game != nil {
b = a.game.Board()
allPlayers = a.game.GetAllPlayers()
}
return CountPerCondition(per, a.sourceCardID, a.player, b, a.cardRegistry, allPlayers)
}
// ApplyCardDrawOutputs processes card-peek/take/buy outputs together
// Returns true if a pending selection was created (caller should defer action consumption)
func (a *BehaviorApplier) ApplyCardDrawOutputs(
ctx context.Context,
outputs []shared.BehaviorCondition,
) (bool, error) {
log := a.logger.With(
zap.String("source", a.source),
zap.String("method", "ApplyCardDrawOutputs"),
)
// Scan outputs for card-peek, card-take, card-buy
var peekAmount, takeAmount, buyAmount int
var isPrelude bool
for _, output := range outputs {
switch output.GetResourceType() {
case shared.ResourceCardPeek:
peekAmount += output.GetAmount()
case shared.ResourceCardTake:
takeAmount += output.GetAmount()
case shared.ResourceCardBuy:
buyAmount += output.GetAmount()
}
if co, ok := output.(*shared.CardOperationCondition); ok && hasPreludeCardType(co.Selectors) {
isPrelude = true
}
}
// If no card-peek found, nothing to do
if peekAmount == 0 {
return false, nil
}
if a.player == nil {
return false, fmt.Errorf("cannot apply card draw outputs: no player context")
}
if a.game == nil {
return false, fmt.Errorf("cannot apply card draw outputs: no game context")
}
// Draw cards from the appropriate deck
var drawnCards []string
var err error
if isPrelude {
drawnCards, err = a.game.Deck().DrawPreludeCards(ctx, peekAmount)
} else {
drawnCards, err = a.game.Deck().DrawProjectCards(ctx, peekAmount)
}
if err != nil {
return false, fmt.Errorf("failed to draw cards: %w", err)
}
log.Debug("Drew cards for peek selection",
zap.Int("peek_amount", peekAmount),
zap.Int("take_amount", takeAmount),
zap.Int("buy_amount", buyAmount),
zap.Bool("is_prelude", isPrelude),
zap.Strings("drawn_cards", drawnCards))
// Calculate card buy cost (accounts for discounts like Polyphemos)
cardBuyCost := 3
if a.player != nil && a.cardRegistry != nil {
calc := NewRequirementModifierCalculator(a.cardRegistry)
discounts := calc.CalculateActionDiscounts(a.player, shared.ActionCardBuying)
cardBuyCost = max(3-discounts[shared.ResourceCredit], 0)
}
// Create pending card draw selection
selection := &shared.PendingCardDrawSelection{
AvailableCards: drawnCards,
FreeTakeCount: takeAmount,
MaxBuyCount: buyAmount,
CardBuyCost: cardBuyCost,
Source: a.source,
SourceCardID: a.sourceCardID,
SourceBehaviorIndex: a.sourceBehaviorIdx,
PlayAsPrelude: isPrelude,
}
// Set on player
a.player.Selection().SetPendingCardDrawSelection(selection)
log.Debug("Created pending card draw selection",
zap.String("source", a.source),
zap.String("source_card_id", a.sourceCardID),
zap.Int("source_behavior_index", a.sourceBehaviorIdx),
zap.Int("available_cards", len(drawnCards)),
zap.Int("free_take", takeAmount),
zap.Int("max_buy", buyAmount))
return true, nil
}
// stealAnyPlayerResource removes resources from the target player and adds them to self
func (a *BehaviorApplier) stealAnyPlayerResource(
resourceType shared.ResourceType,
amount int,
log *zap.Logger,
) error {
if a.targetPlayerID == "" {
log.Debug("Skipping steal: no target player (solo mode)",
zap.String("resource_type", string(resourceType)))
return nil
}
if a.game == nil {
return fmt.Errorf("cannot steal resource: no game context")
}
if a.player == nil {
return fmt.Errorf("cannot steal resource: no player context")
}
targetPlayer, err := a.game.GetPlayer(a.targetPlayerID)
if err != nil {
return fmt.Errorf("target player not found: %w", err)
}
resources := targetPlayer.Resources().Get()
var current int
switch resourceType {
case shared.ResourceCredit:
current = resources.Credits
case shared.ResourceSteel:
current = resources.Steel
case shared.ResourceTitanium:
current = resources.Titanium
case shared.ResourcePlant:
current = resources.Plants
case shared.ResourceEnergy:
current = resources.Energy
case shared.ResourceHeat:
current = resources.Heat
}
stolenAmount := min(amount, current)
if stolenAmount > 0 {
targetPlayer.Resources().Add(map[shared.ResourceType]int{
resourceType: -stolenAmount,
})
a.player.Resources().Add(map[shared.ResourceType]int{
resourceType: stolenAmount,
})
}
log.Debug("Stole resource from target player",
zap.String("target_player_id", a.targetPlayerID),
zap.String("resource_type", string(resourceType)),
zap.Int("requested", amount),
zap.Int("stolen", stolenAmount))
return nil
}
// applyAnyPlayerResource removes resources from the target player (clamped to what they have)
func (a *BehaviorApplier) applyAnyPlayerResource(
resourceType shared.ResourceType,
amount int,
log *zap.Logger,
) error {
if a.targetPlayerID == "" {
log.Debug("Skipping any-player resource removal: no target player (solo mode)",
zap.String("resource_type", string(resourceType)))
return nil
}
if a.game == nil {
return fmt.Errorf("cannot apply any-player resource: no game context")
}
targetPlayer, err := a.game.GetPlayer(a.targetPlayerID)
if err != nil {
return fmt.Errorf("target player not found: %w", err)
}
resources := targetPlayer.Resources().Get()
var current int
switch resourceType {
case shared.ResourceCredit:
current = resources.Credits
case shared.ResourceSteel:
current = resources.Steel
case shared.ResourceTitanium:
current = resources.Titanium
case shared.ResourcePlant:
current = resources.Plants
case shared.ResourceEnergy:
current = resources.Energy
case shared.ResourceHeat:
current = resources.Heat
}
// Card data uses negative amounts for removal (e.g., Deimos Down: amount=-8).
// Normalize to positive for clamping.
absAmount := amount
if absAmount < 0 {
absAmount = -absAmount
}
removeAmount := min(absAmount, current)
if removeAmount > 0 {
targetPlayer.Resources().Add(map[shared.ResourceType]int{
resourceType: -removeAmount,
})
}
log.Debug("Removed resource from target player",
zap.String("target_player_id", a.targetPlayerID),
zap.String("resource_type", string(resourceType)),
zap.Int("requested", absAmount),
zap.Int("removed", removeAmount))
return nil
}
// applyAnyPlayerProduction applies production changes to the target player.
// Card data uses negative amounts for decreases (e.g., Asteroid Mining Consortium: amount=-1).
// The amount is applied directly via AddProduction (which handles clamping to minimums).
func (a *BehaviorApplier) applyAnyPlayerProduction(
productionType shared.ResourceType,
amount int,
log *zap.Logger,
) error {
if a.targetPlayerID == "" {
log.Debug("Skipping any-player production change: no target player (solo mode)",
zap.String("production_type", string(productionType)))
return nil
}
if a.game == nil {
return fmt.Errorf("cannot apply any-player production: no game context")
}
targetPlayer, err := a.game.GetPlayer(a.targetPlayerID)
if err != nil {
return fmt.Errorf("target player not found: %w", err)
}
targetPlayer.Resources().AddProduction(map[shared.ResourceType]int{
productionType: amount,
})
log.Debug("Applied production change to target player",
zap.String("target_player_id", a.targetPlayerID),
zap.String("production_type", string(productionType)),
zap.Int("amount", amount))
return nil
}
// applyOutput dispatches a single output to the appropriate category handler.
// The amount parameter is the pre-calculated value (accounting for Per and VariableAmount).
func (a *BehaviorApplier) applyOutput(
ctx context.Context,
output shared.BehaviorCondition,
amount int,
log *zap.Logger,
) error {
switch o := output.(type) {
case *shared.BasicResourceCondition:
return a.applyBasicResourceOutput(ctx, o, amount, log)
case *shared.ProductionCondition:
return a.applyProductionOutput(ctx, o, amount, log)
case *shared.GlobalParameterCondition:
return a.applyGlobalParameterOutput(ctx, o, amount, log)
case *shared.TilePlacementCondition:
return a.applyTilePlacementOutput(ctx, o, amount, log)
case *shared.EffectCondition:
return a.applyEffectOutput(ctx, o, amount, log)
case *shared.CardStorageCondition:
return a.applyCardStorageOutput(ctx, o, amount, log)
case *shared.CardOperationCondition:
return a.applyCardOperationOutput(ctx, o, amount, log)
case *shared.ColonyCondition:
return a.applyColonyOutput(ctx, o, amount, log)
case *shared.TileModificationCondition:
return a.applyTileModificationOutput(ctx, o, amount, log)
case *shared.MiscCondition:
return a.applyMiscOutput(ctx, o, amount, log)
default:
return fmt.Errorf("unknown output condition type: %T", output)
}
}
// applyColonyBonuses applies all colony bonuses for the player.
// Card-targeted resources (microbe, animal, floater) are queued for player selection.
func (a *BehaviorApplier) applyColonyBonuses(_ context.Context, log *zap.Logger) {
bonuses := CollectColonyBonuses(a.player.ID(), a.game.Colonies().States(), a.colonyBonusLookup)
pendingByType := map[string]int{}
var pendingOrder []string
for _, b := range bonuses {
rt := shared.ResourceType(b.ResourceType)
if IsStorageResourceType(rt) {
if _, exists := pendingByType[b.ResourceType]; !exists {
pendingOrder = append(pendingOrder, b.ResourceType)
}
pendingByType[b.ResourceType] += b.Amount
} else {
a.player.Resources().Add(map[shared.ResourceType]int{rt: b.Amount})
}
log.Debug("Applied colony bonus",
zap.String("type", b.ResourceType),
zap.Int("amount", b.Amount))
}
for _, rt := range pendingOrder {
amount := pendingByType[rt]
if !HasEligibleStorageCard(a.player, shared.ResourceType(rt), a.cardRegistry) {
log.Debug("No eligible storage card for colony bonus, resources lost",
zap.String("resource_type", rt),
zap.Int("amount", amount))
continue
}
a.player.Selection().AppendPendingColonyResource(shared.PendingColonyResourceSelection{
ResourceType: rt,
Amount: amount,
Source: a.source,
Reason: "colony-bonus",
})
}
}
func (a *BehaviorApplier) collectColonyBonusOutputs(_ *zap.Logger) []shared.CalculatedOutput {
if a.game == nil || a.player == nil || a.colonyBonusLookup == nil {
return nil
}
return ColonyBonusesToCalculatedOutputs(
CollectColonyBonuses(a.player.ID(), a.game.Colonies().States(), a.colonyBonusLookup),
)
}
// ColonyBonusEntry represents a single colony bonus resource gain.
type ColonyBonusEntry struct {
ResourceType string
Amount int
}
// CollectColonyBonuses iterates colony tile states and returns all bonuses for the given player.
func CollectColonyBonuses(playerID string, tileStates []*colony.ColonyState, lookup ColonyBonusLookup) []ColonyBonusEntry {
if lookup == nil {
return nil
}
var result []ColonyBonusEntry
for _, ts := range tileStates {
colonyCount := 0
for _, ownerID := range ts.PlayerColonies {
if ownerID == playerID {
colonyCount++
}
}
if colonyCount == 0 {
continue
}
def, err := lookup.GetByID(ts.DefinitionID)
if err != nil {
continue
}
for i := 0; i < colonyCount; i++ {
for _, bonus := range def.ColonyBonus {
if bonus.Amount > 0 {
result = append(result, ColonyBonusEntry{ResourceType: bonus.Type, Amount: bonus.Amount})
}
}
}
}
return result
}
// ColonyBonusesToCalculatedOutputs aggregates colony bonus entries into calculated outputs by type.
func ColonyBonusesToCalculatedOutputs(bonuses []ColonyBonusEntry) []shared.CalculatedOutput {
if len(bonuses) == 0 {
return []shared.CalculatedOutput{}
}
totals := map[string]int{}
var order []string
for _, b := range bonuses {
if _, exists := totals[b.ResourceType]; !exists {
order = append(order, b.ResourceType)
}
totals[b.ResourceType] += b.Amount
}
outputs := make([]shared.CalculatedOutput, 0, len(totals))
for _, rt := range order {
outputs = append(outputs, shared.CalculatedOutput{ResourceType: rt, Amount: totals[rt]})
}
return outputs
}
// HasEligibleStorageCard checks if a player has any played card or corporation
// that can store the given resource type.
func HasEligibleStorageCard(p *player.Player, resourceType shared.ResourceType, cardRegistry CardRegistryInterface) bool {
if cardRegistry == nil {
return false
}
for _, cardID := range p.PlayedCards().Cards() {
card, err := cardRegistry.GetByID(cardID)
if err != nil {
continue
}
if card.ResourceStorage != nil && card.ResourceStorage.Type == resourceType {
return true
}
}
if corpID := p.CorporationID(); corpID != "" {
corp, err := cardRegistry.GetByID(corpID)
if err == nil && corp.ResourceStorage != nil && corp.ResourceStorage.Type == resourceType {
return true
}
}
return false
}
package cards
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game/shared"
)
func (a *BehaviorApplier) applyCardStorageOutput(ctx context.Context, o *shared.CardStorageCondition, amount int, log *zap.Logger) error {
if a.player == nil {
return fmt.Errorf("cannot apply card resource: no player context")
}
rt := o.ResourceType
// Generic card-resource: add resources of whatever type the target card stores
if rt == shared.ResourceCardResource {
targetID := a.nextTargetCardID()
if targetID == "" {
return fmt.Errorf("no target card specified for card-resource output")
}
if a.cardRegistry == nil {
return fmt.Errorf("cannot apply card-resource: no card registry")
}
targetCard, err := a.cardRegistry.GetByID(targetID)
if err != nil {
return fmt.Errorf("target card not found in registry: %w", err)
}
if targetCard.ResourceStorage == nil {
return fmt.Errorf("target card %s has no resource storage", targetID)
}
a.player.Resources().AddToStorage(targetID, amount)
log.Debug("Added card-resource to target card storage",
zap.String("card_id", targetID), zap.String("storage_type", string(targetCard.ResourceStorage.Type)), zap.Int("amount", amount))
return nil
}
// Specific storage types (animal, microbe, floater, etc.)
switch o.Target {
case "self-card":
if a.sourceCardID == "" {
log.Warn("Cannot place resource on self-card: no source card ID", zap.String("resource_type", string(rt)))
return nil
}
a.player.Resources().AddToStorage(a.sourceCardID, amount)
log.Debug("Added resource to card storage",
zap.String("card_id", a.sourceCardID), zap.String("resource_type", string(rt)), zap.Int("amount", amount))
case "steal-from-any-card":
if a.stealSourceCardID == "" {
return fmt.Errorf("steal-from-any-card requires a source card ID")
}
if a.game == nil {
return fmt.Errorf("cannot steal from card: no game context")
}
stolenAmount := 0
for _, p := range a.game.GetAllPlayers() {
storage := p.Resources().GetCardStorage(a.stealSourceCardID)
if storage > 0 {
stolenAmount = min(amount, storage)
p.Resources().AddToStorage(a.stealSourceCardID, -stolenAmount)
log.Debug("Stole resource from card",
zap.String("source_card_id", a.stealSourceCardID), zap.String("owner_player_id", p.ID()),
zap.String("resource_type", string(rt)), zap.Int("amount", stolenAmount))
break
}
}
if stolenAmount > 0 && a.sourceCardID != "" {
a.player.Resources().AddToStorage(a.sourceCardID, stolenAmount)
log.Debug("Added stolen resource to self card",
zap.String("card_id", a.sourceCardID), zap.String("resource_type", string(rt)), zap.Int("amount", stolenAmount))
}
case "any-card":
targetID := a.nextTargetCardID()
if targetID == "" {
log.Warn("No target card for any-card resource placement — resources lost",
zap.String("resource_type", string(rt)), zap.Int("amount", amount))
return nil
}
if a.cardRegistry != nil {
targetCard, err := a.cardRegistry.GetByID(targetID)
if err != nil {
return fmt.Errorf("target card %s not found in registry: %w", targetID, err)
}
if targetCard.ResourceStorage == nil {
return fmt.Errorf("target card %s has no resource storage", targetID)
}
if targetCard.ResourceStorage.Type != rt {
return fmt.Errorf("target card %s stores %s, cannot add %s", targetID, targetCard.ResourceStorage.Type, rt)
}
}
a.player.Resources().AddToStorage(targetID, amount)
log.Debug("Added resource to target card storage",
zap.String("card_id", targetID), zap.String("resource_type", string(rt)), zap.Int("amount", amount))
default:
if a.sourceCardID != "" {
a.player.Resources().AddToStorage(a.sourceCardID, amount)
log.Debug("Added resource to card storage (default to self)",
zap.String("card_id", a.sourceCardID), zap.String("resource_type", string(rt)), zap.Int("amount", amount))
} else {
log.Warn("Unhandled target for card resource", zap.String("target", o.Target), zap.String("resource_type", string(rt)))
}
}
return nil
}
func (a *BehaviorApplier) applyCardOperationOutput(ctx context.Context, o *shared.CardOperationCondition, amount int, log *zap.Logger) error {
switch o.ResourceType {
case shared.ResourceCardDraw:
if a.game == nil || a.player == nil {
return fmt.Errorf("cannot apply card-draw: missing game or player context")
}
if o.Target == "all-opponents" {
for _, opponent := range a.game.GetAllPlayers() {
if opponent.ID() == a.player.ID() {
continue
}
drawnCards, err := a.game.Deck().DrawProjectCards(ctx, amount)
if err != nil {
log.Warn("Failed to draw cards for opponent", zap.String("opponent_id", opponent.ID()), zap.Error(err))
continue
}
for _, cardID := range drawnCards {
opponent.Hand().AddCard(cardID)
}
log.Debug("Opponent drew cards", zap.String("opponent_id", opponent.ID()), zap.Int("amount", len(drawnCards)))
}
} else if HasCardSelectors(o.Selectors) && a.cardRegistry != nil {
matcher := func(cardID string) bool {
card, err := a.cardRegistry.GetByID(cardID)
if err != nil || card == nil {
return false
}
return MatchesAnySelector(card, o.Selectors)
}
matched, discarded, err := a.game.Deck().DrawProjectCardsUntilMatching(ctx, amount, matcher)
if err != nil {
log.Warn("Failed to draw matching cards", zap.Error(err))
return nil
}
for _, cardID := range matched {
a.player.Hand().AddCard(cardID)
}
if len(discarded) > 0 {
_ = a.game.Deck().Discard(ctx, discarded)
}
log.Debug("Drew matching cards (draw-until)", zap.Int("matched", len(matched)), zap.Int("discarded", len(discarded)))
} else {
drawnCards, err := a.game.Deck().DrawProjectCards(ctx, amount)
if err != nil {
log.Warn("Failed to draw cards", zap.Error(err))
return nil
}
for _, cardID := range drawnCards {
a.player.Hand().AddCard(cardID)
}
log.Debug("Drew cards and added to hand", zap.Int("amount", len(drawnCards)))
}
case shared.ResourceCardDiscard:
log.Debug("Skipping card-discard output (handled at action layer)")
case shared.ResourceCardPeek, shared.ResourceCardTake, shared.ResourceCardBuy:
log.Debug("Skipping card draw output (handled by ApplyCardDrawOutputs)",
zap.String("type", string(o.ResourceType)), zap.Int("amount", amount))
default:
log.Warn("Unhandled card operation type", zap.String("type", string(o.ResourceType)))
}
return nil
}
package cards
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game/shared"
)
func (a *BehaviorApplier) applyEffectOutput(ctx context.Context, o *shared.EffectCondition, amount int, log *zap.Logger) error {
switch o.ResourceType {
case shared.ResourcePaymentSubstitute:
if a.player == nil {
return fmt.Errorf("cannot apply payment substitute: no player context")
}
resources := GetResourcesFromSelectors(o.Selectors)
if len(resources) > 0 {
resourceType := shared.ResourceType(resources[0])
a.player.Resources().AddPaymentSubstitute(resourceType, amount)
log.Debug("Added payment substitute",
zap.String("resource_type", string(resourceType)), zap.Int("conversion_rate", amount))
} else {
log.Warn("payment-substitute output missing selectors with resources")
}
case shared.ResourceDiscount:
log.Debug("Discount effect registered", zap.Int("amount", amount), zap.Any("selectors", o.Selectors))
case shared.ResourceGlobalParameterLenience:
log.Debug("Global parameter lenience effect registered", zap.Int("amount", amount), zap.String("temporary", o.Temporary))
case shared.ResourceIgnoreGlobalRequirements:
log.Debug("Ignore global requirements effect registered", zap.String("temporary", o.Temporary))
case shared.ResourceValueModifier:
if a.player == nil {
return fmt.Errorf("cannot apply value modifier: no player context")
}
for _, resourceStr := range GetResourcesFromSelectors(o.Selectors) {
resourceType := shared.ResourceType(resourceStr)
a.player.Resources().AddValueModifier(resourceType, amount)
log.Debug("Added resource value modifier",
zap.String("resource_type", string(resourceType)), zap.Int("modifier_amount", amount))
}
case shared.ResourceStoragePaymentSubstitute:
if a.player == nil {
return fmt.Errorf("cannot apply storage payment substitute: no player context")
}
if a.sourceCardID == "" {
log.Warn("storage-payment-substitute output missing source card ID")
return nil
}
storageResourceType := shared.ResourceFloater
if a.cardRegistry != nil {
if sourceCard, err := a.cardRegistry.GetByID(a.sourceCardID); err == nil && sourceCard.ResourceStorage != nil {
storageResourceType = sourceCard.ResourceStorage.Type
}
}
targetResource := shared.ResourceCredit
resources := GetResourcesFromSelectors(o.Selectors)
if len(resources) > 0 {
targetResource = shared.ResourceType(resources[0])
}
a.player.Resources().AddStoragePaymentSubstitute(shared.StoragePaymentSubstitute{
CardID: a.sourceCardID,
ResourceType: storageResourceType,
ConversionRate: amount,
TargetResource: targetResource,
Selectors: o.Selectors,
})
log.Debug("Added storage payment substitute",
zap.String("card_id", a.sourceCardID), zap.String("resource_type", string(storageResourceType)),
zap.String("target_resource", string(targetResource)), zap.Int("conversion_rate", amount))
case shared.ResourceOceanAdjacencyBonus:
log.Debug("Ocean adjacency bonus effect registered", zap.Int("amount", amount))
case shared.ResourceDefense:
log.Debug("Defense effect registered", zap.Int("amount", amount), zap.Any("selectors", o.Selectors))
case shared.ResourceActionReuse:
log.Debug("Skipping action-reuse output (handled at action layer)")
case shared.ResourceEffect:
log.Debug("Effect registered", zap.Int("amount", amount))
case shared.ResourceTag:
log.Debug("Tag effect registered", zap.Int("amount", amount))
default:
log.Warn("Unhandled effect type", zap.String("type", string(o.ResourceType)))
}
return nil
}
package cards
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game/shared"
)
func (a *BehaviorApplier) applyColonyOutput(ctx context.Context, o *shared.ColonyCondition, amount int, log *zap.Logger) error {
switch o.ResourceType {
case shared.ResourceColony:
if a.game == nil || a.player == nil {
return fmt.Errorf("cannot apply colony tile: missing game or player context")
}
if !a.game.HasColonies() {
log.Warn("Colony tile output ignored: colonies expansion not enabled")
return nil
}
colonyIDs := a.game.Colonies().GetPlaceableIDs(a.player.ID(), o.AllowDuplicatePlayerColony)
if len(colonyIDs) == 0 {
log.Warn("No colony tiles available for placement")
return nil
}
a.player.Selection().SetPendingColonySelection(&shared.PendingColonySelection{
AvailableColonyIDs: colonyIDs,
AllowDuplicatePlayerColony: o.AllowDuplicatePlayerColony,
Source: a.source,
SourceCardID: a.sourceCardID,
})
log.Debug("Set pending colony selection",
zap.Int("available_colonies", len(colonyIDs)), zap.Bool("allow_duplicate", o.AllowDuplicatePlayerColony))
case shared.ResourceColonyBonus:
if a.game == nil || a.player == nil {
return fmt.Errorf("cannot apply colony bonus: missing game or player context")
}
if !a.game.HasColonies() {
log.Warn("Colony bonus output ignored: colonies expansion not enabled")
return nil
}
if a.colonyBonusLookup == nil {
return fmt.Errorf("cannot apply colony bonus: no colony bonus lookup configured")
}
a.applyColonyBonuses(ctx, log)
case shared.ResourceColonyCount, shared.ResourceColonyTrackStep:
log.Debug("Colony count/track output (informational)", zap.String("type", string(o.ResourceType)), zap.Int("amount", amount))
default:
log.Warn("Unhandled colony type", zap.String("type", string(o.ResourceType)))
}
return nil
}
func (a *BehaviorApplier) applyMiscOutput(ctx context.Context, o *shared.MiscCondition, amount int, log *zap.Logger) error {
switch o.ResourceType {
case shared.ResourceExtraActions:
if a.game == nil {
return fmt.Errorf("cannot apply extra actions: no game context")
}
currentTurn := a.game.CurrentTurn()
if currentTurn != nil {
currentTurn.AddExtraActions(amount)
}
log.Debug("Granted extra actions", zap.Int("amount", amount))
case shared.ResourceBonusTags:
if a.player == nil {
return fmt.Errorf("cannot apply bonus tags: no player context")
}
if o.Per != nil && o.Per.Tag != nil {
tagToCount := *o.Per.Tag
tagToGrant := shared.CardTag(o.ResourceType)
if len(o.Selectors) > 0 && len(o.Selectors[0].Tags) > 0 {
tagToGrant = o.Selectors[0].Tags[0]
}
var tagCount int
if a.cardRegistry != nil {
tagCount = CountPlayerTagsByType(a.player, a.cardRegistry, tagToCount)
}
bonusCount := tagCount * amount
if bonusCount > 0 {
a.player.AddBonusTags(tagToGrant, bonusCount)
}
log.Debug("Added bonus tags",
zap.String("tag_type", string(tagToGrant)), zap.Int("count", bonusCount),
zap.String("per_tag", string(tagToCount)), zap.Int("tag_count", tagCount))
}
case shared.ResourceFreeTrade:
if a.game == nil || a.player == nil {
return fmt.Errorf("cannot apply free trade: missing game or player context")
}
if !a.game.HasColonies() {
log.Warn("Free trade output ignored: colonies expansion not enabled")
return nil
}
if !a.game.Colonies().GetTradeFleetAvailable(a.player.ID()) {
log.Warn("Free trade output ignored: no trade fleet available")
return nil
}
tradeableColonyIDs := a.game.Colonies().GetTradeableIDs()
if len(tradeableColonyIDs) == 0 {
log.Warn("Free trade output ignored: no colonies available for trading")
return nil
}
a.player.Selection().SetPendingFreeTradeSelection(&shared.PendingFreeTradeSelection{
AvailableColonyIDs: tradeableColonyIDs,
Source: a.source,
SourceCardID: a.sourceCardID,
})
log.Debug("Set pending free trade selection", zap.Int("available_colonies", len(tradeableColonyIDs)))
case shared.ResourceWorldTreeTile:
log.Debug("World tree tile output", zap.Int("amount", amount))
case shared.ResourceAwardFund:
log.Debug("Award fund output", zap.Int("amount", amount))
default:
log.Warn("Unhandled misc output type", zap.String("type", string(o.ResourceType)))
}
return nil
}
package cards
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game/shared"
)
func (a *BehaviorApplier) applyBasicResourceOutput(ctx context.Context, o *shared.BasicResourceCondition, amount int, log *zap.Logger) error {
rt := o.ResourceType
// Special case: credit steal with adjacency restriction (deferred for post-tile-placement)
if rt == shared.ResourceCredit && o.Target == "steal-any-player" && o.TargetRestriction != nil && o.TargetRestriction.Adjacent == "self-card" {
a.deferredSteal = o
log.Debug("Deferred adjacent steal for post-tile-placement", zap.Int("amount", amount))
return nil
}
if o.Target == "steal-any-player" {
return a.stealAnyPlayerResource(rt, amount, log)
}
if o.Target == "any-player" {
return a.applyAnyPlayerResource(rt, amount, log)
}
if a.player == nil {
return fmt.Errorf("cannot apply %s: no player context", rt)
}
a.player.Resources().Add(map[shared.ResourceType]int{rt: amount})
log.Debug("Added resource", zap.String("type", string(rt)), zap.Int("amount", amount))
return nil
}
func (a *BehaviorApplier) applyProductionOutput(ctx context.Context, o *shared.ProductionCondition, amount int, log *zap.Logger) error {
rt := o.ResourceType
if o.Target == "any-player" {
return a.applyAnyPlayerProduction(rt, amount, log)
}
if a.player == nil {
return fmt.Errorf("cannot apply %s: no player context", rt)
}
a.player.Resources().AddProduction(map[shared.ResourceType]int{rt: amount})
log.Debug("Added production", zap.String("type", string(rt)), zap.Int("amount", amount))
return nil
}
func (a *BehaviorApplier) applyGlobalParameterOutput(ctx context.Context, o *shared.GlobalParameterCondition, amount int, log *zap.Logger) error {
switch o.ResourceType {
case shared.ResourceTR:
if a.player == nil {
return fmt.Errorf("cannot apply terraform rating: no player context")
}
a.player.Resources().UpdateTerraformRating(amount)
log.Debug("Added terraform rating", zap.Int("amount", amount))
case shared.ResourceOxygen:
if a.game == nil {
return fmt.Errorf("cannot apply oxygen: no game context")
}
actualSteps, err := a.game.GlobalParameters().IncreaseOxygen(ctx, amount, a.player.ID())
if err != nil {
return fmt.Errorf("failed to increase oxygen: %w", err)
}
if actualSteps > 0 && a.player != nil {
a.player.Resources().UpdateTerraformRating(actualSteps)
}
log.Debug("Increased oxygen", zap.Int("steps", actualSteps), zap.Int("tr_gained", actualSteps))
case shared.ResourceTemperature:
if a.game == nil {
return fmt.Errorf("cannot apply temperature: no game context")
}
actualSteps, err := a.game.GlobalParameters().IncreaseTemperature(ctx, amount, a.player.ID())
if err != nil {
return fmt.Errorf("failed to increase temperature: %w", err)
}
if actualSteps > 0 && a.player != nil {
a.player.Resources().UpdateTerraformRating(actualSteps)
}
log.Debug("Increased temperature", zap.Int("steps", actualSteps), zap.Int("tr_gained", actualSteps))
case shared.ResourceVenus:
if a.game == nil {
return fmt.Errorf("cannot apply venus: no game context")
}
actualSteps, err := a.game.GlobalParameters().IncreaseVenus(ctx, amount, a.player.ID())
if err != nil {
return fmt.Errorf("failed to increase venus: %w", err)
}
if actualSteps > 0 && a.player != nil {
a.player.Resources().UpdateTerraformRating(actualSteps)
}
log.Debug("Increased venus", zap.Int("steps", actualSteps), zap.Int("tr_gained", actualSteps))
default:
log.Warn("Unhandled global parameter type", zap.String("type", string(o.ResourceType)))
}
return nil
}
package cards
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game/shared"
)
func (a *BehaviorApplier) applyTilePlacementOutput(ctx context.Context, o *shared.TilePlacementCondition, amount int, log *zap.Logger) error {
if a.game == nil {
return fmt.Errorf("cannot apply tile placement: no game context")
}
if a.player == nil {
return fmt.Errorf("cannot apply tile placement: no player context")
}
rt := o.ResourceType
// Land claim is a special tile type
if rt == shared.ResourceLandClaim {
tileTypes := make([]string, amount)
for i := range tileTypes {
tileTypes[i] = "land-claim"
}
if err := a.game.AppendToPendingTileSelectionQueue(ctx, a.player.ID(), tileTypes, a.source, a.sourceCardID, nil); err != nil {
return fmt.Errorf("failed to append land claim to pending tile selection queue: %w", err)
}
log.Debug("Added land claim tile selection to queue", zap.Int("count", amount))
return nil
}
// Generic tile-placement uses the TileType field
if rt == shared.ResourceTilePlacement {
if o.TileType == "" {
return fmt.Errorf("tile-placement output missing tileType field")
}
tileTypes := make([]string, amount)
for i := range tileTypes {
tileTypes[i] = o.TileType
}
restrictions := copyTileRestrictions(o.TileRestrictions)
if err := a.game.AppendToPendingTileSelectionQueue(ctx, a.player.ID(), tileTypes, a.source, a.sourceCardID, restrictions); err != nil {
return fmt.Errorf("failed to append to pending tile selection queue: %w", err)
}
log.Debug("Added special tile placements to queue",
zap.String("tile_type", o.TileType), zap.Int("count", amount), zap.Any("tile_restrictions", restrictions))
return nil
}
// Standard placements: city, greenery, ocean, volcano
var tileType string
switch rt {
case shared.ResourceCityPlacement:
tileType = "city"
case shared.ResourceGreeneryPlacement:
tileType = "greenery"
case shared.ResourceOceanPlacement:
tileType = "ocean"
case shared.ResourceVolcanoPlacement:
tileType = "volcano"
default:
return fmt.Errorf("unknown tile placement type: %s", rt)
}
tileTypes := make([]string, amount)
for i := range tileTypes {
tileTypes[i] = tileType
}
restrictions := copyTileRestrictions(o.TileRestrictions)
// For greenery, enforce adjacency to owned tiles unless card overrides
if rt == shared.ResourceGreeneryPlacement {
if restrictions == nil {
restrictions = &shared.TileRestrictions{AdjacentToOwned: true}
} else if restrictions.OnTileType == "" {
restrictions.AdjacentToOwned = true
}
}
if err := a.game.AppendToPendingTileSelectionQueue(ctx, a.player.ID(), tileTypes, a.source, a.sourceCardID, restrictions); err != nil {
return fmt.Errorf("failed to append to pending tile selection queue: %w", err)
}
log.Debug("Added tile placements to queue",
zap.String("tile_type", tileType), zap.Int("count", amount), zap.Any("tile_restrictions", restrictions))
return nil
}
// copyTileRestrictions creates a deep copy of TileRestrictions, or returns nil.
func copyTileRestrictions(tr *shared.TileRestrictions) *shared.TileRestrictions {
if tr == nil {
return nil
}
result := *tr
if tr.BoardTags != nil {
bt := make([]string, len(tr.BoardTags))
copy(bt, tr.BoardTags)
result.BoardTags = bt
}
if tr.OnBonusType != nil {
ob := make([]string, len(tr.OnBonusType))
copy(ob, tr.OnBonusType)
result.OnBonusType = ob
}
if tr.MinAdjacentOfType != nil {
v := *tr.MinAdjacentOfType
result.MinAdjacentOfType = &v
}
return &result
}
func (a *BehaviorApplier) applyTileModificationOutput(ctx context.Context, o *shared.TileModificationCondition, amount int, log *zap.Logger) error {
if a.game == nil {
return fmt.Errorf("cannot apply tile modification: no game context")
}
if a.player == nil {
return fmt.Errorf("cannot apply tile modification: no player context")
}
switch o.ResourceType {
case shared.ResourceTileDestruction:
tileTypes := make([]string, amount)
for i := range tileTypes {
tileTypes[i] = "tile-destruction"
}
if err := a.game.AppendToPendingTileSelectionQueue(ctx, a.player.ID(), tileTypes, a.source, a.sourceCardID, nil); err != nil {
return fmt.Errorf("failed to append tile destruction to queue: %w", err)
}
log.Debug("Added tile destruction selection to queue")
case shared.ResourceTileReplacement:
tileTypes := make([]string, amount)
for i := range tileTypes {
tileTypes[i] = "tile-replacement:" + o.TileType
}
if err := a.game.AppendToPendingTileSelectionQueue(ctx, a.player.ID(), tileTypes, a.source, a.sourceCardID, nil); err != nil {
return fmt.Errorf("failed to append tile replacement to queue: %w", err)
}
log.Debug("Added tile replacement selection to queue", zap.String("replacement_tile", o.TileType))
}
return nil
}
package cards
import (
"terraforming-mars-backend/internal/game/shared"
)
// HasAutoTrigger checks if a behavior has an auto trigger without conditions
func HasAutoTrigger(behavior shared.CardBehavior) bool {
for _, trigger := range behavior.Triggers {
if trigger.Type == string(ResourceTriggerAuto) && trigger.Condition == nil {
return true
}
}
return false
}
// HasManualTrigger checks if a behavior has a manual trigger
func HasManualTrigger(behavior shared.CardBehavior) bool {
for _, trigger := range behavior.Triggers {
if trigger.Type == string(ResourceTriggerManual) {
return true
}
}
return false
}
// HasConditionalTrigger checks if a behavior has an auto trigger with a condition (passive effect)
func HasConditionalTrigger(behavior shared.CardBehavior) bool {
for _, trigger := range behavior.Triggers {
if (trigger.Type == string(ResourceTriggerAuto) || trigger.Type == string(ResourceTriggerAutoCorporationStart)) && trigger.Condition != nil {
return true
}
}
return false
}
// HasCorporationStartTrigger checks if a behavior has a corporation start trigger
func HasCorporationStartTrigger(behavior shared.CardBehavior) bool {
for _, trigger := range behavior.Triggers {
if trigger.Type == string(ResourceTriggerAutoCorporationStart) {
return true
}
}
return false
}
// HasCorporationFirstActionTrigger checks if a behavior has a corporation first action trigger
func HasCorporationFirstActionTrigger(behavior shared.CardBehavior) bool {
for _, trigger := range behavior.Triggers {
if trigger.Type == string(ResourceTriggerAutoCorporationFirstAction) {
return true
}
}
return false
}
// GetImmediateBehaviors returns all behaviors with auto triggers (no conditions)
func GetImmediateBehaviors(card *Card) []shared.CardBehavior {
var immediate []shared.CardBehavior
for _, behavior := range card.Behaviors {
if HasAutoTrigger(behavior) {
immediate = append(immediate, behavior)
}
}
return immediate
}
// GetManualBehaviors returns all behaviors with manual triggers
func GetManualBehaviors(card *Card) []shared.CardBehavior {
var manual []shared.CardBehavior
for _, behavior := range card.Behaviors {
if HasManualTrigger(behavior) {
manual = append(manual, behavior)
}
}
return manual
}
// GetPassiveBehaviors returns all behaviors with conditional triggers
func GetPassiveBehaviors(card *Card) []shared.CardBehavior {
var passive []shared.CardBehavior
for _, behavior := range card.Behaviors {
if HasConditionalTrigger(behavior) {
passive = append(passive, behavior)
}
}
return passive
}
// HasPersistentEffects checks if a behavior has persistent outputs that should be
// registered as effects (e.g., discount, payment-substitute, global-parameter-lenience)
// These are different from immediate resource gains - they modify future actions
func HasPersistentEffects(behavior shared.CardBehavior) bool {
for _, output := range behavior.Outputs {
switch output.GetResourceType() {
case shared.ResourceDiscount, shared.ResourcePaymentSubstitute, shared.ResourceGlobalParameterLenience, shared.ResourceIgnoreGlobalRequirements, shared.ResourceStoragePaymentSubstitute:
return true
}
}
return false
}
// HasTemporaryOutputs checks if a behavior has any outputs marked as temporary
func HasTemporaryOutputs(behavior shared.CardBehavior) bool {
for _, output := range behavior.Outputs {
if shared.GetTemporary(output) != "" {
return true
}
}
return false
}
// HasChoices checks if a behavior has player choices
func HasChoices(behavior shared.CardBehavior) bool {
return len(behavior.Choices) > 0
}
// HasCardDiscardInput checks if a behavior has card-discard type inputs
// These require a pending selection before outputs can be applied
func HasCardDiscardInput(behavior shared.CardBehavior) bool {
for _, input := range behavior.Inputs {
if input.GetResourceType() == shared.ResourceCardDiscard {
return true
}
}
return false
}
// HasCardDiscardOutput checks if a behavior has card-discard type outputs
// These require a pending selection before remaining outputs can be applied
func HasCardDiscardOutput(behavior shared.CardBehavior) bool {
for _, output := range behavior.Outputs {
if output.GetResourceType() == shared.ResourceCardDiscard {
return true
}
}
return false
}
package cards
import (
"terraforming-mars-backend/internal/game/shared"
)
// CardType represents different types of cards in Terraforming Mars
type CardType string
const (
CardTypeAutomated CardType = "automated" // Green cards - immediate effects, production bonuses
CardTypeActive CardType = "active" // Blue cards - ongoing effects, repeatable actions
CardTypeEvent CardType = "event" // Red cards - one-time effects
CardTypeCorporation CardType = "corporation" // Corporation cards - unique player abilities
CardTypePrelude CardType = "prelude" // Prelude cards - setup phase cards
)
// Card represents a game card
type Card struct {
ID string `json:"id"`
Name string `json:"name"`
Type CardType `json:"type"`
Cost int `json:"cost"`
Description string `json:"description"`
Pack string `json:"pack"`
Tags []shared.CardTag `json:"tags"`
Requirements *CardRequirements `json:"requirements,omitempty"`
Behaviors []shared.CardBehavior `json:"behaviors"`
ResourceStorage *ResourceStorage `json:"resourceStorage"`
VPConditions []VictoryPointCondition `json:"vpConditions"`
StartingResources *shared.ResourceSet `json:"startingResources"`
StartingProduction *shared.ResourceSet `json:"startingProduction"`
}
// DeepCopy creates a deep copy of the Card
func (c Card) DeepCopy() Card {
tags := make([]shared.CardTag, len(c.Tags))
copy(tags, c.Tags)
var requirements *CardRequirements
if c.Requirements != nil {
items := make([]Requirement, len(c.Requirements.Items))
copy(items, c.Requirements.Items)
requirements = &CardRequirements{
Description: c.Requirements.Description,
Items: items,
}
}
behaviors := make([]shared.CardBehavior, len(c.Behaviors))
for i, behavior := range c.Behaviors {
behaviors[i] = behavior.DeepCopy()
}
vpConditions := make([]VictoryPointCondition, len(c.VPConditions))
for i, vpc := range c.VPConditions {
vpConditions[i] = vpc
if vpc.MaxTrigger != nil {
mt := *vpc.MaxTrigger
vpConditions[i].MaxTrigger = &mt
}
if vpc.Per != nil {
perCopy := *vpc.Per
if perCopy.Location != nil {
loc := *perCopy.Location
perCopy.Location = &loc
}
if perCopy.Target != nil {
tgt := *perCopy.Target
perCopy.Target = &tgt
}
if perCopy.Tag != nil {
tag := *perCopy.Tag
perCopy.Tag = &tag
}
if perCopy.AdjacentToTileType != nil {
att := *perCopy.AdjacentToTileType
perCopy.AdjacentToTileType = &att
}
vpConditions[i].Per = &perCopy
}
}
var resourceStorage *ResourceStorage
if c.ResourceStorage != nil {
rs := *c.ResourceStorage
resourceStorage = &rs
}
var startingResources *shared.ResourceSet
if c.StartingResources != nil {
rs := *c.StartingResources
startingResources = &rs
}
var startingProduction *shared.ResourceSet
if c.StartingProduction != nil {
sp := *c.StartingProduction
startingProduction = &sp
}
return Card{
ID: c.ID,
Name: c.Name,
Type: c.Type,
Cost: c.Cost,
Description: c.Description,
Pack: c.Pack,
Tags: tags,
Requirements: requirements,
Behaviors: behaviors,
ResourceStorage: resourceStorage,
VPConditions: vpConditions,
StartingResources: startingResources,
StartingProduction: startingProduction,
}
}
package cards
import (
"fmt"
"terraforming-mars-backend/internal/game/shared"
)
// ValidateCardJSON validates the JSON structure of a card at load time
// This ensures all enum values are valid and structure is correct
func ValidateCardJSON(card *Card) []error {
var errors []error
if !isValidCardType(card.Type) {
errors = append(errors, fmt.Errorf("card %s: invalid card type: %s", card.ID, card.Type))
}
for _, tag := range card.Tags {
if !isValidCardTag(tag) {
errors = append(errors, fmt.Errorf("card %s: invalid tag: %s", card.ID, tag))
}
}
if card.Requirements != nil {
for i, req := range card.Requirements.Items {
if reqErr := validateRequirement(card.ID, i, req); reqErr != nil {
errors = append(errors, reqErr)
}
}
}
for i, behavior := range card.Behaviors {
behaviorErrors := validateBehavior(card.ID, i, behavior)
errors = append(errors, behaviorErrors...)
}
if card.ResourceStorage != nil {
if !isValidResourceType(card.ResourceStorage.Type) {
errors = append(errors, fmt.Errorf("card %s: invalid resource storage type: %s", card.ID, card.ResourceStorage.Type))
}
}
for i, vp := range card.VPConditions {
if vpErr := validateVictoryPointCondition(card.ID, i, vp); vpErr != nil {
errors = append(errors, vpErr)
}
}
if card.StartingResources != nil {
if rsErr := validateResourceSet(card.ID, "starting resources", *card.StartingResources); rsErr != nil {
errors = append(errors, rsErr)
}
}
if card.StartingProduction != nil {
if prodErr := validateResourceSet(card.ID, "starting production", *card.StartingProduction); prodErr != nil {
errors = append(errors, prodErr)
}
}
return errors
}
func validateRequirement(cardID string, index int, req Requirement) error {
if !isValidRequirementType(req.Type) {
return fmt.Errorf("card %s: requirement[%d] has invalid type: %s", cardID, index, req.Type)
}
if req.Type == RequirementTags && req.Tag != nil {
if !isValidCardTag(*req.Tag) {
return fmt.Errorf("card %s: requirement[%d] has invalid tag: %s", cardID, index, *req.Tag)
}
}
return nil
}
func validateBehavior(cardID string, index int, behavior shared.CardBehavior) []error {
var errors []error
for i, trigger := range behavior.Triggers {
if trigger.Type == "" {
errors = append(errors, fmt.Errorf("card %s: behavior[%d].trigger[%d] has empty type", cardID, index, i))
}
if trigger.Condition != nil {
for _, rt := range trigger.Condition.ResourceTypes {
if !isValidResourceType(rt) {
errors = append(errors, fmt.Errorf("card %s: behavior[%d].trigger[%d] has invalid resource type: %s", cardID, index, i, rt))
}
}
}
}
for i, input := range behavior.Inputs {
if inputErr := validateBehaviorCondition(cardID, index, "input", i, input); inputErr != nil {
errors = append(errors, inputErr)
}
}
for i, output := range behavior.Outputs {
if outputErr := validateBehaviorCondition(cardID, index, "output", i, output); outputErr != nil {
errors = append(errors, outputErr)
}
}
for i, choice := range behavior.Choices {
for k, input := range choice.Inputs {
if inputErr := validateBehaviorCondition(cardID, index, fmt.Sprintf("choice[%d].input", i), k, input); inputErr != nil {
errors = append(errors, inputErr)
}
}
for k, output := range choice.Outputs {
if outputErr := validateBehaviorCondition(cardID, index, fmt.Sprintf("choice[%d].output", i), k, output); outputErr != nil {
errors = append(errors, outputErr)
}
}
}
return errors
}
func validateBehaviorCondition(cardID string, behaviorIndex int, condType string, index int, cond shared.BehaviorCondition) error {
if cond.GetTarget() == "" {
return fmt.Errorf("card %s: behavior[%d].%s[%d] has empty target", cardID, behaviorIndex, condType, index)
}
if !isValidResourceType(cond.GetResourceType()) {
return fmt.Errorf("card %s: behavior[%d].%s[%d] has invalid resource type: %s", cardID, behaviorIndex, condType, index, cond.GetResourceType())
}
if per := shared.GetPerCondition(cond); per != nil {
if !isValidResourceType(per.ResourceType) {
return fmt.Errorf("card %s: behavior[%d].%s[%d].per has invalid resource type: %s", cardID, behaviorIndex, condType, index, per.ResourceType)
}
}
return nil
}
func validateVictoryPointCondition(cardID string, index int, vp VictoryPointCondition) error {
if vp.Per != nil {
if !isValidResourceType(vp.Per.ResourceType) {
return fmt.Errorf("card %s: victory_point_condition[%d].per has invalid resource type: %s", cardID, index, vp.Per.ResourceType)
}
}
return nil
}
func validateResourceSet(cardID, fieldName string, rs shared.ResourceSet) error {
return nil
}
func isValidCardType(ct CardType) bool {
switch ct {
case CardTypeCorporation, CardTypeAutomated, CardTypeActive, CardTypeEvent, CardTypePrelude:
return true
default:
return false
}
}
func isValidCardTag(tag shared.CardTag) bool {
switch tag {
case shared.TagBuilding, shared.TagSpace, shared.TagScience,
shared.TagPower, shared.TagEarth, shared.TagJovian,
shared.TagVenus, shared.TagPlant, shared.TagMicrobe,
shared.TagAnimal, shared.TagCity, shared.TagEvent,
shared.TagWildlife, shared.TagWild:
return true
default:
return false
}
}
func isValidRequirementType(rt RequirementType) bool {
switch rt {
case RequirementTemperature, RequirementOxygen, RequirementOceans,
RequirementTags, RequirementProduction, RequirementTR,
RequirementResource, RequirementVenus, RequirementCities,
RequirementGreeneries:
return true
default:
return false
}
}
func isValidResourceType(rt shared.ResourceType) bool {
return rt != ""
}
package cards
import (
"fmt"
"terraforming-mars-backend/internal/game/global_parameters"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
)
// ValidateCardCanBePlayed checks if a card can be played in the current game context
// Returns nil if valid, or an error describing why the card cannot be played
func ValidateCardCanBePlayed(
card *Card,
pl *player.Player,
globalParams *global_parameters.GlobalParameters,
playedCards []*Card, // All cards played by the player (for tag counting)
) error {
if card.Requirements == nil {
return nil
}
for _, req := range card.Requirements.Items {
if err := validateRequirementMet(req, pl, globalParams, playedCards); err != nil {
return fmt.Errorf("requirement not met: %w", err)
}
}
return nil
}
// CanAffordCard checks if a player can afford to pay for a card
func CanAffordCard(card *Card, pl *player.Player, discounts map[shared.CardTag]int) bool {
totalCost := card.Cost
// Apply tag-based discounts
for _, tag := range card.Tags {
if discount, ok := discounts[tag]; ok {
totalCost -= discount
}
}
// Cost cannot go below 0
if totalCost < 0 {
totalCost = 0
}
resources := pl.Resources().Get()
return resources.Credits >= totalCost
}
// validateRequirementMet checks if a single requirement is met
func validateRequirementMet(
req Requirement,
pl *player.Player,
globalParams *global_parameters.GlobalParameters,
playedCards []*Card,
) error {
switch req.Type {
case RequirementTemperature:
return validateTemperatureRequirement(req, globalParams)
case RequirementOxygen:
return validateOxygenRequirement(req, globalParams)
case RequirementOceans:
return validateOceansRequirement(req, globalParams)
case RequirementTags:
return validateTagsRequirement(req, playedCards)
case RequirementProduction:
return validateProductionRequirement(req, pl)
case RequirementTR:
return validateTRRequirement(req, pl)
case RequirementResource:
return validateResourceRequirement(req, pl)
case RequirementCities:
return validateCitiesRequirement(req)
case RequirementGreeneries:
return validateGreeeneriesRequirement(req)
default:
return fmt.Errorf("unknown requirement type: %s", req.Type)
}
}
// validateTemperatureRequirement checks if temperature requirement is met
func validateTemperatureRequirement(req Requirement, globalParams *global_parameters.GlobalParameters) error {
temp := globalParams.Temperature()
if req.Min != nil && temp < *req.Min {
return fmt.Errorf("temperature %d°C is below required minimum %d°C", temp, *req.Min)
}
if req.Max != nil && temp > *req.Max {
return fmt.Errorf("temperature %d°C is above required maximum %d°C", temp, *req.Max)
}
return nil
}
// validateOxygenRequirement checks if oxygen requirement is met
func validateOxygenRequirement(req Requirement, globalParams *global_parameters.GlobalParameters) error {
oxygen := globalParams.Oxygen()
if req.Min != nil && oxygen < *req.Min {
return fmt.Errorf("oxygen %d%% is below required minimum %d%%", oxygen, *req.Min)
}
if req.Max != nil && oxygen > *req.Max {
return fmt.Errorf("oxygen %d%% is above required maximum %d%%", oxygen, *req.Max)
}
return nil
}
// validateOceansRequirement checks if oceans requirement is met
func validateOceansRequirement(req Requirement, globalParams *global_parameters.GlobalParameters) error {
oceans := globalParams.Oceans()
if req.Min != nil && oceans < *req.Min {
return fmt.Errorf("ocean count %d is below required minimum %d", oceans, *req.Min)
}
if req.Max != nil && oceans > *req.Max {
return fmt.Errorf("ocean count %d is above required maximum %d", oceans, *req.Max)
}
return nil
}
// validateTagsRequirement checks if tag count requirement is met
func validateTagsRequirement(req Requirement, playedCards []*Card) error {
if req.Tag == nil {
return fmt.Errorf("tags requirement missing tag specification")
}
tagCount := countTagsInPlayedCards(*req.Tag, playedCards)
if req.Min != nil && tagCount < *req.Min {
return fmt.Errorf("tag %s count %d is below required minimum %d", *req.Tag, tagCount, *req.Min)
}
if req.Max != nil && tagCount > *req.Max {
return fmt.Errorf("tag %s count %d is above required maximum %d", *req.Tag, tagCount, *req.Max)
}
return nil
}
// countTagsInPlayedCards counts occurrences of a specific tag in played cards (excluding events).
// Wild tags count toward any tag type.
func countTagsInPlayedCards(tag shared.CardTag, playedCards []*Card) int {
count := 0
for _, card := range playedCards {
if card.Type == CardTypeEvent {
continue
}
count += countTagsInList(card.Tags, tag)
}
return count
}
// validateProductionRequirement checks if production requirement is met
func validateProductionRequirement(req Requirement, pl *player.Player) error {
if req.Resource == nil {
return fmt.Errorf("production requirement missing resource type specification")
}
production := pl.Resources().Production()
var productionAmount int
switch *req.Resource {
case shared.ResourceCredit:
productionAmount = production.Credits
case shared.ResourceSteel:
productionAmount = production.Steel
case shared.ResourceTitanium:
productionAmount = production.Titanium
case shared.ResourcePlant:
productionAmount = production.Plants
case shared.ResourceEnergy:
productionAmount = production.Energy
case shared.ResourceHeat:
productionAmount = production.Heat
default:
return fmt.Errorf("invalid resource type for production requirement: %s", *req.Resource)
}
if req.Min != nil && productionAmount < *req.Min {
return fmt.Errorf("%s production %d is below required minimum %d", *req.Resource, productionAmount, *req.Min)
}
if req.Max != nil && productionAmount > *req.Max {
return fmt.Errorf("%s production %d is above required maximum %d", *req.Resource, productionAmount, *req.Max)
}
return nil
}
// validateTRRequirement checks if terraform rating requirement is met
func validateTRRequirement(req Requirement, pl *player.Player) error {
tr := pl.Resources().TerraformRating()
if req.Min != nil && tr < *req.Min {
return fmt.Errorf("terraform rating %d is below required minimum %d", tr, *req.Min)
}
if req.Max != nil && tr > *req.Max {
return fmt.Errorf("terraform rating %d is above required maximum %d", tr, *req.Max)
}
return nil
}
// validateResourceRequirement checks if resource amount requirement is met
func validateResourceRequirement(req Requirement, pl *player.Player) error {
if req.Resource == nil {
return fmt.Errorf("resource requirement missing resource type specification")
}
resources := pl.Resources().Get()
var resourceAmount int
switch *req.Resource {
case shared.ResourceCredit:
resourceAmount = resources.Credits
case shared.ResourceSteel:
resourceAmount = resources.Steel
case shared.ResourceTitanium:
resourceAmount = resources.Titanium
case shared.ResourcePlant:
resourceAmount = resources.Plants
case shared.ResourceEnergy:
resourceAmount = resources.Energy
case shared.ResourceHeat:
resourceAmount = resources.Heat
default:
storage := pl.Resources().Storage()
if amount, ok := storage[string(*req.Resource)]; ok {
resourceAmount = amount
} else {
resourceAmount = 0
}
}
if req.Min != nil && resourceAmount < *req.Min {
return fmt.Errorf("%s amount %d is below required minimum %d", *req.Resource, resourceAmount, *req.Min)
}
if req.Max != nil && resourceAmount > *req.Max {
return fmt.Errorf("%s amount %d is above required maximum %d", *req.Resource, resourceAmount, *req.Max)
}
return nil
}
// validateCitiesRequirement checks if cities requirement is met
// TODO: Implement when city tracking is available
func validateCitiesRequirement(req Requirement) error {
// Placeholder - requires board state to count cities
return nil
}
// validateGreeeneriesRequirement checks if greeneries requirement is met
// TODO: Implement when greenery tracking is available
func validateGreeeneriesRequirement(req Requirement) error {
// Placeholder - requires board state to count greeneries
return nil
}
package cards
import (
"context"
"fmt"
"go.uber.org/zap"
"terraforming-mars-backend/internal/awards"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
)
// CorporationProcessor handles applying corporation card effects
type CorporationProcessor struct {
cardRegistry CardRegistryInterface
awardRegistry awards.AwardRegistry
logger *zap.Logger
}
// NewCorporationProcessor creates a new corporation processor
func NewCorporationProcessor(cardRegistry CardRegistryInterface, awardRegistry awards.AwardRegistry, logger *zap.Logger) *CorporationProcessor {
return &CorporationProcessor{
cardRegistry: cardRegistry,
awardRegistry: awardRegistry,
logger: logger,
}
}
// ApplyStartingEffects processes ONLY auto-corporation-start behaviors
// and applies starting resources/production
func (p *CorporationProcessor) ApplyStartingEffects(
ctx context.Context,
card *Card,
pl *player.Player,
g *game.Game,
) error {
log := p.logger.With(
zap.String("corporation_id", card.ID),
zap.String("corporation_name", card.Name),
zap.String("player_id", pl.ID()),
)
log.Debug("Applying corporation starting effects")
applier := NewBehaviorApplier(pl, g, card.Name, p.logger).
WithSourceCardID(card.ID).
WithCardRegistry(p.cardRegistry)
// Process ONLY behaviors with auto-corporation-start trigger
for _, behavior := range card.Behaviors {
for _, trigger := range behavior.Triggers {
if trigger.Type == string(ResourceTriggerAutoCorporationStart) {
log.Debug("Found auto-corporation-start behavior",
zap.Int("outputs", len(behavior.Outputs)))
if err := applier.ApplyOutputs(ctx, behavior.Outputs); err != nil {
return fmt.Errorf("failed to apply starting effects: %w", err)
}
}
}
}
log.Debug("Corporation starting effects applied")
return nil
}
// ApplyAutoEffects processes auto triggers WITHOUT conditions
// (e.g., payment-substitute for Helion)
func (p *CorporationProcessor) ApplyAutoEffects(
ctx context.Context,
card *Card,
pl *player.Player,
g *game.Game,
) error {
log := p.logger.With(
zap.String("corporation_id", card.ID),
zap.String("corporation_name", card.Name),
zap.String("player_id", pl.ID()),
)
log.Debug("Applying corporation auto effects")
applier := NewBehaviorApplier(pl, g, card.Name, p.logger).
WithSourceCardID(card.ID).
WithCardRegistry(p.cardRegistry)
// Process behaviors with auto trigger WITHOUT conditions
for _, behavior := range card.Behaviors {
for _, trigger := range behavior.Triggers {
// Handle auto trigger WITHOUT conditions (immediate effects like payment-substitute)
// Auto triggers WITH conditions are passive effects handled separately
if trigger.Type == string(ResourceTriggerAuto) && trigger.Condition == nil {
log.Debug("Found auto behavior (no condition)",
zap.Int("outputs", len(behavior.Outputs)))
if err := applier.ApplyOutputs(ctx, behavior.Outputs); err != nil {
return fmt.Errorf("failed to apply auto effects: %w", err)
}
}
}
}
log.Debug("Corporation auto effects applied")
return nil
}
// SetupForcedFirstAction processes auto-corporation-first-action behaviors and sets forced actions
func (p *CorporationProcessor) SetupForcedFirstAction(
ctx context.Context,
card *Card,
g *game.Game,
playerID string,
) error {
log := p.logger.With(
zap.String("corporation_id", card.ID),
zap.String("corporation_name", card.Name),
zap.String("player_id", playerID),
)
log.Debug("Checking for forced first action")
// Process behaviors with auto-corporation-first-action trigger
for _, behavior := range card.Behaviors {
for _, trigger := range behavior.Triggers {
if trigger.Type == string(ResourceTriggerAutoCorporationFirstAction) {
log.Debug("Found auto-corporation-first-action behavior",
zap.Int("outputs", len(behavior.Outputs)))
// Check if this behavior has card-peek/card-take outputs (e.g. Valley Trust)
if p.hasCardDrawOutputs(behavior) {
if err := p.applyCardDrawForcedAction(ctx, behavior, card, g, playerID, log); err != nil {
return fmt.Errorf("failed to apply card draw forced action: %w", err)
}
continue
}
// Create forced action based on individual outputs
for _, output := range behavior.Outputs {
if err := p.createForcedAction(ctx, output, card, g, playerID, log); err != nil {
return fmt.Errorf("failed to create forced action: %w", err)
}
}
}
}
}
return nil
}
// GetAutoEffects returns all auto effects (without conditions) from a corporation card
// These are behaviors with auto triggers without conditions (e.g., payment-substitute for Helion)
// They are applied immediately AND registered in effects list for display purposes
// This is a READ-ONLY helper that parses the card behaviors and returns CardEffect structs
// The action layer is responsible for adding these effects to the player
func (p *CorporationProcessor) GetAutoEffects(card *Card) []shared.CardEffect {
var effects []shared.CardEffect
// Iterate through all behaviors and find auto triggers without conditions
for behaviorIndex, behavior := range card.Behaviors {
for _, trigger := range behavior.Triggers {
// Auto triggers WITHOUT conditions are immediate/permanent effects
if trigger.Type == string(ResourceTriggerAuto) && trigger.Condition == nil {
effect := shared.CardEffect{
CardID: card.ID,
CardName: card.Name,
BehaviorIndex: behaviorIndex,
Behavior: behavior,
}
effects = append(effects, effect)
}
}
}
return effects
}
// GetTriggerEffects returns all trigger effects (conditional triggers) from a corporation card
// These are behaviors with auto triggers that have conditions, for event subscription
// This is a READ-ONLY helper that parses the card behaviors and returns CardEffect structs
// The action layer is responsible for adding these effects to the player
func (p *CorporationProcessor) GetTriggerEffects(card *Card) []shared.CardEffect {
var effects []shared.CardEffect
// Iterate through all behaviors and find conditional triggers
for behaviorIndex, behavior := range card.Behaviors {
if HasConditionalTrigger(behavior) {
effect := shared.CardEffect{
CardID: card.ID,
CardName: card.Name,
BehaviorIndex: behaviorIndex,
Behavior: behavior,
}
effects = append(effects, effect)
}
}
return effects
}
// GetManualActions returns all manual actions (manual triggers) from a corporation card
// This is a READ-ONLY helper that parses the card behaviors and returns CardAction structs
// The action layer is responsible for adding these actions to the player
func (p *CorporationProcessor) GetManualActions(card *Card) []shared.CardAction {
var actions []shared.CardAction
// Iterate through all behaviors and find manual triggers
for behaviorIndex, behavior := range card.Behaviors {
if HasManualTrigger(behavior) {
action := shared.CardAction{
CardID: card.ID,
CardName: card.Name,
BehaviorIndex: behaviorIndex,
Behavior: behavior,
TimesUsedThisTurn: 0,
TimesUsedThisGeneration: 0,
}
actions = append(actions, action)
}
}
return actions
}
// hasCardDrawOutputs returns true if the behavior has card-peek or card-take outputs
func (p *CorporationProcessor) hasCardDrawOutputs(behavior shared.CardBehavior) bool {
for _, output := range behavior.Outputs {
switch output.GetResourceType() {
case shared.ResourceCardPeek, shared.ResourceCardTake, shared.ResourceCardBuy:
return true
}
}
return false
}
// applyCardDrawForcedAction handles first-action behaviors with card-peek/card-take outputs.
// These need to be processed as a batch via ApplyCardDrawOutputs.
func (p *CorporationProcessor) applyCardDrawForcedAction(
ctx context.Context,
behavior shared.CardBehavior,
card *Card,
g *game.Game,
playerID string,
log *zap.Logger,
) error {
pl, err := g.GetPlayer(playerID)
if err != nil {
return fmt.Errorf("failed to get player for card draw: %w", err)
}
applier := NewBehaviorApplier(pl, g, card.Name, log).
WithSourceCardID(card.ID).
WithCardRegistry(p.cardRegistry)
_, err = applier.ApplyCardDrawOutputs(ctx, behavior.Outputs)
if err != nil {
return fmt.Errorf("failed to apply card draw outputs: %w", err)
}
action := &shared.ForcedFirstAction{
ActionType: "card-draw-selection",
CorporationID: card.ID,
Source: "corporation-starting-action",
Completed: false,
Description: fmt.Sprintf("Draw and select cards (%s starting action)", card.Name),
}
if err := g.SetForcedFirstAction(ctx, playerID, action); err != nil {
return fmt.Errorf("failed to set forced card draw action: %w", err)
}
log.Debug("Set forced card draw selection action",
zap.String("description", action.Description))
return nil
}
// createForcedAction creates a forced first action based on the output.
// During starting_selection, only stores the ForcedFirstAction metadata without creating tile queues.
// Tile queues are created when transitioning to action phase to avoid conflicts with prelude tile placements.
func (p *CorporationProcessor) createForcedAction(
ctx context.Context,
outputBC shared.BehaviorCondition,
card *Card,
g *game.Game,
playerID string,
log *zap.Logger,
) error {
inStartingSelection := g.CurrentPhase() == shared.GamePhaseStartingSelection
switch outputBC.GetResourceType() {
case shared.ResourceCityPlacement:
action := &shared.ForcedFirstAction{
ActionType: "city-placement",
CorporationID: card.ID,
Source: "corporation-starting-action",
Completed: false,
Description: fmt.Sprintf("Place a city tile (%s starting action)", card.Name),
}
if err := g.SetForcedFirstAction(ctx, playerID, action); err != nil {
return fmt.Errorf("failed to set forced city placement action: %w", err)
}
log.Debug("Set forced city placement action",
zap.String("description", action.Description))
if !inStartingSelection {
queue := &shared.PendingTileSelectionQueue{
Items: []string{"city"},
Source: "corporation-starting-action",
}
if err := g.SetPendingTileSelectionQueue(ctx, playerID, queue); err != nil {
return fmt.Errorf("failed to queue tile placement: %w", err)
}
log.Debug("Queued city tile for placement")
} else {
log.Debug("Deferred city tile queue to action phase")
}
p.subscribeForcedActionCompletion(ctx, g, playerID, "corporation-starting-action", log)
case shared.ResourceGreeneryPlacement:
action := &shared.ForcedFirstAction{
ActionType: "greenery-placement",
CorporationID: card.ID,
Source: "corporation-starting-action",
Completed: false,
Description: fmt.Sprintf("Place a greenery tile (%s starting action)", card.Name),
}
if err := g.SetForcedFirstAction(ctx, playerID, action); err != nil {
return fmt.Errorf("failed to set forced greenery placement action: %w", err)
}
log.Debug("Set forced greenery placement action",
zap.String("description", action.Description))
if !inStartingSelection {
queue := &shared.PendingTileSelectionQueue{
Items: []string{"greenery"},
Source: "corporation-starting-action",
}
if err := g.SetPendingTileSelectionQueue(ctx, playerID, queue); err != nil {
return fmt.Errorf("failed to queue tile placement: %w", err)
}
log.Debug("Queued greenery tile for placement")
} else {
log.Debug("Deferred greenery tile queue to action phase")
}
p.subscribeForcedActionCompletion(ctx, g, playerID, "corporation-starting-action", log)
case shared.ResourceOceanPlacement:
action := &shared.ForcedFirstAction{
ActionType: "ocean-placement",
CorporationID: card.ID,
Source: "corporation-starting-action",
Completed: false,
Description: fmt.Sprintf("Place an ocean tile (%s starting action)", card.Name),
}
if err := g.SetForcedFirstAction(ctx, playerID, action); err != nil {
return fmt.Errorf("failed to set forced ocean placement action: %w", err)
}
log.Debug("Set forced ocean placement action",
zap.String("description", action.Description))
if !inStartingSelection {
queue := &shared.PendingTileSelectionQueue{
Items: []string{"ocean"},
Source: "corporation-starting-action",
}
if err := g.SetPendingTileSelectionQueue(ctx, playerID, queue); err != nil {
return fmt.Errorf("failed to queue tile placement: %w", err)
}
log.Debug("Queued ocean tile for placement")
} else {
log.Debug("Deferred ocean tile queue to action phase")
}
p.subscribeForcedActionCompletion(ctx, g, playerID, "corporation-starting-action", log)
case shared.ResourceAwardFund:
forcedAction := &shared.ForcedFirstAction{
ActionType: "award-fund",
CorporationID: card.ID,
Source: "corporation-starting-action",
Completed: false,
Description: fmt.Sprintf("Fund an award for free (%s starting action)", card.Name),
}
if err := g.SetForcedFirstAction(ctx, playerID, forcedAction); err != nil {
return fmt.Errorf("failed to set forced award fund action: %w", err)
}
log.Debug("Set forced award fund action",
zap.String("description", forcedAction.Description))
var availableAwards []string
for _, def := range p.awardRegistry.GetAll() {
if !g.Awards().IsFunded(shared.AwardType(def.ID)) {
availableAwards = append(availableAwards, def.ID)
}
}
pl, err := g.GetPlayer(playerID)
if err != nil {
return fmt.Errorf("failed to get player for award fund: %w", err)
}
pl.Selection().SetPendingAwardFundSelection(&shared.PendingAwardFundSelection{
AvailableAwards: availableAwards,
Source: "corporation-starting-action",
})
log.Debug("Set pending award fund selection",
zap.Int("available_awards", len(availableAwards)))
case shared.ResourceCardDraw:
pl, err := g.GetPlayer(playerID)
if err != nil {
return fmt.Errorf("failed to get player for card draw: %w", err)
}
applier := NewBehaviorApplier(pl, g, card.Name, log).
WithSourceCardID(card.ID).
WithCardRegistry(p.cardRegistry)
if err := applier.ApplyOutputs(ctx, []shared.BehaviorCondition{outputBC}); err != nil {
return fmt.Errorf("failed to apply card-draw output: %w", err)
}
log.Debug("Applied card-draw forced action",
zap.Int("amount", outputBC.GetAmount()))
case shared.ResourceColony:
action := &shared.ForcedFirstAction{
ActionType: "colony-placement",
CorporationID: card.ID,
Source: "corporation-starting-action",
Completed: false,
Description: fmt.Sprintf("Place a colony (%s starting action)", card.Name),
}
if err := g.SetForcedFirstAction(ctx, playerID, action); err != nil {
return fmt.Errorf("failed to set forced colony placement action: %w", err)
}
log.Debug("Set forced colony placement action",
zap.String("description", action.Description))
allowDuplicate := false
if cc, ok := outputBC.(*shared.ColonyCondition); ok {
allowDuplicate = cc.AllowDuplicatePlayerColony
}
if !inStartingSelection {
pl, err := g.GetPlayer(playerID)
if err != nil {
return fmt.Errorf("failed to get player for colony placement: %w", err)
}
colonyIDs := g.Colonies().GetPlaceableIDs(pl.ID(), allowDuplicate)
if len(colonyIDs) > 0 {
pl.Selection().SetPendingColonySelection(&shared.PendingColonySelection{
AvailableColonyIDs: colonyIDs,
AllowDuplicatePlayerColony: allowDuplicate,
Source: "Build Colony",
SourceCardID: card.ID,
})
log.Debug("Set pending colony selection for forced action",
zap.Int("available_colonies", len(colonyIDs)))
}
} else {
log.Debug("Deferred colony selection to action phase")
}
p.subscribeColonyForcedActionCompletion(ctx, g, playerID, "corporation-starting-action", log)
default:
log.Warn("Unhandled forced action type",
zap.String("type", string(outputBC.GetResourceType())))
}
return nil
}
// subscribeForcedActionCompletion subscribes to TilePlacedEvent to handle forced action completion
// When the last tile in a forced action is placed, this consumes 1 player action and clears the forced action
func (p *CorporationProcessor) subscribeForcedActionCompletion(
ctx context.Context,
g *game.Game,
playerID string,
source string,
log *zap.Logger,
) {
eventBus := g.EventBus()
if eventBus == nil {
log.Warn("No event bus available, cannot subscribe to forced action completion")
return
}
var subID events.SubscriptionID
subID = events.Subscribe(eventBus, func(event events.TilePlacedEvent) {
// Only handle events for this player
if event.PlayerID != playerID {
return
}
log.Debug("Received TilePlacedEvent for forced action check",
zap.String("player_id", event.PlayerID),
zap.String("tile_type", event.TileType))
// Check if there's a forced first action for this player
forcedAction := g.GetForcedFirstAction(playerID)
if forcedAction == nil {
log.Debug("No forced first action, ignoring event")
return
}
// Check if the queue is now empty (last tile was placed)
queue := g.GetPendingTileSelectionQueue(playerID)
if queue != nil && len(queue.Items) > 0 {
log.Debug("Tile queue still has items, waiting for more tiles",
zap.Int("remaining_tiles", len(queue.Items)))
return
}
log.Debug("Forced first action completed (free action)",
zap.String("action_type", forcedAction.ActionType),
zap.String("corporation_id", forcedAction.CorporationID))
if err := g.SetForcedFirstAction(ctx, playerID, nil); err != nil {
log.Error("Failed to clear forced first action", zap.Error(err))
}
eventBus.Unsubscribe(subID)
})
log.Debug("Subscribed to TilePlacedEvent for forced action completion",
zap.String("player_id", playerID),
zap.String("source", source))
}
// subscribeColonyForcedActionCompletion subscribes to ColonyBuiltEvent to handle forced colony placement completion
func (p *CorporationProcessor) subscribeColonyForcedActionCompletion(
ctx context.Context,
g *game.Game,
playerID string,
source string,
log *zap.Logger,
) {
eventBus := g.EventBus()
if eventBus == nil {
log.Warn("No event bus available, cannot subscribe to forced colony action completion")
return
}
var subID events.SubscriptionID
subID = events.Subscribe(eventBus, func(event events.ColonyBuiltEvent) {
if event.PlayerID != playerID {
return
}
forcedAction := g.GetForcedFirstAction(playerID)
if forcedAction == nil || forcedAction.ActionType != "colony-placement" {
return
}
log.Debug("Forced colony placement completed",
zap.String("corporation_id", forcedAction.CorporationID),
zap.String("colony_id", event.ColonyID))
if err := g.SetForcedFirstAction(ctx, playerID, nil); err != nil {
log.Error("Failed to clear forced colony placement action", zap.Error(err))
}
eventBus.Unsubscribe(subID)
})
log.Debug("Subscribed to ColonyBuiltEvent for forced colony placement completion",
zap.String("player_id", playerID),
zap.String("source", source))
}
package cards
import (
"terraforming-mars-backend/internal/game/board"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
)
// CardRegistryInterface defines the interface for looking up cards
type CardRegistryInterface interface {
GetByID(cardID string) (*Card, error)
}
// CountPlayerTiles counts tiles owned by a player on the board.
// If tileType is nil, counts all tiles owned by the player.
// If tileType is specified, counts only tiles of that type.
func CountPlayerTiles(playerID string, b *board.Board, tileType *shared.ResourceType) int {
count := 0
tiles := b.Tiles()
for _, tile := range tiles {
if tile.OwnerID == nil || *tile.OwnerID != playerID {
continue
}
if tile.OccupiedBy == nil {
continue
}
if tileType != nil && tile.OccupiedBy.Type != *tileType {
continue
}
count++
}
return count
}
// CountPlayerTagsByType counts tags of a specific type across all played cards and corporation for a player.
// Wild tags count toward any tag type. Event cards are excluded unless counting TagEvent.
// Optional extraTags allows counting tags from a card not yet in played cards (e.g., the card being played).
func CountPlayerTagsByType(p *player.Player, cardRegistry CardRegistryInterface, tagType shared.CardTag, extraTags ...[]shared.CardTag) int {
count := 0
for _, cardID := range p.PlayedCards().Cards() {
card, err := cardRegistry.GetByID(cardID)
if err != nil {
continue
}
if card.Type == CardTypeEvent && tagType != shared.TagEvent {
continue
}
count += countTagsInList(card.Tags, tagType)
}
if corpID := p.CorporationID(); corpID != "" {
if corp, err := cardRegistry.GetByID(corpID); err == nil {
count += countTagsInList(corp.Tags, tagType)
}
}
for _, tags := range extraTags {
count += countTagsInList(tags, tagType)
}
// Include bonus tags from effects like Home Schooled
count += p.BonusTagCount(tagType)
return count
}
// countTagsInList counts occurrences of a tag in a slice, including wild tags.
func countTagsInList(tags []shared.CardTag, target shared.CardTag) int {
count := 0
for _, tag := range tags {
if tag == target || tag == shared.TagWild {
count++
}
}
return count
}
// HasTag checks if a card has a specific tag.
func HasTag(card *Card, tag shared.CardTag) bool {
for _, cardTag := range card.Tags {
if cardTag == tag {
return true
}
}
return false
}
// CountPlayerNonOceanTiles counts all non-ocean tiles owned by a player.
func CountPlayerNonOceanTiles(playerID string, b *board.Board) int {
count := 0
for _, tile := range b.Tiles() {
if tile.OwnerID == nil || *tile.OwnerID != playerID {
continue
}
if tile.OccupiedBy == nil || tile.OccupiedBy.Type == shared.ResourceOceanTile {
continue
}
count++
}
return count
}
// CountAllNonOceanTiles counts all non-ocean tiles on the board.
func CountAllNonOceanTiles(b *board.Board) int {
count := 0
for _, tile := range b.Tiles() {
if tile.OccupiedBy != nil && tile.OccupiedBy.Type != shared.ResourceOceanTile {
count++
}
}
return count
}
// CountAllTilesOfType counts all tiles of a specific type on the board, regardless of owner.
func CountAllTilesOfType(b *board.Board, tileType shared.ResourceType) int {
count := 0
tiles := b.Tiles()
for _, tile := range tiles {
if tile.OccupiedBy != nil && tile.OccupiedBy.Type == tileType {
count++
}
}
return count
}
// CountTilesOfTypeByLocation counts tiles of a specific type, optionally filtered by location.
// If location is "mars", only counts tiles with TileLocationMars.
// If location is nil or "anywhere", counts all tiles of that type.
func CountTilesOfTypeByLocation(b *board.Board, tileType shared.ResourceType, location *string) int {
count := 0
tiles := b.Tiles()
for _, tile := range tiles {
if tile.OccupiedBy == nil || tile.OccupiedBy.Type != tileType {
continue
}
if location != nil && *location == "mars" && tile.Location != board.TileLocationMars {
continue
}
count++
}
return count
}
// CountAllPlayersTagsByType sums tag counts of a specific type across all players.
func CountAllPlayersTagsByType(players []*player.Player, cardRegistry CardRegistryInterface, tagType shared.CardTag) int {
count := 0
for _, p := range players {
count += CountPlayerTagsByType(p, cardRegistry, tagType)
}
return count
}
// CountOtherPlayersTagsByType sums tag counts of a specific type across all players except the given one.
func CountOtherPlayersTagsByType(players []*player.Player, excludePlayerID string, cardRegistry CardRegistryInterface, tagType shared.CardTag) int {
count := 0
for _, p := range players {
if p.ID() == excludePlayerID {
continue
}
count += CountPlayerTagsByType(p, cardRegistry, tagType)
}
return count
}
// CountPerCondition is the unified counter for PerCondition evaluation.
// Used by card behaviors (scaled outputs), VP calculations, and award scoring.
// Parameters:
// - per: the condition to evaluate
// - sourceCardID: card ID for self-card storage or adjacency (empty string if N/A)
// - p: the player to evaluate for
// - b: the game board (nil-safe for non-tile conditions)
// - cardRegistry: card registry for tag lookups (nil-safe for non-tag conditions)
// - allPlayers: all players in game (only needed for any-player/other-players targeting, nil otherwise)
func CountPerCondition(
per *shared.PerCondition,
sourceCardID string,
p *player.Player,
b *board.Board,
cardRegistry CardRegistryInterface,
allPlayers []*player.Player,
) int {
if per == nil {
return 0
}
// Card storage (e.g., animals on this card)
if per.Target != nil && *per.Target == "self-card" {
if sourceCardID != "" {
return p.Resources().GetCardStorage(sourceCardID)
}
return 0
}
// Adjacent to tile placed by this card (e.g., Capital: 1 VP per adjacent ocean)
if per.AdjacentToSelfTile && b != nil {
return countAdjacentTilesOfTypeForCard(b, sourceCardID, per.ResourceType)
}
// Adjacent to tile type (e.g., World Tree: 1 VP per adjacent forest)
// Skip for land-tile — handled in the tile counting switch with dedicated logic
if per.AdjacentToTileType != nil && b != nil && per.ResourceType != shared.ResourceNonOceanTile {
return countAdjacentTilesOfType(p.ID(), b, per.ResourceType, *per.AdjacentToTileType)
}
// Multi-tag counting (e.g., Ecologist: plant + microbe + animal)
if len(per.Tags) > 0 && cardRegistry != nil {
count := 0
for _, tag := range per.Tags {
count += CountPlayerTagsByType(p, cardRegistry, tag)
}
return count
}
// Tag counting
if per.Tag != nil && cardRegistry != nil {
if per.Target != nil && *per.Target == "any-player" && allPlayers != nil {
return CountAllPlayersTagsByType(allPlayers, cardRegistry, *per.Tag)
}
if per.Target != nil && *per.Target == "other-players" && allPlayers != nil {
return CountOtherPlayersTagsByType(allPlayers, p.ID(), cardRegistry, *per.Tag)
}
return CountPlayerTagsByType(p, cardRegistry, *per.Tag)
}
// Tile counting
if b != nil {
switch per.ResourceType {
case shared.ResourceOceanTile:
return CountAllTilesOfType(b, shared.ResourceOceanTile)
case shared.ResourceNonOceanTile:
if per.MinRow != nil && per.Target != nil && *per.Target == "self-player" {
return countPlayerTilesOnRows(p.ID(), b, *per.MinRow)
}
if per.AdjacentToTileType != nil && per.Target != nil && *per.Target == "self-player" {
return countPlayerTilesAdjacentToOcean(p.ID(), b)
}
if per.Target != nil && *per.Target == "self-player" {
return CountPlayerNonOceanTiles(p.ID(), b)
}
return CountAllNonOceanTiles(b)
case shared.ResourceCityTile:
if per.Target != nil && *per.Target == "self-player" {
rt := shared.ResourceCityTile
return CountPlayerTiles(p.ID(), b, &rt)
}
return CountAllTilesOfType(b, shared.ResourceCityTile)
case shared.ResourceGreeneryTile:
if per.Target != nil && *per.Target == "self-player" {
rt := shared.ResourceGreeneryTile
return CountPlayerTiles(p.ID(), b, &rt)
}
return CountAllTilesOfType(b, shared.ResourceGreeneryTile)
case shared.ResourceColony:
// Colonies are not board tiles; callers must handle colony counting
// via game.Colonies().CountAllColonies() before reaching here.
return 0
}
}
// Terraform rating
if per.ResourceType == shared.ResourceTR {
return p.Resources().TerraformRating()
}
// Cards in hand
if per.ResourceType == shared.ResourceCardCount {
return p.Hand().CardCount()
}
// Card storage resources (floater, animal, microbe, science, etc.)
if isCardStorageType(per.ResourceType) && cardRegistry != nil {
return CountPlayerCardStorageByType(p, cardRegistry, per.ResourceType)
}
// Production counting (e.g., credit-production)
if isProductionType(per.ResourceType) {
return p.Resources().Production().GetAmount(per.ResourceType)
}
// Resource counting (e.g., heat, steel, titanium)
if isBasicResourceType(per.ResourceType) {
return p.Resources().Get().GetAmount(per.ResourceType)
}
// Distinct tag count (Diversifier: 8 different tags)
if per.ResourceType == shared.ResourceDistinctTagCount && cardRegistry != nil {
return countDistinctTags(p, cardRegistry)
}
// Cards with requirements (Tactician: 5 cards with requirements)
if per.ResourceType == shared.ResourceCardsWithRequirements && cardRegistry != nil {
return countCardsWithRequirements(p, cardRegistry)
}
// Max single production (Specialist: 10 of any single production)
if per.ResourceType == shared.ResourceMaxSingleProduction {
return maxSingleProduction(p)
}
// Played card type count (Tycoon, Legend, Magnate, Celebrity)
if per.ResourceType == shared.ResourcePlayedCardTypeCount && cardRegistry != nil {
if per.MinCost != nil {
return countCardsWithMinCost(p, cardRegistry, *per.MinCost)
}
if per.CardTypeFilter != nil {
return countPlayedCardsByType(p, cardRegistry, *per.CardTypeFilter)
}
}
// Total card storage (Excentric: most resources on cards)
if per.ResourceType == shared.ResourceTotalCardStorage {
return countTotalCardStorage(p)
}
// Fallback: try to count as a tag type
if cardRegistry != nil {
return CountPlayerTagsByType(p, cardRegistry, shared.CardTag(per.ResourceType))
}
return 0
}
func isProductionType(rt shared.ResourceType) bool {
switch rt {
case shared.ResourceCreditProduction, shared.ResourceSteelProduction,
shared.ResourceTitaniumProduction, shared.ResourcePlantProduction,
shared.ResourceEnergyProduction, shared.ResourceHeatProduction:
return true
}
return false
}
func isBasicResourceType(rt shared.ResourceType) bool {
switch rt {
case shared.ResourceCredit, shared.ResourceSteel, shared.ResourceTitanium,
shared.ResourcePlant, shared.ResourceEnergy, shared.ResourceHeat:
return true
}
return false
}
func isCardStorageType(rt shared.ResourceType) bool {
switch rt {
case shared.ResourceFloater, shared.ResourceAnimal, shared.ResourceMicrobe,
shared.ResourceScience, shared.ResourceAsteroid, shared.ResourceFighter,
shared.ResourceDisease:
return true
}
return false
}
// CountPlayerCardStorageByType sums card storage across all played cards and corporation
// where the card's storage type matches the given type.
func CountPlayerCardStorageByType(p *player.Player, cardRegistry CardRegistryInterface, storageType shared.ResourceType) int {
total := 0
for _, cardID := range p.PlayedCards().Cards() {
card, err := cardRegistry.GetByID(cardID)
if err != nil || card.ResourceStorage == nil || card.ResourceStorage.Type != storageType {
continue
}
total += p.Resources().GetCardStorage(cardID)
}
if corpID := p.CorporationID(); corpID != "" {
if corp, err := cardRegistry.GetByID(corpID); err == nil && corp.ResourceStorage != nil && corp.ResourceStorage.Type == storageType {
total += p.Resources().GetCardStorage(corpID)
}
}
return total
}
func countDistinctTags(p *player.Player, cardRegistry CardRegistryInterface) int {
tagSet := make(map[shared.CardTag]bool)
for _, cardID := range p.PlayedCards().Cards() {
card, err := cardRegistry.GetByID(cardID)
if err != nil {
continue
}
if card.Type == CardTypeEvent {
continue
}
for _, tag := range card.Tags {
if tag == shared.TagWild {
continue
}
tagSet[tag] = true
}
}
if corpID := p.CorporationID(); corpID != "" {
if corp, err := cardRegistry.GetByID(corpID); err == nil {
for _, tag := range corp.Tags {
if tag != shared.TagWild {
tagSet[tag] = true
}
}
}
}
return len(tagSet)
}
func countCardsWithRequirements(p *player.Player, cardRegistry CardRegistryInterface) int {
count := 0
for _, cardID := range p.PlayedCards().Cards() {
card, err := cardRegistry.GetByID(cardID)
if err != nil {
continue
}
if card.Requirements != nil && len(card.Requirements.Items) > 0 {
count++
}
}
return count
}
func countPlayerTilesOnRows(playerID string, b *board.Board, minRow int) int {
count := 0
for _, tile := range b.Tiles() {
if tile.OwnerID == nil || *tile.OwnerID != playerID {
continue
}
if tile.OccupiedBy == nil {
continue
}
if tile.Coordinates.R >= minRow {
count++
}
}
return count
}
func maxSingleProduction(p *player.Player) int {
prod := p.Resources().Production()
best := 0
for _, rt := range []shared.ResourceType{
shared.ResourceCreditProduction,
shared.ResourceSteelProduction,
shared.ResourceTitaniumProduction,
shared.ResourcePlantProduction,
shared.ResourceEnergyProduction,
shared.ResourceHeatProduction,
} {
if v := prod.GetAmount(rt); v > best {
best = v
}
}
return best
}
func countPlayedCardsByType(p *player.Player, cardRegistry CardRegistryInterface, filter string) int {
count := 0
for _, cardID := range p.PlayedCards().Cards() {
card, err := cardRegistry.GetByID(cardID)
if err != nil {
continue
}
switch filter {
case "automated":
if card.Type == CardTypeAutomated {
count++
}
case "event":
if card.Type == CardTypeEvent {
count++
}
case "automated+event":
if card.Type == CardTypeAutomated || card.Type == CardTypeEvent {
count++
}
}
}
return count
}
func countCardsWithMinCost(p *player.Player, cardRegistry CardRegistryInterface, minCost int) int {
count := 0
for _, cardID := range p.PlayedCards().Cards() {
card, err := cardRegistry.GetByID(cardID)
if err != nil {
continue
}
if card.Cost >= minCost {
count++
}
}
return count
}
func countPlayerTilesAdjacentToOcean(playerID string, b *board.Board) int {
tiles := b.Tiles()
oceanPositions := make(map[shared.HexPosition]bool)
for _, tile := range tiles {
if tile.OccupiedBy != nil && tile.OccupiedBy.Type == shared.ResourceOceanTile {
oceanPositions[tile.Coordinates] = true
}
}
count := 0
for _, tile := range tiles {
if tile.OwnerID == nil || *tile.OwnerID != playerID {
continue
}
if tile.OccupiedBy == nil || tile.OccupiedBy.Type == shared.ResourceOceanTile {
continue
}
for _, neighbor := range tile.Coordinates.GetNeighbors() {
if oceanPositions[neighbor] {
count++
break
}
}
}
return count
}
func countTotalCardStorage(p *player.Player) int {
total := 0
for _, cardID := range p.PlayedCards().Cards() {
stored := p.Resources().GetCardStorage(cardID)
if stored > 0 {
total += stored
}
}
return total
}
package cards
import (
"terraforming-mars-backend/internal/game/board"
"terraforming-mars-backend/internal/game/milestone"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
)
// CalculateMilestoneProgress returns the current progress for a player towards a milestone
func CalculateMilestoneProgress(def *milestone.MilestoneDefinition, p *player.Player, b *board.Board, cardRegistry CardRegistryInterface) int {
req := def.Requirement
switch req.Kind {
case milestone.RequirementKindCountable:
if req.Countable == nil {
return 0
}
return CountPerCondition(&req.Countable.PerCondition, "", p, b, cardRegistry, nil)
case milestone.RequirementKindState:
if req.State == nil {
return 0
}
switch req.State.Type {
case milestone.StateTypeAllProduction:
return countProductionsAtOrAbove(p, req.State.Min)
}
return 0
default:
return 0
}
}
// CanClaimMilestone checks if a player meets the requirements for a milestone
func CanClaimMilestone(def *milestone.MilestoneDefinition, p *player.Player, b *board.Board, cardRegistry CardRegistryInterface) bool {
req := def.Requirement
switch req.Kind {
case milestone.RequirementKindCountable:
if req.Countable == nil {
return false
}
progress := CountPerCondition(&req.Countable.PerCondition, "", p, b, cardRegistry, nil)
if req.Countable.Min != nil && progress < *req.Countable.Min {
return false
}
if req.Countable.Max != nil && progress > *req.Countable.Max {
return false
}
return true
case milestone.RequirementKindState:
if req.State == nil {
return false
}
switch req.State.Type {
case milestone.StateTypeAllProduction:
return countProductionsAtOrAbove(p, req.State.Min) == 6
}
return false
default:
return false
}
}
func countProductionsAtOrAbove(p *player.Player, min int) int {
prod := p.Resources().Production()
count := 0
productions := []shared.ResourceType{
shared.ResourceCreditProduction,
shared.ResourceSteelProduction,
shared.ResourceTitaniumProduction,
shared.ResourcePlantProduction,
shared.ResourceEnergyProduction,
shared.ResourceHeatProduction,
}
for _, rt := range productions {
if prod.GetAmount(rt) >= min {
count++
}
}
return count
}
package cards
import (
"fmt"
"terraforming-mars-backend/internal/game/shared"
)
// CardPayment represents how a player is paying for a card
type CardPayment struct {
Credits int
Steel int
Titanium int
Substitutes map[shared.ResourceType]int
StorageSubstitutes map[string]int // cardID -> amount of storage resources to use as payment
}
// TotalValue calculates the total MC value of this payment.
// playerSubstitutes MUST include steel and titanium with their effective values
// (base value + any modifiers from cards like Phobolog or Advanced Alloys).
// All payment values (steel, titanium, other substitutes) are looked up from playerSubstitutes.
func (p CardPayment) TotalValue(playerSubstitutes []shared.PaymentSubstitute, storageSubstitutes []shared.StoragePaymentSubstitute) int {
total := p.Credits
substituteValues := make(map[shared.ResourceType]int)
for _, sub := range playerSubstitutes {
substituteValues[sub.ResourceType] = sub.ConversionRate
}
if steelValue, ok := substituteValues[shared.ResourceSteel]; ok {
total += p.Steel * steelValue
}
if titaniumValue, ok := substituteValues[shared.ResourceTitanium]; ok {
total += p.Titanium * titaniumValue
}
if p.Substitutes != nil {
for resourceType, amount := range p.Substitutes {
if conversionRate, ok := substituteValues[resourceType]; ok {
total += amount * conversionRate
}
}
}
// Add storage substitute values (e.g., Dirigibles floaters at 3 M€ each)
if p.StorageSubstitutes != nil {
storageSubValues := make(map[string]int)
for _, sub := range storageSubstitutes {
storageSubValues[sub.CardID] = sub.ConversionRate
}
for cardID, amount := range p.StorageSubstitutes {
if rate, ok := storageSubValues[cardID]; ok {
total += amount * rate
}
}
}
return total
}
// Validate checks if the payment is valid
func (p CardPayment) Validate() error {
if p.Credits < 0 {
return fmt.Errorf("payment credits cannot be negative: %d", p.Credits)
}
if p.Steel < 0 {
return fmt.Errorf("payment steel cannot be negative: %d", p.Steel)
}
if p.Titanium < 0 {
return fmt.Errorf("payment titanium cannot be negative: %d", p.Titanium)
}
if p.Substitutes != nil {
for resourceType, amount := range p.Substitutes {
if amount < 0 {
return fmt.Errorf("payment substitute %s cannot be negative: %d", resourceType, amount)
}
}
}
if p.StorageSubstitutes != nil {
for cardID, amount := range p.StorageSubstitutes {
if amount < 0 {
return fmt.Errorf("storage payment substitute from card %s cannot be negative: %d", cardID, amount)
}
}
}
return nil
}
// CanAfford checks if a player has sufficient resources for this payment.
// storageGetter provides access to card storage amounts (cardID -> stored amount).
func (p CardPayment) CanAfford(playerResources shared.Resources, storageGetter func(cardID string) int) error {
if playerResources.Credits < p.Credits {
return fmt.Errorf("insufficient credits: need %d, have %d", p.Credits, playerResources.Credits)
}
if playerResources.Steel < p.Steel {
return fmt.Errorf("insufficient steel: need %d, have %d", p.Steel, playerResources.Steel)
}
if playerResources.Titanium < p.Titanium {
return fmt.Errorf("insufficient titanium: need %d, have %d", p.Titanium, playerResources.Titanium)
}
if p.Substitutes != nil {
for resourceType, amount := range p.Substitutes {
var available int
switch resourceType {
case shared.ResourceHeat:
available = playerResources.Heat
case shared.ResourceEnergy:
available = playerResources.Energy
case shared.ResourcePlant:
available = playerResources.Plants
default:
return fmt.Errorf("unsupported payment substitute resource type: %s", resourceType)
}
if available < amount {
return fmt.Errorf("insufficient %s: need %d, have %d", resourceType, amount, available)
}
}
}
if p.StorageSubstitutes != nil && storageGetter != nil {
for cardID, amount := range p.StorageSubstitutes {
available := storageGetter(cardID)
if available < amount {
return fmt.Errorf("insufficient storage on card %s: need %d, have %d", cardID, amount, available)
}
}
}
return nil
}
// CoversCardCost checks if this payment covers the card cost
func (p CardPayment) CoversCardCost(cardCost int, allowSteel, allowTitanium bool, playerSubstitutes []shared.PaymentSubstitute, storageSubstitutes []shared.StoragePaymentSubstitute) error {
if err := p.Validate(); err != nil {
return err
}
if p.Steel > 0 && !allowSteel {
return fmt.Errorf("card does not have building tag, cannot use steel")
}
if p.Titanium > 0 && !allowTitanium {
return fmt.Errorf("card does not have space tag, cannot use titanium")
}
if p.Substitutes != nil {
for resourceType := range p.Substitutes {
found := false
for _, sub := range playerSubstitutes {
if sub.ResourceType == resourceType {
found = true
break
}
}
if !found {
return fmt.Errorf("player cannot use %s as payment substitute", resourceType)
}
}
}
// Validate storage substitutes are from valid cards
if p.StorageSubstitutes != nil {
for cardID := range p.StorageSubstitutes {
found := false
for _, sub := range storageSubstitutes {
if sub.CardID == cardID {
found = true
break
}
}
if !found {
return fmt.Errorf("player cannot use storage from card %s as payment", cardID)
}
}
}
totalValue := p.TotalValue(playerSubstitutes, storageSubstitutes)
if totalValue < cardCost {
return fmt.Errorf("payment insufficient: card costs %d MC, payment provides %d MC", cardCost, totalValue)
}
return nil
}
package cards
import (
"slices"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
)
// CardLookup is a minimal interface for looking up cards by ID
// This avoids import cycles with internal/cards package
type CardLookup interface {
GetByID(id string) (*Card, error)
}
// RequirementModifierCalculator computes requirement modifiers from player effects and hand
type RequirementModifierCalculator struct {
cardLookup CardLookup
}
// NewRequirementModifierCalculator creates a new calculator
func NewRequirementModifierCalculator(cardLookup CardLookup) *RequirementModifierCalculator {
return &RequirementModifierCalculator{
cardLookup: cardLookup,
}
}
// Calculate computes all requirement modifiers for a player based on their effects and hand
func (c *RequirementModifierCalculator) Calculate(p *player.Player) []shared.RequirementModifier {
if p == nil {
return []shared.RequirementModifier{}
}
modifiers := []shared.RequirementModifier{}
effects := p.Effects().List()
handCardIDs := p.Hand().Cards()
for _, effect := range effects {
for _, outputBC := range effect.Behavior.Outputs {
if outputBC.GetResourceType() != shared.ResourceDiscount {
continue
}
selectors := shared.GetSelectors(outputBC)
// Check selectors for discount targeting
if len(selectors) > 0 {
hasCardSelectors := HasCardSelectorsExcludingResources(selectors)
hasStandardProjectSelectors := HasStandardProjectSelectors(selectors)
hasActionSelectors := HasActionSelectors(selectors)
// Skip action-only selectors (e.g., card-buying, colony-trade)
if hasActionSelectors && !hasCardSelectors && !hasStandardProjectSelectors {
continue
}
// Case 1: Standard project discount
if hasStandardProjectSelectors {
for _, selector := range selectors {
for _, project := range selector.StandardProjects {
projectCopy := project
affectedResources := c.convertAffectedResources(GetResourcesFromSelectors(selectors))
modifier := shared.RequirementModifier{
Amount: outputBC.GetAmount(),
AffectedResources: affectedResources,
StandardProjectTarget: &projectCopy,
}
modifiers = append(modifiers, modifier)
}
}
}
// Case 2: Card discount (tag or type based)
if hasCardSelectors {
for _, cardID := range handCardIDs {
card, err := c.cardLookup.GetByID(cardID)
if err != nil {
continue
}
if MatchesAnySelectorExcludingResources(card, selectors) {
cardIDCopy := cardID
modifier := shared.RequirementModifier{
Amount: outputBC.GetAmount(),
AffectedResources: []shared.ResourceType{shared.ResourceCredit},
CardTarget: &cardIDCopy,
}
modifiers = append(modifiers, modifier)
}
}
}
continue
}
// Global discount (no selectors - applies to all cards in hand)
for _, cardID := range handCardIDs {
cardIDCopy := cardID
modifier := shared.RequirementModifier{
Amount: outputBC.GetAmount(),
AffectedResources: []shared.ResourceType{shared.ResourceCredit},
CardTarget: &cardIDCopy,
}
modifiers = append(modifiers, modifier)
}
}
}
return c.mergeModifiers(modifiers)
}
// convertAffectedResources converts string slice to ResourceType slice
func (c *RequirementModifierCalculator) convertAffectedResources(resources []string) []shared.ResourceType {
if len(resources) == 0 {
return []shared.ResourceType{shared.ResourceCredit} // Default to credits discount
}
result := make([]shared.ResourceType, len(resources))
for i, r := range resources {
result[i] = shared.ResourceType(r)
}
return result
}
// mergeModifiers combines modifiers targeting the same card/project by summing amounts
func (c *RequirementModifierCalculator) mergeModifiers(modifiers []shared.RequirementModifier) []shared.RequirementModifier {
cardModifiers := make(map[string]*shared.RequirementModifier)
projectModifiers := make(map[shared.StandardProject]*shared.RequirementModifier)
for _, mod := range modifiers {
if mod.CardTarget != nil {
key := *mod.CardTarget
if existing, ok := cardModifiers[key]; ok {
existing.Amount += mod.Amount
} else {
modCopy := mod
cardModifiers[key] = &modCopy
}
} else if mod.StandardProjectTarget != nil {
key := *mod.StandardProjectTarget
if existing, ok := projectModifiers[key]; ok {
existing.Amount += mod.Amount
} else {
modCopy := mod
projectModifiers[key] = &modCopy
}
}
}
result := make([]shared.RequirementModifier, 0, len(cardModifiers)+len(projectModifiers))
for _, mod := range cardModifiers {
result = append(result, *mod)
}
for _, mod := range projectModifiers {
result = append(result, *mod)
}
return result
}
// CalculateCardDiscounts computes the total credit discount for a specific card.
// This is used during EntityState calculation instead of pre-computing all modifiers.
// Returns the total discount amount in credits that applies to this card.
func (c *RequirementModifierCalculator) CalculateCardDiscounts(p *player.Player, card *Card) int {
if p == nil || card == nil {
return 0
}
totalDiscount := 0
effects := p.Effects().List()
for _, effect := range effects {
for _, outputBC := range effect.Behavior.Outputs {
if outputBC.GetResourceType() != shared.ResourceDiscount {
continue
}
selectors := shared.GetSelectors(outputBC)
// Check selectors first (new system with AND logic within selector, OR between selectors)
if len(selectors) > 0 {
hasCardSelectors := HasCardSelectorsExcludingResources(selectors)
hasOnlyStandardProjectSelectors := HasStandardProjectSelectors(selectors) && !hasCardSelectors
// Skip non-card-playing action selectors (e.g., card-buying, colony-trade)
if HasActionSelectors(selectors) && !hasCardSelectors && !hasAction(selectors, shared.ActionCardPlaying) {
continue
}
if hasOnlyStandardProjectSelectors {
continue
}
if hasCardSelectors {
if MatchesAnySelectorExcludingResources(card, selectors) {
totalDiscount += outputBC.GetAmount()
}
continue
}
totalDiscount += outputBC.GetAmount()
continue
}
// Global discount (no selectors - applies to all cards)
totalDiscount += outputBC.GetAmount()
}
}
return totalDiscount
}
// CalculateGlobalParameterLenience computes the total lenience for a specific global parameter requirement.
// Lenience widens the min/max window: min is lowered, max is raised.
// The paramType should be one of: "temperature", "oxygen", "ocean", "venus".
func (c *RequirementModifierCalculator) CalculateGlobalParameterLenience(p *player.Player, paramType string) int {
if p == nil {
return 0
}
totalLenience := 0
for _, effect := range p.Effects().List() {
for _, outputBC := range effect.Behavior.Outputs {
if outputBC.GetResourceType() != shared.ResourceGlobalParameterLenience {
continue
}
selectors := shared.GetSelectors(outputBC)
if len(selectors) > 0 {
if matchesGlobalParameterSelector(selectors, paramType) {
totalLenience += outputBC.GetAmount()
}
} else {
totalLenience += outputBC.GetAmount()
}
}
}
return totalLenience
}
// HasIgnoreGlobalRequirements returns true if the player has an active effect
// that ignores all global parameter requirements (e.g. from Ecology Experts).
func (c *RequirementModifierCalculator) HasIgnoreGlobalRequirements(p *player.Player) bool {
if p == nil {
return false
}
for _, effect := range p.Effects().List() {
for _, output := range effect.Behavior.Outputs {
if output.GetResourceType() == shared.ResourceIgnoreGlobalRequirements {
return true
}
}
}
return false
}
func matchesGlobalParameterSelector(selectors []shared.Selector, paramType string) bool {
for _, sel := range selectors {
if slices.Contains(sel.GlobalParameters, paramType) {
return true
}
}
return false
}
// CalculateActionDiscounts computes discounts for a specific action type (e.g., card-buying, colony-trade).
// Returns a map of resource type to discount amount.
// Affected resources are read from the selector's resources field; defaults to credits if unspecified.
func (c *RequirementModifierCalculator) CalculateActionDiscounts(
p *player.Player,
actionType string,
) map[shared.ResourceType]int {
discounts := make(map[shared.ResourceType]int)
if p == nil {
return discounts
}
for _, effect := range p.Effects().List() {
for _, outputBC := range effect.Behavior.Outputs {
if outputBC.GetResourceType() != shared.ResourceDiscount {
continue
}
selectors := shared.GetSelectors(outputBC)
if len(selectors) > 0 && MatchesAnyActionSelector(actionType, selectors) {
affectedResources := c.convertAffectedResources(GetResourcesFromSelectors(selectors))
for _, rt := range affectedResources {
discounts[rt] += outputBC.GetAmount()
}
}
}
}
return discounts
}
// CalculateActionDiscountsFromCard computes action discounts from a specific card's behaviors.
// Used during starting selection when corporation effects are not yet applied to the player.
func CalculateActionDiscountsFromCard(card *Card, actionType string) int {
totalDiscount := 0
for _, behavior := range card.Behaviors {
for _, outputBC := range behavior.Outputs {
if outputBC.GetResourceType() != shared.ResourceDiscount {
continue
}
if MatchesAnyActionSelector(actionType, shared.GetSelectors(outputBC)) {
totalDiscount += outputBC.GetAmount()
}
}
}
return totalDiscount
}
// CalculateStandardProjectDiscounts computes discounts for a specific standard project.
// Returns a map of resource type to discount amount.
// For example, Ecoline's discount on PlantGreenery returns {"plants": 1}.
func (c *RequirementModifierCalculator) CalculateStandardProjectDiscounts(
p *player.Player,
projectType shared.StandardProject,
) map[shared.ResourceType]int {
discounts := make(map[shared.ResourceType]int)
if p == nil {
return discounts
}
effects := p.Effects().List()
for _, effect := range effects {
for _, outputBC := range effect.Behavior.Outputs {
if outputBC.GetResourceType() != shared.ResourceDiscount {
continue
}
selectors := shared.GetSelectors(outputBC)
// Check selectors for standard project discount
if len(selectors) > 0 {
if MatchesAnyStandardProjectSelector(projectType, selectors) {
affectedResources := c.convertAffectedResources(GetResourcesFromSelectors(selectors))
for _, resourceType := range affectedResources {
discounts[resourceType] += outputBC.GetAmount()
}
}
}
}
}
return discounts
}
func hasAction(selectors []shared.Selector, actionType string) bool {
for _, sel := range selectors {
if slices.Contains(sel.Actions, actionType) {
return true
}
}
return false
}
package cards
import (
"slices"
"terraforming-mars-backend/internal/game/shared"
)
// MatchesSelector checks if a card matches a single selector.
// Tags: ALL must be present (AND logic)
// CardTypes: ANY can match (OR logic - card has one type)
// Resources: card must have the resource (in storage, behavior outputs, or behavior inputs)
// RequiredOriginalCost: card cost must satisfy min/max constraints
// A selector must have at least one card-relevant criterion to match.
func MatchesSelector(card *Card, selector shared.Selector) bool {
if len(selector.Tags) == 0 && len(selector.CardTypes) == 0 && selector.RequiredOriginalCost == nil && selector.VP == nil && len(selector.Resources) == 0 {
return false
}
if len(selector.Tags) > 0 {
for _, requiredTag := range selector.Tags {
if !slices.Contains(card.Tags, requiredTag) {
return false
}
}
}
if len(selector.CardTypes) > 0 {
if !slices.Contains(selector.CardTypes, string(card.Type)) {
return false
}
}
if selector.RequiredOriginalCost != nil {
if selector.RequiredOriginalCost.Min != nil && card.Cost < *selector.RequiredOriginalCost.Min {
return false
}
if selector.RequiredOriginalCost.Max != nil && card.Cost > *selector.RequiredOriginalCost.Max {
return false
}
}
if selector.VP != nil {
hasVP := false
for _, vp := range card.VPConditions {
if selector.VP.Min != nil && vp.Amount < *selector.VP.Min {
continue
}
if selector.VP.Max != nil && vp.Amount > *selector.VP.Max {
continue
}
hasVP = true
break
}
if !hasVP {
return false
}
}
if len(selector.Resources) > 0 {
if !cardHasAnyResource(card, selector.Resources) {
return false
}
}
return true
}
// cardHasAnyResource checks if a card has any of the specified resources
// in its resource storage, behavior outputs, or behavior inputs.
func cardHasAnyResource(card *Card, resources []string) bool {
for _, res := range resources {
rt := shared.ResourceType(res)
if card.ResourceStorage != nil && card.ResourceStorage.Type == rt {
return true
}
for _, b := range card.Behaviors {
for _, o := range b.Outputs {
if o.GetResourceType() == rt {
return true
}
}
for _, i := range b.Inputs {
if i.GetResourceType() == rt {
return true
}
}
}
}
return false
}
// MatchesAnySelector checks if a card matches any selector (OR between selectors)
func MatchesAnySelector(card *Card, selectors []shared.Selector) bool {
if len(selectors) == 0 {
return false
}
for _, sel := range selectors {
if MatchesSelector(card, sel) {
return true
}
}
return false
}
// MatchesStandardProjectSelector checks if a project matches a selector
func MatchesStandardProjectSelector(project shared.StandardProject, selector shared.Selector) bool {
return slices.Contains(selector.StandardProjects, project)
}
// MatchesAnyStandardProjectSelector checks OR between selectors for projects
func MatchesAnyStandardProjectSelector(project shared.StandardProject, selectors []shared.Selector) bool {
for _, sel := range selectors {
if MatchesStandardProjectSelector(project, sel) {
return true
}
}
return false
}
// HasCardSelectors returns true if any selector targets cards
func HasCardSelectors(selectors []shared.Selector) bool {
for _, sel := range selectors {
if len(sel.Tags) > 0 || len(sel.CardTypes) > 0 || sel.RequiredOriginalCost != nil || sel.VP != nil || len(sel.Resources) > 0 {
return true
}
}
return false
}
// HasCardSelectorsExcludingResources returns true if any selector targets cards via tags, types, cost, or VP.
// Unlike HasCardSelectors, this ignores the Resources field which on discount selectors
// indicates the affected resource type rather than a card filter.
func HasCardSelectorsExcludingResources(selectors []shared.Selector) bool {
for _, sel := range selectors {
if len(sel.Tags) > 0 || len(sel.CardTypes) > 0 || sel.RequiredOriginalCost != nil || sel.VP != nil {
return true
}
}
return false
}
// MatchesSelectorExcludingResources checks if a card matches a selector, ignoring the Resources field.
// Used for discount calculations where Resources indicates the discounted resource type, not a card filter.
func MatchesSelectorExcludingResources(card *Card, selector shared.Selector) bool {
if len(selector.Tags) == 0 && len(selector.CardTypes) == 0 && selector.RequiredOriginalCost == nil && selector.VP == nil {
return false
}
if len(selector.Tags) > 0 {
for _, requiredTag := range selector.Tags {
if !slices.Contains(card.Tags, requiredTag) {
return false
}
}
}
if len(selector.CardTypes) > 0 {
if !slices.Contains(selector.CardTypes, string(card.Type)) {
return false
}
}
if selector.RequiredOriginalCost != nil {
if selector.RequiredOriginalCost.Min != nil && card.Cost < *selector.RequiredOriginalCost.Min {
return false
}
if selector.RequiredOriginalCost.Max != nil && card.Cost > *selector.RequiredOriginalCost.Max {
return false
}
}
if selector.VP != nil {
hasVP := false
for _, vp := range card.VPConditions {
if selector.VP.Min != nil && vp.Amount < *selector.VP.Min {
continue
}
if selector.VP.Max != nil && vp.Amount > *selector.VP.Max {
continue
}
hasVP = true
break
}
if !hasVP {
return false
}
}
return true
}
// MatchesAnySelectorExcludingResources checks if a card matches any selector, ignoring Resources field.
func MatchesAnySelectorExcludingResources(card *Card, selectors []shared.Selector) bool {
for _, sel := range selectors {
if MatchesSelectorExcludingResources(card, sel) {
return true
}
}
return false
}
// HasStandardProjectSelectors returns true if any selector targets standard projects
func HasStandardProjectSelectors(selectors []shared.Selector) bool {
for _, sel := range selectors {
if len(sel.StandardProjects) > 0 {
return true
}
}
return false
}
// HasResourceSelectors returns true if any selector targets resources
func HasResourceSelectors(selectors []shared.Selector) bool {
for _, sel := range selectors {
if len(sel.Resources) > 0 {
return true
}
}
return false
}
// GetResourcesFromSelectors collects all resources from selectors into a single slice
func GetResourcesFromSelectors(selectors []shared.Selector) []string {
var resources []string
seen := make(map[string]bool)
for _, sel := range selectors {
for _, r := range sel.Resources {
if !seen[r] {
seen[r] = true
resources = append(resources, r)
}
}
}
return resources
}
// hasPreludeCardType returns true if any selector contains "prelude" in its CardTypes
func hasPreludeCardType(selectors []shared.Selector) bool {
for _, sel := range selectors {
if slices.Contains(sel.CardTypes, "prelude") {
return true
}
}
return false
}
// HasActionSelectors returns true if any selector targets actions
func HasActionSelectors(selectors []shared.Selector) bool {
for _, sel := range selectors {
if len(sel.Actions) > 0 {
return true
}
}
return false
}
// MatchesAnyActionSelector checks if an action type matches any selector (OR between selectors)
func MatchesAnyActionSelector(actionType string, selectors []shared.Selector) bool {
for _, sel := range selectors {
if slices.Contains(sel.Actions, actionType) {
return true
}
}
return false
}
// MatchesResourceSelector checks if a resource type matches a selector
func MatchesResourceSelector(resourceType string, selector shared.Selector) bool {
return slices.Contains(selector.Resources, resourceType)
}
// MatchesAnyResourceSelector checks OR between selectors for resources
func MatchesAnyResourceSelector(resourceType string, selectors []shared.Selector) bool {
for _, sel := range selectors {
if MatchesResourceSelector(resourceType, sel) {
return true
}
}
return false
}
package cards
import (
"fmt"
"terraforming-mars-backend/internal/awards"
"terraforming-mars-backend/internal/game/board"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
"terraforming-mars-backend/internal/milestones"
)
// BoardContext provides board and colony data for VP calculation.
// *game.Game satisfies this interface.
type BoardContext interface {
Board() *board.Board
CountAllColonies() int
}
// CardVPConditionDetail represents the detailed calculation of a single VP condition
type CardVPConditionDetail struct {
ConditionType string `json:"conditionType"` // "fixed", "per", "once"
Amount int `json:"amount"` // VP amount per trigger or fixed amount
Count int `json:"count"` // Items counted (for "per" conditions)
MaxTrigger *int `json:"maxTrigger"` // Max triggers limit (if any)
ActualTriggers int `json:"actualTriggers"` // Actual triggers after applying max
TotalVP int `json:"totalVP"` // Final VP from this condition
Explanation string `json:"explanation"` // Human-readable breakdown
}
// CardVPDetail represents VP calculation for a single card
type CardVPDetail struct {
CardID string `json:"cardId"`
CardName string `json:"cardName"`
Conditions []CardVPConditionDetail `json:"conditions"`
TotalVP int `json:"totalVP"`
}
// GreeneryVPDetail represents VP from a single greenery tile
type GreeneryVPDetail struct {
Coordinate string `json:"coordinate"` // Format: "q,r,s"
VP int `json:"vp"` // Always 1 per greenery
}
// CityVPDetail represents VP from a single city tile and its adjacent greeneries
type CityVPDetail struct {
CityCoordinate string `json:"cityCoordinate"` // Format: "q,r,s"
AdjacentGreeneries []string `json:"adjacentGreeneries"` // Coordinates of adjacent greenery tiles
VP int `json:"vp"` // Number of adjacent greeneries
}
// VPBreakdown contains the detailed breakdown of a player's victory points
type VPBreakdown struct {
TerraformRating int `json:"terraformRating"`
CardVP int `json:"cardVP"`
CardVPDetails []CardVPDetail `json:"cardVPDetails"` // Per-card VP breakdown
MilestoneVP int `json:"milestoneVP"`
AwardVP int `json:"awardVP"`
GreeneryVP int `json:"greeneryVP"`
GreeneryVPDetails []GreeneryVPDetail `json:"greeneryVPDetails"` // Per-greenery VP breakdown
CityVP int `json:"cityVP"`
CityVPDetails []CityVPDetail `json:"cityVPDetails"` // Per-city VP breakdown with adjacencies
TotalVP int `json:"totalVP"`
}
// MilestonesInterface defines the interface for accessing milestones
type MilestonesInterface interface {
GetClaimedByPlayer(playerID string) []ClaimedMilestoneInfo
}
// ClaimedMilestoneInfo represents info about a claimed milestone
type ClaimedMilestoneInfo struct {
Type string
PlayerID string
}
// AwardsInterface defines the interface for accessing funded awards
type AwardsInterface interface {
FundedAwards() []FundedAwardInfo
}
// FundedAwardInfo represents info about a funded award
type FundedAwardInfo struct {
Type string
}
// CalculatePlayerVP computes the total VP for a player with detailed breakdown
func CalculatePlayerVP(
p *player.Player,
bc BoardContext,
claimedMilestones []ClaimedMilestoneInfo,
fundedAwards []FundedAwardInfo,
allPlayers []*player.Player,
cardRegistry CardRegistryInterface,
awardRegistry awards.AwardRegistry,
milestoneRegistry milestones.MilestoneRegistry,
) VPBreakdown {
b := bc.Board()
breakdown := VPBreakdown{}
// 1. Terraform Rating
breakdown.TerraformRating = p.Resources().TerraformRating()
// 2. Card VP (with detailed breakdown)
cardVPDetails := calculateCardVPDetailed(p, bc, cardRegistry)
breakdown.CardVPDetails = cardVPDetails
breakdown.CardVP = 0
for _, detail := range cardVPDetails {
breakdown.CardVP += detail.TotalVP
}
// 3. Milestone VP
breakdown.MilestoneVP = calculateMilestoneVP(p.ID(), claimedMilestones, milestoneRegistry)
// 4. Award VP
breakdown.AwardVP = calculateAwardVP(p.ID(), fundedAwards, allPlayers, b, cardRegistry, awardRegistry)
// 5. Greenery VP (1 VP per greenery tile owned) with detailed breakdown
greeneryDetails := calculateGreeneryVPDetailed(p.ID(), b)
breakdown.GreeneryVPDetails = greeneryDetails
breakdown.GreeneryVP = 0
for _, detail := range greeneryDetails {
breakdown.GreeneryVP += detail.VP
}
// 6. City VP (1 VP per adjacent greenery to owned cities) with detailed breakdown
cityDetails := calculateCityVPDetailed(p.ID(), b)
breakdown.CityVPDetails = cityDetails
breakdown.CityVP = 0
for _, detail := range cityDetails {
breakdown.CityVP += detail.VP
}
breakdown.TotalVP = breakdown.TerraformRating +
breakdown.CardVP +
breakdown.MilestoneVP +
breakdown.AwardVP +
breakdown.GreeneryVP +
breakdown.CityVP
return breakdown
}
// calculateCardVPDetailed calculates VP from all played cards with detailed breakdown
func calculateCardVPDetailed(p *player.Player, bc BoardContext, cardRegistry CardRegistryInterface) []CardVPDetail {
var details []CardVPDetail
playedCardIDs := p.PlayedCards().Cards()
for _, cardID := range playedCardIDs {
card, err := cardRegistry.GetByID(cardID)
if err != nil {
continue // Skip cards not in registry
}
if len(card.VPConditions) == 0 {
continue // Skip cards with no VP
}
detail := CardVPDetail{
CardID: card.ID,
CardName: card.Name,
TotalVP: 0,
}
for _, vpCond := range card.VPConditions {
condDetail := evaluateVPConditionDetailed(vpCond, p, bc, card, cardRegistry)
detail.Conditions = append(detail.Conditions, condDetail)
detail.TotalVP += condDetail.TotalVP
}
if detail.TotalVP > 0 || len(detail.Conditions) > 0 {
details = append(details, detail)
}
}
return details
}
// evaluateVPConditionDetailed evaluates a single VP condition and returns detailed breakdown
func evaluateVPConditionDetailed(
vpCond VictoryPointCondition,
p *player.Player,
bc BoardContext,
card *Card,
cardRegistry CardRegistryInterface,
) CardVPConditionDetail {
detail := CardVPConditionDetail{
ConditionType: string(vpCond.Condition),
Amount: vpCond.Amount,
}
switch vpCond.Condition {
case VPConditionFixed:
detail.ActualTriggers = 1
detail.TotalVP = vpCond.Amount
detail.Explanation = fmt.Sprintf("%d VP", vpCond.Amount)
case VPConditionPer:
if vpCond.Per == nil {
detail.Explanation = "Invalid condition"
return detail
}
var count int
if vpCond.Per.ResourceType == shared.ResourceColony {
count = bc.CountAllColonies()
} else {
count = CountPerCondition(vpCond.Per, card.ID, p, bc.Board(), cardRegistry, nil)
}
detail.Count = count
if vpCond.Per.Amount > 0 {
triggers := count / vpCond.Per.Amount
detail.MaxTrigger = vpCond.MaxTrigger
if vpCond.MaxTrigger != nil && *vpCond.MaxTrigger >= 0 && triggers > *vpCond.MaxTrigger {
triggers = *vpCond.MaxTrigger
}
detail.ActualTriggers = triggers
detail.TotalVP = vpCond.Amount * triggers
// Build human-readable explanation
countType := getPerConditionTypeName(vpCond.Per)
if vpCond.Per.Amount == 1 {
detail.Explanation = fmt.Sprintf("%d VP (%d %s)", detail.TotalVP, count, countType)
} else {
detail.Explanation = fmt.Sprintf("%d VP (%d per %d %s, %d found)", detail.TotalVP, vpCond.Amount, vpCond.Per.Amount, countType, count)
}
}
case VPConditionOnce:
detail.ActualTriggers = 1
detail.TotalVP = vpCond.Amount
detail.Explanation = fmt.Sprintf("%d VP (one-time)", vpCond.Amount)
default:
detail.Explanation = "Unknown condition"
}
return detail
}
func getPerConditionTypeName(per *shared.PerCondition) string {
if per.Target != nil && *per.Target == "self-card" {
return "on this card"
}
if per.Tag != nil {
return string(*per.Tag) + " tags"
}
switch per.ResourceType {
case shared.ResourceOceanTile:
return "ocean tiles"
case shared.ResourceCityTile:
return "city tiles"
case shared.ResourceGreeneryTile:
return "greenery tiles"
case shared.ResourceColony:
return "colonies"
default:
return string(per.ResourceType)
}
}
// calculateMilestoneVP calculates VP from claimed milestones using per-milestone reward VP
func calculateMilestoneVP(playerID string, claimedMilestones []ClaimedMilestoneInfo, milestoneRegistry milestones.MilestoneRegistry) int {
vp := 0
for _, ms := range claimedMilestones {
if ms.PlayerID != playerID {
continue
}
if milestoneRegistry != nil {
def, err := milestoneRegistry.GetByID(ms.Type)
if err == nil {
vp += def.GetRewardVP()
continue
}
}
vp += 5 // fallback
}
return vp
}
func calculateAwardVP(
playerID string,
fundedAwards []FundedAwardInfo,
allPlayers []*player.Player,
b *board.Board,
cardRegistry CardRegistryInterface,
awardRegistry awards.AwardRegistry,
) int {
totalVP := 0
for _, fundedAward := range fundedAwards {
def, err := awardRegistry.GetByID(fundedAward.Type)
if err != nil {
continue
}
placements := ScoreAward(def, allPlayers, b, cardRegistry)
for _, placement := range placements {
if placement.PlayerID == playerID {
totalVP += GetAwardVP(def, placement.Placement)
break
}
}
}
return totalVP
}
// calculateGreeneryVPDetailed calculates VP from greenery tiles with coordinate details
func calculateGreeneryVPDetailed(playerID string, b *board.Board) []GreeneryVPDetail {
var details []GreeneryVPDetail
tiles := b.Tiles()
for _, tile := range tiles {
if tile.OwnerID == nil || *tile.OwnerID != playerID {
continue
}
if tile.OccupiedBy == nil || tile.OccupiedBy.Type != shared.ResourceGreeneryTile {
continue
}
coord := fmt.Sprintf("%d,%d,%d", tile.Coordinates.Q, tile.Coordinates.R, tile.Coordinates.S)
details = append(details, GreeneryVPDetail{
Coordinate: coord,
VP: 1,
})
}
return details
}
// calculateCityVPDetailed calculates VP from city tiles with adjacent greenery coordinates
func calculateCityVPDetailed(playerID string, b *board.Board) []CityVPDetail {
var details []CityVPDetail
tiles := b.Tiles()
// Find all cities owned by the player
for _, tile := range tiles {
if tile.OwnerID == nil || *tile.OwnerID != playerID {
continue
}
if tile.OccupiedBy == nil || tile.OccupiedBy.Type != shared.ResourceCityTile {
continue
}
cityCoord := fmt.Sprintf("%d,%d,%d", tile.Coordinates.Q, tile.Coordinates.R, tile.Coordinates.S)
adjacentGreeneryCoords := getAdjacentGreeneryCoordinates(tile.Coordinates, tiles)
details = append(details, CityVPDetail{
CityCoordinate: cityCoord,
AdjacentGreeneries: adjacentGreeneryCoords,
VP: len(adjacentGreeneryCoords),
})
}
return details
}
// getAdjacentGreeneryCoordinates returns coordinates of greenery tiles adjacent to a position
func getAdjacentGreeneryCoordinates(coords shared.HexPosition, tiles []board.Tile) []string {
var greeneryCoords []string
neighbors := coords.GetNeighbors()
for _, tile := range tiles {
if tile.OccupiedBy == nil || tile.OccupiedBy.Type != shared.ResourceGreeneryTile {
continue
}
for _, neighbor := range neighbors {
if tile.Coordinates == neighbor {
coord := fmt.Sprintf("%d,%d,%d", tile.Coordinates.Q, tile.Coordinates.R, tile.Coordinates.S)
greeneryCoords = append(greeneryCoords, coord)
break
}
}
}
return greeneryCoords
}
// countAdjacentTilesOfTypeForCard counts tiles matching countType that are adjacent
// to the tile placed by a specific card (identified by "source:CARD_ID" occupant tag).
func countAdjacentTilesOfTypeForCard(b *board.Board, cardID string, countType shared.ResourceType) int {
tiles := b.Tiles()
sourceTag := "source:" + cardID
// Find the tile placed by this card
var sourceTile *board.Tile
for i := range tiles {
if tiles[i].OccupiedBy == nil {
continue
}
for _, tag := range tiles[i].OccupiedBy.Tags {
if tag == sourceTag {
sourceTile = &tiles[i]
break
}
}
if sourceTile != nil {
break
}
}
if sourceTile == nil {
return 0
}
neighbors := sourceTile.Coordinates.GetNeighbors()
count := 0
for _, tile := range tiles {
if tile.OccupiedBy == nil {
continue
}
if tile.OccupiedBy.Type != countType {
continue
}
for _, neighbor := range neighbors {
if tile.Coordinates == neighbor {
count++
break
}
}
}
return count
}
// countAdjacentTilesOfType counts unique tiles matching countType that are adjacent
// to tiles of adjacentToType owned by playerID. Uses shared.IsForestTile for greenery matching.
func countAdjacentTilesOfType(playerID string, b *board.Board, countType shared.ResourceType, adjacentToType shared.ResourceType) int {
tiles := b.Tiles()
counted := make(map[shared.HexPosition]bool)
for _, tile := range tiles {
if tile.OwnerID == nil || *tile.OwnerID != playerID {
continue
}
if tile.OccupiedBy == nil || tile.OccupiedBy.Type != adjacentToType {
continue
}
neighbors := tile.Coordinates.GetNeighbors()
for _, neighborTile := range tiles {
if neighborTile.OccupiedBy == nil || counted[neighborTile.Coordinates] {
continue
}
var matches bool
if countType == shared.ResourceGreeneryTile {
matches = shared.IsForestTile(neighborTile.OccupiedBy.Type)
} else {
matches = neighborTile.OccupiedBy.Type == countType
}
if !matches {
continue
}
for _, neighbor := range neighbors {
if neighborTile.Coordinates == neighbor {
counted[neighborTile.Coordinates] = true
break
}
}
}
}
return len(counted)
}
package colonies
import (
"slices"
"time"
"go.uber.org/zap"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game/colony"
"terraforming-mars-backend/internal/game/datastore"
"terraforming-mars-backend/internal/logger"
)
const maxColoniesPerTile = 3
type Colonies struct {
ds *datastore.DataStore
gameID string
eventBus *events.EventBusImpl
}
func NewColonies(ds *datastore.DataStore, gameID string, eventBus *events.EventBusImpl) *Colonies {
return &Colonies{
ds: ds,
gameID: gameID,
eventBus: eventBus,
}
}
func (c *Colonies) update(fn func(s *datastore.GameState)) {
if err := c.ds.UpdateGame(c.gameID, fn); err != nil {
logger.Get().Warn("Failed to update game state", zap.String("game_id", c.gameID), zap.Error(err))
}
}
func (c *Colonies) read(fn func(s *datastore.GameState)) {
if err := c.ds.ReadGame(c.gameID, fn); err != nil {
logger.Get().Warn("Failed to read game state", zap.String("game_id", c.gameID), zap.Error(err))
}
}
func (c *Colonies) States() []*colony.ColonyState {
var result []*colony.ColonyState
c.read(func(s *datastore.GameState) { result = s.ColonyStates })
return result
}
func (c *Colonies) SetStates(states []*colony.ColonyState) {
c.update(func(s *datastore.GameState) {
s.ColonyStates = states
s.UpdatedAt = time.Now()
})
}
func (c *Colonies) GetState(colonyID string) *colony.ColonyState {
var result *colony.ColonyState
c.read(func(s *datastore.GameState) {
for _, state := range s.ColonyStates {
if state.DefinitionID == colonyID {
result = state
return
}
}
})
return result
}
func (c *Colonies) GetAvailableIDs() []string {
states := c.States()
ids := make([]string, 0, len(states))
for _, cs := range states {
ids = append(ids, cs.DefinitionID)
}
return ids
}
// GetPlaceableIDs returns colony IDs where the player can place a colony
// (not full and player doesn't already have a colony there, unless allowDuplicate is true).
func (c *Colonies) GetPlaceableIDs(playerID string, allowDuplicate bool) []string {
states := c.States()
ids := make([]string, 0, len(states))
for _, cs := range states {
if len(cs.PlayerColonies) >= maxColoniesPerTile {
continue
}
if !allowDuplicate && slices.Contains(cs.PlayerColonies, playerID) {
continue
}
ids = append(ids, cs.DefinitionID)
}
return ids
}
// GetTradeableIDs returns colony IDs that haven't been traded this generation.
func (c *Colonies) GetTradeableIDs() []string {
states := c.States()
ids := make([]string, 0, len(states))
for _, cs := range states {
if !cs.TradedThisGen {
ids = append(ids, cs.DefinitionID)
}
}
return ids
}
// CountPlayerColonies counts how many colonies a specific player has across all colony tiles.
func (c *Colonies) CountPlayerColonies(playerID string) int {
var total int
c.read(func(s *datastore.GameState) {
for _, state := range s.ColonyStates {
for _, id := range state.PlayerColonies {
if id == playerID {
total++
}
}
}
})
return total
}
func (c *Colonies) CountAllColonies() int {
var total int
c.read(func(s *datastore.GameState) {
for _, state := range s.ColonyStates {
total += len(state.PlayerColonies)
}
})
return total
}
func (c *Colonies) GetTradeFleetAvailable(playerID string) bool {
var v bool
c.read(func(s *datastore.GameState) {
if s.TradeFleets == nil {
return
}
v = s.TradeFleets[playerID]
})
return v
}
func (c *Colonies) SetTradeFleetAvailable(playerID string, available bool) {
c.update(func(s *datastore.GameState) {
if s.TradeFleets == nil {
s.TradeFleets = make(map[string]bool)
}
s.TradeFleets[playerID] = available
s.UpdatedAt = time.Now()
})
}
package datastore
import (
"encoding/json"
"fmt"
"sync"
"time"
"github.com/hashicorp/go-memdb"
"terraforming-mars-backend/internal/game/shared"
)
// DataStore is the single source of truth for all game data.
type DataStore struct {
db *memdb.MemDB
historySeqMu sync.Mutex
historySequence map[string]int64 // per-game sequence counter
}
func NewDataStore() (*DataStore, error) {
db, err := memdb.NewMemDB(createSchema())
if err != nil {
return nil, fmt.Errorf("failed to create memdb: %w", err)
}
return &DataStore{
db: db,
historySequence: make(map[string]int64),
}, nil
}
func (ds *DataStore) GetGame(gameID string) (*GameState, error) {
txn := ds.db.Txn(false)
defer txn.Abort()
raw, err := txn.First("games", "id", gameID)
if err != nil {
return nil, fmt.Errorf("failed to get game %s: %w", gameID, err)
}
if raw == nil {
return nil, fmt.Errorf("game %s not found", gameID)
}
return raw.(*GameState), nil
}
func (ds *DataStore) ListGames(status *shared.GameStatus) ([]*GameState, error) {
txn := ds.db.Txn(false)
defer txn.Abort()
var it memdb.ResultIterator
var err error
if status != nil {
it, err = txn.Get("games", "status", *status)
} else {
it, err = txn.Get("games", "id")
}
if err != nil {
return nil, fmt.Errorf("failed to list games: %w", err)
}
var games []*GameState
for obj := it.Next(); obj != nil; obj = it.Next() {
games = append(games, obj.(*GameState))
}
return games, nil
}
// UpdateGame fetches a game inside a write transaction, passes it to fn for mutation,
// then re-inserts and commits. If the game is not found, returns an error without calling fn.
func (ds *DataStore) UpdateGame(gameID string, fn func(state *GameState)) error {
txn := ds.db.Txn(true)
defer txn.Abort()
raw, err := txn.First("games", "id", gameID)
if err != nil {
return fmt.Errorf("failed to get game %s: %w", gameID, err)
}
if raw == nil {
return fmt.Errorf("game %s not found", gameID)
}
state := raw.(*GameState)
fn(state)
if err := txn.Insert("games", state); err != nil {
return fmt.Errorf("failed to insert game %s: %w", state.ID, err)
}
txn.Commit()
ds.appendHistory(state)
return nil
}
func (ds *DataStore) appendHistory(state *GameState) {
copied := deepCopyGameState(state)
if copied == nil {
return
}
ds.historySeqMu.Lock()
ds.historySequence[state.ID]++
seq := ds.historySequence[state.ID]
ds.historySeqMu.Unlock()
entry := &GameStateHistoryEntry{
GameID: state.ID,
Sequence: seq,
Timestamp: time.Now(),
State: copied,
}
htxn := ds.db.Txn(true)
_ = htxn.Insert("game_history", entry)
htxn.Commit()
}
func deepCopyGameState(src *GameState) *GameState {
data, err := json.Marshal(src)
if err != nil {
return nil
}
dst := &GameState{}
if err := json.Unmarshal(data, dst); err != nil {
return nil
}
return dst
}
// RecordInitialHistory records the initial game state as the first history entry.
func (ds *DataStore) RecordInitialHistory(state *GameState) {
ds.appendHistory(state)
}
// GetGameHistory returns all history entries for a game in chronological order.
func (ds *DataStore) GetGameHistory(gameID string) ([]*GameStateHistoryEntry, error) {
txn := ds.db.Txn(false)
defer txn.Abort()
it, err := txn.Get("game_history", "game_id", gameID)
if err != nil {
return nil, fmt.Errorf("failed to get game history for %s: %w", gameID, err)
}
var entries []*GameStateHistoryEntry
for obj := it.Next(); obj != nil; obj = it.Next() {
entries = append(entries, obj.(*GameStateHistoryEntry))
}
return entries, nil
}
// ReadGame fetches a game inside a read-only transaction and passes it to fn.
func (ds *DataStore) ReadGame(gameID string, fn func(state *GameState)) error {
txn := ds.db.Txn(false)
defer txn.Abort()
raw, err := txn.First("games", "id", gameID)
if err != nil {
return fmt.Errorf("failed to get game %s: %w", gameID, err)
}
if raw == nil {
return fmt.Errorf("game %s not found", gameID)
}
fn(raw.(*GameState))
return nil
}
func (ds *DataStore) UpdatePlayer(gameID, playerID string, fn func(state *PlayerState)) error {
return ds.UpdateGame(gameID, func(s *GameState) {
ps, ok := s.Players[playerID]
if !ok {
return
}
fn(ps)
})
}
func (ds *DataStore) ReadPlayer(gameID, playerID string, fn func(state *PlayerState)) error {
return ds.ReadGame(gameID, func(s *GameState) {
ps, ok := s.Players[playerID]
if !ok {
return
}
fn(ps)
})
}
func (ds *DataStore) GameExists(gameID string) bool {
txn := ds.db.Txn(false)
defer txn.Abort()
raw, err := txn.First("games", "id", gameID)
return err == nil && raw != nil
}
// Txn wraps a go-memdb write transaction.
// Usage: txn := ds.BeginTxn(); defer txn.Abort(); ...; txn.Commit()
type Txn struct {
txn *memdb.Txn
}
func (ds *DataStore) BeginTxn() *Txn {
return &Txn{txn: ds.db.Txn(true)}
}
func (t *Txn) GetGame(gameID string) (*GameState, error) {
raw, err := t.txn.First("games", "id", gameID)
if err != nil {
return nil, fmt.Errorf("failed to get game %s: %w", gameID, err)
}
if raw == nil {
return nil, fmt.Errorf("game %s not found", gameID)
}
return raw.(*GameState), nil
}
func (t *Txn) InsertGame(state *GameState) error {
if state == nil {
return fmt.Errorf("game state cannot be nil")
}
if err := t.txn.Insert("games", state); err != nil {
return fmt.Errorf("failed to insert game %s: %w", state.ID, err)
}
return nil
}
func (t *Txn) DeleteGame(gameID string) error {
raw, err := t.txn.First("games", "id", gameID)
if err != nil {
return fmt.Errorf("failed to find game %s for deletion: %w", gameID, err)
}
if raw == nil {
return fmt.Errorf("game %s not found", gameID)
}
if err := t.txn.Delete("games", raw); err != nil {
return fmt.Errorf("failed to delete game %s: %w", gameID, err)
}
return nil
}
func (t *Txn) GetSnapshot(gameID string) (*GameSnapshot, error) {
raw, err := t.txn.First("snapshots", "id", gameID)
if err != nil {
return nil, fmt.Errorf("failed to get snapshot for game %s: %w", gameID, err)
}
if raw == nil {
return nil, nil // No snapshot yet is not an error
}
return raw.(*GameSnapshot), nil
}
func (t *Txn) InsertSnapshot(snapshot *GameSnapshot) error {
if err := t.txn.Insert("snapshots", snapshot); err != nil {
return fmt.Errorf("failed to insert snapshot: %w", err)
}
return nil
}
func (t *Txn) GetDiffLog(gameID string) (*DiffLog, error) {
raw, err := t.txn.First("difflogs", "id", gameID)
if err != nil {
return nil, fmt.Errorf("failed to get diff log for game %s: %w", gameID, err)
}
if raw == nil {
return nil, nil // No diff log yet is not an error
}
return raw.(*DiffLog), nil
}
func (t *Txn) InsertDiffLog(diffLog *DiffLog) error {
if err := t.txn.Insert("difflogs", diffLog); err != nil {
return fmt.Errorf("failed to insert diff log: %w", err)
}
return nil
}
func (t *Txn) Commit() {
t.txn.Commit()
}
// Abort discards the transaction. Safe to call after Commit (no-op).
func (t *Txn) Abort() {
t.txn.Abort()
}
package datastore
import (
"fmt"
"sync"
"terraforming-mars-backend/internal/events"
)
// Runtime holds per-game non-serializable state outside memdb.
type Runtime struct {
EventBus *events.EventBusImpl
}
// RuntimeManager manages per-game runtime state outside of memdb.
type RuntimeManager struct {
mu sync.RWMutex
runtimes map[string]*Runtime
}
func NewRuntimeManager() *RuntimeManager {
return &RuntimeManager{
runtimes: make(map[string]*Runtime),
}
}
func (rm *RuntimeManager) Get(gameID string) *Runtime {
rm.mu.RLock()
defer rm.mu.RUnlock()
return rm.runtimes[gameID]
}
// GetOrCreate returns the runtime for a game, creating one if it doesn't exist.
func (rm *RuntimeManager) GetOrCreate(gameID string) *Runtime {
rm.mu.Lock()
defer rm.mu.Unlock()
if r, ok := rm.runtimes[gameID]; ok {
return r
}
r := &Runtime{
EventBus: events.NewEventBus(),
}
rm.runtimes[gameID] = r
return r
}
// Create creates a new runtime for a game. Returns error if one already exists.
func (rm *RuntimeManager) Create(gameID string) (*Runtime, error) {
rm.mu.Lock()
defer rm.mu.Unlock()
if _, ok := rm.runtimes[gameID]; ok {
return nil, fmt.Errorf("runtime already exists for game %s", gameID)
}
r := &Runtime{
EventBus: events.NewEventBus(),
}
rm.runtimes[gameID] = r
return r, nil
}
// Register stores an externally-created EventBus for a game, replacing any existing one.
func (rm *RuntimeManager) Register(gameID string, eventBus *events.EventBusImpl) {
rm.mu.Lock()
defer rm.mu.Unlock()
rm.runtimes[gameID] = &Runtime{EventBus: eventBus}
}
func (rm *RuntimeManager) Delete(gameID string) {
rm.mu.Lock()
defer rm.mu.Unlock()
delete(rm.runtimes, gameID)
}
package datastore
import (
"fmt"
"github.com/hashicorp/go-memdb"
"terraforming-mars-backend/internal/game/shared"
)
func createSchema() *memdb.DBSchema {
return &memdb.DBSchema{
Tables: map[string]*memdb.TableSchema{
"games": {
Name: "games",
Indexes: map[string]*memdb.IndexSchema{
"id": {
Name: "id",
Unique: true,
Indexer: &memdb.StringFieldIndex{Field: "ID"},
},
"status": {
Name: "status",
Unique: false,
AllowMissing: true,
Indexer: &gameStatusIndexer{},
},
},
},
"snapshots": {
Name: "snapshots",
Indexes: map[string]*memdb.IndexSchema{
"id": {
Name: "id",
Unique: true,
Indexer: &memdb.StringFieldIndex{Field: "GameID"},
},
},
},
"difflogs": {
Name: "difflogs",
Indexes: map[string]*memdb.IndexSchema{
"id": {
Name: "id",
Unique: true,
Indexer: &memdb.StringFieldIndex{Field: "GameID"},
},
},
},
"game_history": {
Name: "game_history",
Indexes: map[string]*memdb.IndexSchema{
"id": {
Name: "id",
Unique: true,
Indexer: &historyEntryIDIndexer{},
},
"game_id": {
Name: "game_id",
Unique: false,
Indexer: &memdb.StringFieldIndex{Field: "GameID"},
},
},
},
},
}
}
type historyEntryIDIndexer struct{}
func (h *historyEntryIDIndexer) FromObject(raw any) (bool, []byte, error) {
entry, ok := raw.(*GameStateHistoryEntry)
if !ok {
return false, nil, fmt.Errorf("expected *GameStateHistoryEntry, got %T", raw)
}
key := fmt.Sprintf("%s:%012d", entry.GameID, entry.Sequence)
return true, []byte(key + "\x00"), nil
}
func (h *historyEntryIDIndexer) FromArgs(args ...any) ([]byte, error) {
if len(args) != 1 {
return nil, fmt.Errorf("must provide exactly one argument")
}
key, ok := args[0].(string)
if !ok {
return nil, fmt.Errorf("argument must be a string")
}
return []byte(key + "\x00"), nil
}
type gameStatusIndexer struct{}
func (g *gameStatusIndexer) FromObject(raw any) (bool, []byte, error) {
gs, ok := raw.(*GameState)
if !ok {
return false, nil, nil
}
val := string(gs.Status) + "\x00"
return true, []byte(val), nil
}
func (g *gameStatusIndexer) FromArgs(args ...any) ([]byte, error) {
if len(args) != 1 {
return nil, fmt.Errorf("must provide exactly one argument")
}
switch v := args[0].(type) {
case string:
return []byte(v + "\x00"), nil
case shared.GameStatus:
return []byte(string(v) + "\x00"), nil
default:
return nil, fmt.Errorf("argument must be a string or GameStatus: %#v", args[0])
}
}
package deck
import (
"context"
"fmt"
"math/rand"
"go.uber.org/zap"
"terraforming-mars-backend/internal/game/datastore"
"terraforming-mars-backend/internal/logger"
)
type Deck struct {
ds *datastore.DataStore
gameID string
}
func NewDeck(ds *datastore.DataStore, gameID string, projectCardIDs, corpIDs, preludeIDs []string) *Deck {
projectCopy := make([]string, len(projectCardIDs))
copy(projectCopy, projectCardIDs)
rand.Shuffle(len(projectCopy), func(i, j int) { projectCopy[i], projectCopy[j] = projectCopy[j], projectCopy[i] })
corpCopy := make([]string, len(corpIDs))
copy(corpCopy, corpIDs)
rand.Shuffle(len(corpCopy), func(i, j int) { corpCopy[i], corpCopy[j] = corpCopy[j], corpCopy[i] })
preludeCopy := make([]string, len(preludeIDs))
copy(preludeCopy, preludeIDs)
rand.Shuffle(len(preludeCopy), func(i, j int) { preludeCopy[i], preludeCopy[j] = preludeCopy[j], preludeCopy[i] })
if err := ds.UpdateGame(gameID, func(s *datastore.GameState) {
s.ProjectCards = projectCopy
s.Corporations = corpCopy
s.PreludeCards = preludeCopy
s.DiscardPile = make([]string, 0)
s.RemovedCards = make([]string, 0)
s.DrawnCardCount = 0
s.ShuffleCount = 0
}); err != nil {
logger.Get().Error("Failed to initialize deck state", zap.String("game_id", gameID), zap.Error(err))
}
return &Deck{ds: ds, gameID: gameID}
}
func NewDeckView(ds *datastore.DataStore, gameID string) *Deck {
return &Deck{ds: ds, gameID: gameID}
}
func (d *Deck) update(fn func(s *datastore.GameState)) {
if err := d.ds.UpdateGame(d.gameID, fn); err != nil {
logger.Get().Warn("Failed to update game state", zap.String("game_id", d.gameID), zap.Error(err))
}
}
func (d *Deck) read(fn func(s *datastore.GameState)) {
if err := d.ds.ReadGame(d.gameID, fn); err != nil {
logger.Get().Warn("Failed to read game state", zap.String("game_id", d.gameID), zap.Error(err))
}
}
func (d *Deck) GameID() string {
return d.gameID
}
func (d *Deck) ProjectCards() []string {
var result []string
d.read(func(s *datastore.GameState) {
result = make([]string, len(s.ProjectCards))
copy(result, s.ProjectCards)
})
return result
}
func (d *Deck) Corporations() []string {
var result []string
d.read(func(s *datastore.GameState) {
result = make([]string, len(s.Corporations))
copy(result, s.Corporations)
})
return result
}
func (d *Deck) DiscardPile() []string {
var result []string
d.read(func(s *datastore.GameState) {
result = make([]string, len(s.DiscardPile))
copy(result, s.DiscardPile)
})
return result
}
func (d *Deck) RemovedCards() []string {
var result []string
d.read(func(s *datastore.GameState) {
result = make([]string, len(s.RemovedCards))
copy(result, s.RemovedCards)
})
return result
}
func (d *Deck) PreludeCards() []string {
var result []string
d.read(func(s *datastore.GameState) {
result = make([]string, len(s.PreludeCards))
copy(result, s.PreludeCards)
})
return result
}
func (d *Deck) DrawnCardCount() int {
var v int
d.read(func(s *datastore.GameState) { v = s.DrawnCardCount })
return v
}
func (d *Deck) ShuffleCount() int {
var v int
d.read(func(s *datastore.GameState) { v = s.ShuffleCount })
return v
}
func (d *Deck) GetAvailableCardCount() int {
var v int
d.read(func(s *datastore.GameState) { v = len(s.ProjectCards) })
return v
}
func (d *Deck) DrawProjectCards(ctx context.Context, count int) ([]string, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
var drawn []string
var drawErr error
d.update(func(s *datastore.GameState) {
available := len(s.ProjectCards)
if count > available {
shuffleDeck(s)
available = len(s.ProjectCards)
if count > available {
drawErr = fmt.Errorf("not enough cards available: requested %d, have %d", count, available)
return
}
}
drawn = make([]string, count)
copy(drawn, s.ProjectCards[:count])
s.ProjectCards = s.ProjectCards[count:]
s.DrawnCardCount += count
})
return drawn, drawErr
}
func (d *Deck) DrawProjectCardsUntilMatching(ctx context.Context, count int, matcher func(cardID string) bool) (matched []string, discarded []string, err error) {
if err := ctx.Err(); err != nil {
return nil, nil, err
}
d.update(func(s *datastore.GameState) {
for len(matched) < count {
if len(s.ProjectCards) == 0 {
shuffleDeck(s)
if len(s.ProjectCards) == 0 {
break
}
}
cardID := s.ProjectCards[0]
s.ProjectCards = s.ProjectCards[1:]
s.DrawnCardCount++
if matcher(cardID) {
matched = append(matched, cardID)
} else {
discarded = append(discarded, cardID)
}
}
})
return matched, discarded, nil
}
func (d *Deck) DrawCorporations(ctx context.Context, count int) ([]string, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
var drawn []string
var drawErr error
d.update(func(s *datastore.GameState) {
available := len(s.Corporations)
if count > available {
drawErr = fmt.Errorf("not enough corporations available: requested %d, have %d", count, available)
return
}
drawn = make([]string, count)
copy(drawn, s.Corporations[:count])
s.Corporations = s.Corporations[count:]
})
return drawn, drawErr
}
func (d *Deck) DrawPreludeCards(ctx context.Context, count int) ([]string, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
var drawn []string
var drawErr error
d.update(func(s *datastore.GameState) {
available := len(s.PreludeCards)
if count > available {
drawErr = fmt.Errorf("not enough prelude cards available: requested %d, have %d", count, available)
return
}
drawn = make([]string, count)
copy(drawn, s.PreludeCards[:count])
s.PreludeCards = s.PreludeCards[count:]
})
return drawn, drawErr
}
func (d *Deck) Discard(ctx context.Context, cardIDs []string) error {
if err := ctx.Err(); err != nil {
return err
}
d.update(func(s *datastore.GameState) {
s.DiscardPile = append(s.DiscardPile, cardIDs...)
})
return nil
}
func (d *Deck) Remove(ctx context.Context, cardIDs []string) error {
if err := ctx.Err(); err != nil {
return err
}
d.update(func(s *datastore.GameState) {
s.RemovedCards = append(s.RemovedCards, cardIDs...)
})
return nil
}
func shuffleDeck(s *datastore.GameState) {
s.ProjectCards = append(s.ProjectCards, s.DiscardPile...)
rand.Shuffle(len(s.ProjectCards), func(i, j int) { s.ProjectCards[i], s.ProjectCards[j] = s.ProjectCards[j], s.ProjectCards[i] })
s.DiscardPile = make([]string, 0)
s.ShuffleCount++
}
func (d *Deck) Shuffle(ctx context.Context) error {
if err := ctx.Err(); err != nil {
return err
}
d.update(func(s *datastore.GameState) {
shuffleDeck(s)
})
return nil
}
package game
import (
"context"
"fmt"
"slices"
"strings"
"sync"
"time"
"go.uber.org/zap"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game/board"
"terraforming-mars-backend/internal/game/colonies"
"terraforming-mars-backend/internal/game/datastore"
"terraforming-mars-backend/internal/game/deck"
"terraforming-mars-backend/internal/game/global_parameters"
"terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/projectfunding"
"terraforming-mars-backend/internal/game/shared"
"terraforming-mars-backend/internal/logger"
)
type VPCardInfo struct {
CardID string
CardName string
CardType string
Description string
VPConditions []shared.VPCondition
Tags []shared.CardTag
}
type VPCardLookup interface {
LookupVPCard(cardID string) (*VPCardInfo, error)
}
type gameVPRecalculationContext struct {
game *Game
}
func (ctx *gameVPRecalculationContext) GetCardStorage(playerID string, cardID string) int {
p, err := ctx.game.GetPlayer(playerID)
if err != nil {
return 0
}
return p.Resources().GetCardStorage(cardID)
}
func (ctx *gameVPRecalculationContext) CountPlayerTagsByType(playerID string, tagType shared.CardTag) int {
p, err := ctx.game.GetPlayer(playerID)
if err != nil {
return 0
}
count := 0
if ctx.game.vpCardLookup == nil {
return 0
}
for _, cardID := range p.PlayedCards().Cards() {
cardInfo, err := ctx.game.vpCardLookup.LookupVPCard(cardID)
if err != nil {
continue
}
if cardInfo.CardType == "event" && tagType != shared.TagEvent {
continue
}
for _, tag := range cardInfo.Tags {
if tag == tagType || tag == shared.TagWild {
count++
}
}
}
return count
}
func (ctx *gameVPRecalculationContext) CountAllTilesOfType(tileType shared.ResourceType) int {
tiles := ctx.game.board.Tiles()
count := 0
for _, tile := range tiles {
if tile.OccupiedBy != nil && tile.OccupiedBy.Type == tileType {
count++
}
}
return count
}
func (ctx *gameVPRecalculationContext) CountPlayerTilesOfType(playerID string, tileType shared.ResourceType) int {
tiles := ctx.game.board.Tiles()
count := 0
for _, tile := range tiles {
if tile.OccupiedBy != nil && tile.OccupiedBy.Type == tileType &&
tile.OwnerID != nil && *tile.OwnerID == playerID {
count++
}
}
return count
}
func (ctx *gameVPRecalculationContext) CountAdjacentTilesForCard(cardID string, tileType shared.ResourceType) int {
tiles := ctx.game.board.Tiles()
sourceTag := "source:" + cardID
var sourceTile *board.Tile
for i := range tiles {
if tiles[i].OccupiedBy == nil {
continue
}
if slices.Contains(tiles[i].OccupiedBy.Tags, sourceTag) {
sourceTile = &tiles[i]
break
}
}
if sourceTile == nil {
return 0
}
neighbors := sourceTile.Coordinates.GetNeighbors()
count := 0
for _, tile := range tiles {
if tile.OccupiedBy == nil || tile.OccupiedBy.Type != tileType {
continue
}
if slices.Contains(neighbors, tile.Coordinates) {
count++
}
}
return count
}
func (ctx *gameVPRecalculationContext) CountAdjacentTilesToTileType(playerID string, countType, adjacentToType shared.ResourceType) int {
tiles := ctx.game.board.Tiles()
tilesByCoord := make(map[shared.HexPosition]*board.Tile, len(tiles))
for i := range tiles {
tilesByCoord[tiles[i].Coordinates] = &tiles[i]
}
counted := make(map[shared.HexPosition]bool)
for _, tile := range tiles {
if tile.OwnerID == nil || *tile.OwnerID != playerID {
continue
}
if tile.OccupiedBy == nil || tile.OccupiedBy.Type != adjacentToType {
continue
}
for _, neighborCoord := range tile.Coordinates.GetNeighbors() {
neighborTile, exists := tilesByCoord[neighborCoord]
if !exists || neighborTile.OccupiedBy == nil || counted[neighborCoord] {
continue
}
var matches bool
if countType == shared.ResourceGreeneryTile {
matches = neighborTile.OccupiedBy.Type == shared.ResourceGreeneryTile || neighborTile.OccupiedBy.Type == shared.ResourceWorldTreeTile
} else {
matches = neighborTile.OccupiedBy.Type == countType
}
if matches {
counted[neighborCoord] = true
}
}
}
return len(counted)
}
func (ctx *gameVPRecalculationContext) CountAllColonies() int {
return ctx.game.Colonies().CountAllColonies()
}
type Game struct {
mu sync.RWMutex
ds *datastore.DataStore
id string
globalParameters *global_parameters.GlobalParameters
currentTurn *Turn
board *board.Board
colonies *colonies.Colonies
deck *deck.Deck
players map[string]*player.Player
eventBus *events.EventBusImpl
milestones *Milestones
awards *Awards
vpCardLookup VPCardLookup
}
func (g *Game) update(fn func(s *datastore.GameState)) {
if err := g.ds.UpdateGame(g.id, fn); err != nil {
logger.Get().Warn("Failed to update game state", zap.String("game_id", g.id), zap.Error(err))
}
}
func (g *Game) read(fn func(s *datastore.GameState)) {
if err := g.ds.ReadGame(g.id, fn); err != nil {
logger.Get().Warn("Failed to read game state", zap.String("game_id", g.id), zap.Error(err))
}
}
// NewGame creates a new game with the given settings.
// The DataStore is used as the single point of entry for all state reads/writes.
func NewGame(
ds *datastore.DataStore,
id string,
hostPlayerID string,
settings shared.GameSettings,
) *Game {
now := time.Now()
eventBus := events.NewEventBus()
initTemp := DefaultTemperature
initOxy := DefaultOxygen
initOcean := DefaultOceans
initVenus := DefaultVenus
if settings.Temperature != nil {
initTemp = *settings.Temperature
}
if settings.Oxygen != nil {
initOxy = *settings.Oxygen
}
if settings.Oceans != nil {
initOcean = *settings.Oceans
}
if settings.Venus != nil {
initVenus = *settings.Venus
}
state := &datastore.GameState{
ID: id,
CreatedAt: now,
UpdatedAt: now,
Status: shared.GameStatusLobby,
Settings: settings,
HostPlayerID: hostPlayerID,
CurrentPhase: shared.GamePhaseWaitingForGameStart,
Generation: 1,
Temperature: initTemp,
Oxygen: initOxy,
Oceans: initOcean,
MaxOceans: global_parameters.MaxOceans,
Venus: initVenus,
PlayerOrder: []string{},
TurnOrder: []string{},
Players: make(map[string]*datastore.PlayerState),
ClaimedMilestones: []shared.ClaimedMilestone{},
FundedAwards: []shared.FundedAward{},
SelectedMilestones: []string{},
SelectedAwards: []string{},
Spectators: make(map[string]*shared.SpectatorState),
ChatMessages: []shared.ChatMessage{},
PendingTileSelections: make(map[string]*shared.PendingTileSelection),
PendingTileSelectionQueues: make(map[string]*shared.PendingTileSelectionQueue),
ForcedFirstActions: make(map[string]*shared.ForcedFirstAction),
ProductionPhases: make(map[string]*shared.ProductionPhase),
SelectCorporationPhases: make(map[string]*shared.SelectCorporationPhase),
SelectStartingCardsPhases: make(map[string]*shared.SelectStartingCardsPhase),
SelectPreludeCardsPhases: make(map[string]*shared.SelectPreludeCardsPhase),
DeferredStartingChoices: make(map[string]*shared.DeferredStartingChoices),
TradeFleets: make(map[string]bool),
}
// Insert state into DataStore so components can read/write through it
txn := ds.BeginTxn()
if err := txn.InsertGame(state); err != nil {
logger.Get().Error("Failed to insert game state", zap.String("game_id", id), zap.Error(err))
}
txn.Commit()
ds.RecordInitialHistory(state)
g := &Game{
ds: ds,
id: id,
globalParameters: global_parameters.NewGlobalParameters(ds, id, eventBus),
board: board.NewBoardWithTiles(&state.Tiles, id, board.GenerateMarsBoard(settings.VenusNextEnabled), eventBus),
colonies: colonies.NewColonies(ds, id, eventBus),
players: make(map[string]*player.Player),
eventBus: eventBus,
milestones: NewMilestones(ds, id, eventBus),
awards: NewAwards(ds, id, eventBus),
}
g.subscribeToGenerationalEvents()
g.subscribeToOceanSpaceEvents()
g.subscribeToGlobalParameterBonuses()
return g
}
// ID returns the game ID
func (g *Game) ID() string {
return g.id
}
func (g *Game) CreatedAt() time.Time {
var v time.Time
g.read(func(s *datastore.GameState) { v = s.CreatedAt })
return v
}
func (g *Game) UpdatedAt() time.Time {
var v time.Time
g.read(func(s *datastore.GameState) { v = s.UpdatedAt })
return v
}
func (g *Game) Status() shared.GameStatus {
var v shared.GameStatus
g.read(func(s *datastore.GameState) { v = s.Status })
return v
}
func (g *Game) Settings() shared.GameSettings {
var v shared.GameSettings
g.read(func(s *datastore.GameState) { v = s.Settings })
return v
}
func (g *Game) UpdateSettings(ctx context.Context, settings shared.GameSettings) {
g.update(func(s *datastore.GameState) {
s.Settings = settings
s.UpdatedAt = time.Now()
})
}
func (g *Game) HostPlayerID() string {
var v string
g.read(func(s *datastore.GameState) { v = s.HostPlayerID })
return v
}
func (g *Game) State() *datastore.GameState {
state, _ := g.ds.GetGame(g.id)
return state
}
// EventBus returns the event bus for publishing domain events
func (g *Game) EventBus() *events.EventBusImpl {
return g.eventBus
}
func (g *Game) CurrentPhase() shared.GamePhase {
var v shared.GamePhase
g.read(func(s *datastore.GameState) { v = s.CurrentPhase })
return v
}
func (g *Game) Generation() int {
var v int
g.read(func(s *datastore.GameState) { v = s.Generation })
return v
}
func (g *Game) PlayerOrder() []string {
var order []string
g.read(func(s *datastore.GameState) {
order = make([]string, len(s.PlayerOrder))
copy(order, s.PlayerOrder)
})
return order
}
func (g *Game) TurnOrder() []string {
var order []string
g.read(func(s *datastore.GameState) {
order = make([]string, len(s.TurnOrder))
copy(order, s.TurnOrder)
})
return order
}
func (g *Game) CurrentTurn() *Turn {
g.mu.RLock()
defer g.mu.RUnlock()
return g.currentTurn
}
func (g *Game) GlobalParameters() *global_parameters.GlobalParameters {
return g.globalParameters
}
func (g *Game) Board() *board.Board {
return g.board
}
func (g *Game) Colonies() *colonies.Colonies {
return g.colonies
}
func (g *Game) Deck() *deck.Deck {
g.mu.RLock()
defer g.mu.RUnlock()
return g.deck
}
func (g *Game) SetDeck(d *deck.Deck) {
g.mu.Lock()
defer g.mu.Unlock()
g.deck = d
g.update(func(s *datastore.GameState) { s.UpdatedAt = time.Now() })
}
func (g *Game) InitDeck(projectCardIDs, corpIDs, preludeIDs []string) {
g.mu.Lock()
defer g.mu.Unlock()
g.deck = deck.NewDeck(g.ds, g.id, projectCardIDs, corpIDs, preludeIDs)
g.update(func(s *datastore.GameState) { s.UpdatedAt = time.Now() })
}
func (g *Game) Milestones() *Milestones {
return g.milestones
}
func (g *Game) Awards() *Awards {
return g.awards
}
// SelectedMilestones returns the milestone IDs selected for this game.
func (g *Game) SelectedMilestones() []string {
var result []string
g.read(func(s *datastore.GameState) {
result = make([]string, len(s.SelectedMilestones))
copy(result, s.SelectedMilestones)
})
return result
}
// SelectedAwards returns the award IDs selected for this game.
func (g *Game) SelectedAwards() []string {
var result []string
g.read(func(s *datastore.GameState) {
result = make([]string, len(s.SelectedAwards))
copy(result, s.SelectedAwards)
})
return result
}
// SetSelectedMilestones sets the milestone IDs available for this game.
func (g *Game) SetSelectedMilestones(milestoneIDs []string) {
g.update(func(s *datastore.GameState) {
s.SelectedMilestones = make([]string, len(milestoneIDs))
copy(s.SelectedMilestones, milestoneIDs)
})
}
// SetSelectedAwards sets the award IDs available for this game.
func (g *Game) SetSelectedAwards(awardIDs []string) {
g.update(func(s *datastore.GameState) {
s.SelectedAwards = make([]string, len(awardIDs))
copy(s.SelectedAwards, awardIDs)
})
}
func (g *Game) GetFinalScores() []shared.FinalScore {
var result []shared.FinalScore
g.read(func(s *datastore.GameState) {
if s.FinalScores == nil {
return
}
result = make([]shared.FinalScore, len(s.FinalScores))
copy(result, s.FinalScores)
})
return result
}
func (g *Game) GetWinnerID() string {
var v string
g.read(func(s *datastore.GameState) { v = s.WinnerID })
return v
}
func (g *Game) IsTie() bool {
var v bool
g.read(func(s *datastore.GameState) { v = s.IsTie })
return v
}
func (g *Game) GetPlayer(playerID string) (*player.Player, error) {
g.mu.RLock()
defer g.mu.RUnlock()
p, exists := g.players[playerID]
if !exists {
return nil, fmt.Errorf("player %s not found in game %s", playerID, g.id)
}
return p, nil
}
func (g *Game) GetAllPlayers() []*player.Player {
g.mu.RLock()
defer g.mu.RUnlock()
playerOrder := g.PlayerOrder()
players := make([]*player.Player, 0, len(playerOrder))
for _, id := range playerOrder {
if p, exists := g.players[id]; exists {
players = append(players, p)
}
}
return players
}
// AddNewPlayer creates a new human player backed by this game's state and adds them to the game
func (g *Game) AddNewPlayer(ctx context.Context, playerID, playerName string) (*player.Player, error) {
if err := g.ds.UpdateGame(g.id, func(s *datastore.GameState) {
s.Players[playerID] = &datastore.PlayerState{
ID: playerID,
Name: playerName,
Connected: true,
PlayerType: "human",
TerraformRating: 20,
HandCardIDs: []string{},
PlayedCardIDs: []string{},
ResourceStorage: make(map[string]int),
BonusTags: make(map[shared.CardTag]int),
GenerationalEvents: make(map[shared.GenerationalEvent]int),
}
}); err != nil {
logger.Get().Error("Failed to add player to game state", zap.String("game_id", g.id), zap.String("player_id", playerID), zap.Error(err))
}
p := player.NewPlayer(g.ds, g.id, playerID, g.eventBus)
if err := g.AddPlayer(ctx, p); err != nil {
return nil, err
}
return p, nil
}
// AddNewBotPlayer creates a new bot player backed by this game's state and adds them to the game
func (g *Game) AddNewBotPlayer(ctx context.Context, botID, botName string, difficulty player.BotDifficulty, speed player.BotSpeed) (*player.Player, error) {
if err := g.ds.UpdateGame(g.id, func(s *datastore.GameState) {
s.Players[botID] = &datastore.PlayerState{
ID: botID,
Name: botName,
Connected: false,
PlayerType: "bot",
BotStatus: string(player.BotStatusLoading),
BotDifficulty: string(difficulty),
BotSpeed: string(speed),
TerraformRating: 20,
HandCardIDs: []string{},
PlayedCardIDs: []string{},
ResourceStorage: make(map[string]int),
BonusTags: make(map[shared.CardTag]int),
GenerationalEvents: make(map[shared.GenerationalEvent]int),
}
}); err != nil {
logger.Get().Error("Failed to add bot player to game state", zap.String("game_id", g.id), zap.String("bot_id", botID), zap.Error(err))
}
p := player.NewPlayer(g.ds, g.id, botID, g.eventBus)
if err := g.AddPlayer(ctx, p); err != nil {
return nil, err
}
return p, nil
}
func (g *Game) AddPlayer(ctx context.Context, p *player.Player) error {
if err := ctx.Err(); err != nil {
return err
}
g.mu.Lock()
if _, exists := g.players[p.ID()]; exists {
g.mu.Unlock()
return fmt.Errorf("player %s already exists in game %s", p.ID(), g.id)
}
if p.Color() == "" {
taken := make(map[string]bool, len(g.players))
for _, existing := range g.players {
if existing.Color() != "" {
taken[existing.Color()] = true
}
}
for _, c := range shared.PlayerColors {
if !taken[c] {
p.SetColor(c)
break
}
}
}
g.players[p.ID()] = p
g.update(func(s *datastore.GameState) {
s.PlayerOrder = append(s.PlayerOrder, p.ID())
s.UpdatedAt = time.Now()
})
g.mu.Unlock()
if g.eventBus != nil {
events.Publish(g.eventBus, events.PlayerJoinedEvent{
GameID: g.id,
PlayerID: p.ID(),
})
}
return nil
}
func (g *Game) RemovePlayer(ctx context.Context, playerID string) error {
if err := ctx.Err(); err != nil {
return err
}
g.mu.Lock()
if _, exists := g.players[playerID]; !exists {
g.mu.Unlock()
return fmt.Errorf("player %s not found in game %s", playerID, g.id)
}
delete(g.players, playerID)
g.update(func(s *datastore.GameState) {
for i, id := range s.PlayerOrder {
if id == playerID {
s.PlayerOrder = append(s.PlayerOrder[:i], s.PlayerOrder[i+1:]...)
break
}
}
s.UpdatedAt = time.Now()
})
g.mu.Unlock()
return nil
}
func (g *Game) AddSpectator(ctx context.Context, s *Spectator) error {
if err := ctx.Err(); err != nil {
return err
}
var addErr error
g.update(func(state *datastore.GameState) {
if len(state.Spectators) >= shared.MaxSpectators {
addErr = fmt.Errorf("game %s already has the maximum number of spectators (%d)", g.id, shared.MaxSpectators)
return
}
if _, exists := state.Spectators[s.ID()]; exists {
addErr = fmt.Errorf("spectator %s already exists in game %s", s.ID(), g.id)
return
}
state.Spectators[s.ID()] = &shared.SpectatorState{
ID: s.ID(),
Name: s.Name(),
Color: s.Color(),
}
state.UpdatedAt = time.Now()
})
return addErr
}
func (g *Game) RemoveSpectator(ctx context.Context, spectatorID string) error {
if err := ctx.Err(); err != nil {
return err
}
var removeErr error
g.update(func(state *datastore.GameState) {
if _, exists := state.Spectators[spectatorID]; !exists {
removeErr = fmt.Errorf("spectator %s not found in game %s", spectatorID, g.id)
return
}
delete(state.Spectators, spectatorID)
state.UpdatedAt = time.Now()
})
return removeErr
}
func (g *Game) GetSpectator(spectatorID string) (*Spectator, error) {
var result *Spectator
var getErr error
g.read(func(state *datastore.GameState) {
ss, exists := state.Spectators[spectatorID]
if !exists {
getErr = fmt.Errorf("spectator %s not found in game %s", spectatorID, g.id)
return
}
result = NewSpectator(ss.ID, ss.Name, ss.Color)
})
return result, getErr
}
func (g *Game) GetAllSpectators() []*Spectator {
var spectators []*Spectator
g.read(func(state *datastore.GameState) {
spectators = make([]*Spectator, 0, len(state.Spectators))
for _, ss := range state.Spectators {
spectators = append(spectators, NewSpectator(ss.ID, ss.Name, ss.Color))
}
})
return spectators
}
func (g *Game) SpectatorCount() int {
var count int
g.read(func(state *datastore.GameState) { count = len(state.Spectators) })
return count
}
func (g *Game) NextSpectatorColor() string {
var color string
g.read(func(state *datastore.GameState) {
idx := len(state.Spectators) % len(shared.SpectatorColors)
color = shared.SpectatorColors[idx]
})
return color
}
// IsPlayerColorAvailable returns true if the given color is in the palette and
// not taken by another player (excluding the specified player).
func (g *Game) IsPlayerColorAvailable(color string, excludePlayerID string) bool {
if !slices.Contains(shared.PlayerColors, color) {
return false
}
g.mu.RLock()
defer g.mu.RUnlock()
for _, p := range g.players {
if p.ID() != excludePlayerID && p.Color() == color {
return false
}
}
return true
}
func (g *Game) AddChatMessage(ctx context.Context, msg shared.ChatMessage) {
if ctx.Err() != nil {
return
}
g.update(func(s *datastore.GameState) {
s.ChatMessages = append(s.ChatMessages, msg)
if len(s.ChatMessages) > shared.MaxChatMessages {
s.ChatMessages = s.ChatMessages[len(s.ChatMessages)-shared.MaxChatMessages:]
}
s.UpdatedAt = time.Now()
})
}
func (g *Game) GetChatMessages() []shared.ChatMessage {
var msgs []shared.ChatMessage
g.read(func(s *datastore.GameState) {
msgs = make([]shared.ChatMessage, len(s.ChatMessages))
copy(msgs, s.ChatMessages)
})
return msgs
}
func (g *Game) UpdateStatus(ctx context.Context, newStatus shared.GameStatus) error {
if err := ctx.Err(); err != nil {
return err
}
var oldStatus shared.GameStatus
g.update(func(s *datastore.GameState) {
oldStatus = s.Status
s.Status = newStatus
s.UpdatedAt = time.Now()
})
if g.eventBus != nil && oldStatus != newStatus {
events.Publish(g.eventBus, events.GameStatusChangedEvent{
GameID: g.id,
OldStatus: string(oldStatus),
NewStatus: string(newStatus),
})
}
return nil
}
func (g *Game) SetFinalScores(ctx context.Context, scores []shared.FinalScore, winnerID string, isTie bool) error {
if err := ctx.Err(); err != nil {
return err
}
g.update(func(s *datastore.GameState) {
s.FinalScores = make([]shared.FinalScore, len(scores))
copy(s.FinalScores, scores)
s.WinnerID = winnerID
s.IsTie = isTie
s.UpdatedAt = time.Now()
})
if g.eventBus != nil {
events.Publish(g.eventBus, events.GameStateChangedEvent{
GameID: g.id,
Timestamp: time.Now(),
})
}
return nil
}
func (g *Game) UpdatePhase(ctx context.Context, newPhase shared.GamePhase) error {
if err := ctx.Err(); err != nil {
return err
}
var oldPhase shared.GamePhase
g.update(func(s *datastore.GameState) {
oldPhase = s.CurrentPhase
s.CurrentPhase = newPhase
s.UpdatedAt = time.Now()
})
if g.eventBus != nil && oldPhase != newPhase {
events.Publish(g.eventBus, events.GamePhaseChangedEvent{
GameID: g.id,
OldPhase: string(oldPhase),
NewPhase: string(newPhase),
})
}
return nil
}
func (g *Game) AdvanceGeneration(ctx context.Context) error {
if err := ctx.Err(); err != nil {
return err
}
var oldGeneration, newGeneration int
g.update(func(s *datastore.GameState) {
oldGeneration = s.Generation
s.Generation++
newGeneration = s.Generation
s.UpdatedAt = time.Now()
})
if g.eventBus != nil {
events.Publish(g.eventBus, events.GenerationAdvancedEvent{
GameID: g.id,
OldGeneration: oldGeneration,
NewGeneration: newGeneration,
})
}
return nil
}
func (g *Game) SetGeneration(ctx context.Context, generation int) error {
if err := ctx.Err(); err != nil {
return err
}
var oldGeneration, newGeneration int
g.update(func(s *datastore.GameState) {
oldGeneration = s.Generation
s.Generation = generation
newGeneration = s.Generation
s.UpdatedAt = time.Now()
})
if g.eventBus != nil && oldGeneration != newGeneration {
events.Publish(g.eventBus, events.GenerationAdvancedEvent{
GameID: g.id,
OldGeneration: oldGeneration,
NewGeneration: newGeneration,
})
}
return nil
}
func (g *Game) SetCurrentTurn(ctx context.Context, playerID string, actionsRemaining int) error {
if err := ctx.Err(); err != nil {
return err
}
g.mu.Lock()
g.update(func(s *datastore.GameState) {
s.CurrentTurnPlayerID = playerID
s.CurrentTurnActions = actionsRemaining
s.CurrentTurnTotalActions = actionsRemaining
s.UpdatedAt = time.Now()
})
g.currentTurn = NewTurn(g.ds, g.id)
g.mu.Unlock()
if g.eventBus != nil {
events.Publish(g.eventBus, events.GameStateChangedEvent{
GameID: g.id,
Timestamp: time.Now(),
})
}
return nil
}
func (g *Game) SetTurnOrder(ctx context.Context, turnOrder []string) error {
if err := ctx.Err(); err != nil {
return err
}
g.update(func(s *datastore.GameState) {
s.TurnOrder = make([]string, len(turnOrder))
copy(s.TurnOrder, turnOrder)
s.UpdatedAt = time.Now()
})
if g.eventBus != nil {
events.Publish(g.eventBus, events.GameStateChangedEvent{
GameID: g.id,
Timestamp: time.Now(),
})
}
return nil
}
func (g *Game) SetHostPlayerID(ctx context.Context, playerID string) error {
if err := ctx.Err(); err != nil {
return err
}
g.update(func(s *datastore.GameState) {
s.HostPlayerID = playerID
s.UpdatedAt = time.Now()
})
return nil
}
func (g *Game) NextPlayer() *string {
g.mu.RLock()
defer g.mu.RUnlock()
turnOrder := g.TurnOrder()
if g.currentTurn == nil || len(turnOrder) == 0 {
return nil
}
currentPlayerID := g.currentTurn.PlayerID()
currentIndex := -1
for i, playerID := range turnOrder {
if playerID == currentPlayerID {
currentIndex = i
break
}
}
if currentIndex == -1 {
return &turnOrder[0]
}
nextIndex := (currentIndex + 1) % len(turnOrder)
return &turnOrder[nextIndex]
}
// HasAnyPendingSelection returns true if the player has any pending selection
// (tile placement, card storage, steal target, etc.) that blocks other actions.
func (g *Game) HasAnyPendingSelection(playerID string) bool {
if g.GetPendingTileSelection(playerID) != nil {
return true
}
p, err := g.GetPlayer(playerID)
if err != nil {
return false
}
return p.Selection().HasPendingSelection()
}
func (g *Game) GetPendingTileSelection(playerID string) *shared.PendingTileSelection {
var result *shared.PendingTileSelection
g.read(func(s *datastore.GameState) {
selection, exists := s.PendingTileSelections[playerID]
if !exists || selection == nil {
return
}
selectionCopy := *selection
result = &selectionCopy
})
return result
}
func (g *Game) SetPendingTileSelection(ctx context.Context, playerID string, selection *shared.PendingTileSelection) error {
if err := ctx.Err(); err != nil {
return err
}
g.update(func(s *datastore.GameState) {
if selection == nil {
delete(s.PendingTileSelections, playerID)
} else {
selectionCopy := *selection
s.PendingTileSelections[playerID] = &selectionCopy
}
s.UpdatedAt = time.Now()
})
if g.eventBus != nil {
events.Publish(g.eventBus, events.GameStateChangedEvent{
GameID: g.id,
Timestamp: time.Now(),
})
}
return nil
}
func (g *Game) GetPendingTileSelectionQueue(playerID string) *shared.PendingTileSelectionQueue {
var result *shared.PendingTileSelectionQueue
g.read(func(s *datastore.GameState) {
queue, exists := s.PendingTileSelectionQueues[playerID]
if !exists || queue == nil {
return
}
queueCopy := *queue
result = &queueCopy
})
return result
}
func (g *Game) SetPendingTileSelectionQueue(ctx context.Context, playerID string, queue *shared.PendingTileSelectionQueue) error {
if err := ctx.Err(); err != nil {
return err
}
g.update(func(s *datastore.GameState) {
if queue == nil {
delete(s.PendingTileSelectionQueues, playerID)
} else {
queueCopy := *queue
s.PendingTileSelectionQueues[playerID] = &queueCopy
}
s.UpdatedAt = time.Now()
})
if g.eventBus != nil {
events.Publish(g.eventBus, events.GameStateChangedEvent{
GameID: g.id,
Timestamp: time.Now(),
})
}
if queue != nil && len(queue.Items) > 0 {
if err := g.ProcessNextTile(ctx, playerID); err != nil {
return fmt.Errorf("failed to auto-process first queued tile: %w", err)
}
}
return nil
}
func (g *Game) SetTileQueueOnComplete(_ context.Context, playerID string, callback *shared.TileCompletionCallback) {
g.update(func(s *datastore.GameState) {
if queue, exists := s.PendingTileSelectionQueues[playerID]; exists && queue != nil {
queue.OnComplete = callback
}
if sel, exists := s.PendingTileSelections[playerID]; exists && sel != nil {
sel.OnComplete = callback
}
})
}
func (g *Game) AppendToPendingTileSelectionQueue(ctx context.Context, playerID string, tileTypes []string, source string, sourceCardID string, tileRestrictions *shared.TileRestrictions) error {
if err := ctx.Err(); err != nil {
return err
}
if len(tileTypes) == 0 {
return nil
}
wasEmpty := false
g.update(func(s *datastore.GameState) {
existingQueue, exists := s.PendingTileSelectionQueues[playerID]
var items []string
var queueSource string
var queueSourceCardID string
var queueTileRestrictions *shared.TileRestrictions
if exists && existingQueue != nil {
items = existingQueue.Items
queueSource = existingQueue.Source
queueSourceCardID = existingQueue.SourceCardID
queueTileRestrictions = existingQueue.TileRestrictions
} else {
items = []string{}
queueSource = source
queueSourceCardID = sourceCardID
queueTileRestrictions = tileRestrictions
wasEmpty = true
}
if exists && existingQueue != nil && len(existingQueue.Items) == 0 {
wasEmpty = true
}
items = append(items, tileTypes...)
s.PendingTileSelectionQueues[playerID] = &shared.PendingTileSelectionQueue{
Items: items,
TileRestrictions: queueTileRestrictions,
Source: queueSource,
SourceCardID: queueSourceCardID,
}
s.UpdatedAt = time.Now()
})
if g.eventBus != nil {
events.Publish(g.eventBus, events.GameStateChangedEvent{
GameID: g.id,
Timestamp: time.Now(),
})
}
if wasEmpty {
if err := g.ProcessNextTile(ctx, playerID); err != nil {
return fmt.Errorf("failed to auto-process first queued tile: %w", err)
}
}
return nil
}
func (g *Game) GetForcedFirstAction(playerID string) *shared.ForcedFirstAction {
var result *shared.ForcedFirstAction
g.read(func(s *datastore.GameState) {
action, exists := s.ForcedFirstActions[playerID]
if !exists || action == nil {
return
}
actionCopy := *action
result = &actionCopy
})
return result
}
func (g *Game) SetForcedFirstAction(ctx context.Context, playerID string, action *shared.ForcedFirstAction) error {
if err := ctx.Err(); err != nil {
return err
}
g.update(func(s *datastore.GameState) {
if action == nil {
delete(s.ForcedFirstActions, playerID)
} else {
actionCopy := *action
s.ForcedFirstActions[playerID] = &actionCopy
}
s.UpdatedAt = time.Now()
})
if g.eventBus != nil {
events.Publish(g.eventBus, events.GameStateChangedEvent{
GameID: g.id,
Timestamp: time.Now(),
})
}
return nil
}
func (g *Game) GetProductionPhase(playerID string) *shared.ProductionPhase {
var result *shared.ProductionPhase
g.read(func(s *datastore.GameState) {
phase, exists := s.ProductionPhases[playerID]
if !exists || phase == nil {
return
}
phaseCopy := *phase
result = &phaseCopy
})
return result
}
func (g *Game) SetProductionPhase(ctx context.Context, playerID string, phase *shared.ProductionPhase) error {
if err := ctx.Err(); err != nil {
return err
}
g.update(func(s *datastore.GameState) {
if phase == nil {
delete(s.ProductionPhases, playerID)
} else {
phaseCopy := *phase
s.ProductionPhases[playerID] = &phaseCopy
}
s.UpdatedAt = time.Now()
})
if g.eventBus != nil {
events.Publish(g.eventBus, events.GameStateChangedEvent{
GameID: g.id,
Timestamp: time.Now(),
})
}
return nil
}
func (g *Game) GetSelectCorporationPhase(playerID string) *shared.SelectCorporationPhase {
var result *shared.SelectCorporationPhase
g.read(func(s *datastore.GameState) {
phase, exists := s.SelectCorporationPhases[playerID]
if !exists || phase == nil {
return
}
phaseCopy := *phase
result = &phaseCopy
})
return result
}
func (g *Game) SetSelectCorporationPhase(ctx context.Context, playerID string, phase *shared.SelectCorporationPhase) error {
if err := ctx.Err(); err != nil {
return err
}
g.update(func(s *datastore.GameState) {
if phase == nil {
delete(s.SelectCorporationPhases, playerID)
} else {
phaseCopy := *phase
s.SelectCorporationPhases[playerID] = &phaseCopy
}
s.UpdatedAt = time.Now()
})
if g.eventBus != nil {
events.Publish(g.eventBus, events.GameStateChangedEvent{
GameID: g.id,
Timestamp: time.Now(),
})
}
return nil
}
func (g *Game) GetSelectStartingCardsPhase(playerID string) *shared.SelectStartingCardsPhase {
var result *shared.SelectStartingCardsPhase
g.read(func(s *datastore.GameState) {
phase, exists := s.SelectStartingCardsPhases[playerID]
if !exists || phase == nil {
return
}
phaseCopy := *phase
result = &phaseCopy
})
return result
}
func (g *Game) SetSelectStartingCardsPhase(ctx context.Context, playerID string, phase *shared.SelectStartingCardsPhase) error {
if err := ctx.Err(); err != nil {
return err
}
g.update(func(s *datastore.GameState) {
if phase == nil {
delete(s.SelectStartingCardsPhases, playerID)
} else {
phaseCopy := *phase
s.SelectStartingCardsPhases[playerID] = &phaseCopy
}
s.UpdatedAt = time.Now()
})
if g.eventBus != nil {
events.Publish(g.eventBus, events.GameStateChangedEvent{
GameID: g.id,
Timestamp: time.Now(),
})
}
return nil
}
func (g *Game) GetSelectPreludeCardsPhase(playerID string) *shared.SelectPreludeCardsPhase {
var result *shared.SelectPreludeCardsPhase
g.read(func(s *datastore.GameState) {
phase, exists := s.SelectPreludeCardsPhases[playerID]
if !exists || phase == nil {
return
}
phaseCopy := *phase
result = &phaseCopy
})
return result
}
func (g *Game) SetSelectPreludeCardsPhase(ctx context.Context, playerID string, phase *shared.SelectPreludeCardsPhase) error {
if err := ctx.Err(); err != nil {
return err
}
g.update(func(s *datastore.GameState) {
if phase == nil {
delete(s.SelectPreludeCardsPhases, playerID)
} else {
phaseCopy := *phase
s.SelectPreludeCardsPhases[playerID] = &phaseCopy
}
s.UpdatedAt = time.Now()
})
if g.eventBus != nil {
events.Publish(g.eventBus, events.GameStateChangedEvent{
GameID: g.id,
Timestamp: time.Now(),
})
}
return nil
}
func (g *Game) ProcessNextTile(ctx context.Context, playerID string) error {
if err := ctx.Err(); err != nil {
return err
}
var nextTileType string
var source string
var sourceCardID string
var onComplete *shared.TileCompletionCallback
var tileRestrictions *shared.TileRestrictions
var found bool
g.update(func(s *datastore.GameState) {
queue, exists := s.PendingTileSelectionQueues[playerID]
if !exists || queue == nil || len(queue.Items) == 0 {
return
}
found = true
nextTileType = queue.Items[0]
remainingItems := queue.Items[1:]
source = queue.Source
sourceCardID = queue.SourceCardID
onComplete = queue.OnComplete
tileRestrictions = queue.TileRestrictions
if len(remainingItems) > 0 {
s.PendingTileSelectionQueues[playerID] = &shared.PendingTileSelectionQueue{
Items: remainingItems,
TileRestrictions: tileRestrictions,
Source: source,
SourceCardID: sourceCardID,
OnComplete: onComplete,
}
} else {
delete(s.PendingTileSelectionQueues, playerID)
}
})
if !found {
return nil
}
availableHexes := g.calculateAvailableHexesForTile(nextTileType, playerID, tileRestrictions)
if len(availableHexes) == 0 {
return g.ProcessNextTile(ctx, playerID)
}
err := g.SetPendingTileSelection(ctx, playerID, &shared.PendingTileSelection{
TileType: nextTileType,
AvailableHexes: availableHexes,
Source: source,
SourceCardID: sourceCardID,
OnComplete: onComplete,
})
return err
}
// calculateAvailableHexesForTile returns a list of valid hex positions for placing a tile
// tileRestrictions controls placement rules:
// - BoardTags: restricts to tiles with matching tags (e.g., Noctis City)
// - Adjacency: "none" means no adjacent occupied tiles allowed (Research Outpost)
//
// For cities: if BoardTags is set, only matching tiles are valid (ignoring adjacency);
// if Adjacency is "none", tiles must have no adjacent occupied tiles;
// otherwise, tagged tiles (reserved areas) are excluded and normal adjacency rules apply
func (g *Game) calculateAvailableHexesForTile(tileType string, playerID string, tileRestrictions *shared.TileRestrictions) []string {
g.mu.RLock()
defer g.mu.RUnlock()
if g.board == nil {
return []string{}
}
tiles := g.board.Tiles()
availableHexes := []string{}
// Extract restrictions
var boardTags []string
var adjacency string
if tileRestrictions != nil {
boardTags = tileRestrictions.BoardTags
adjacency = tileRestrictions.Adjacency
}
// Helper to check if tile has any of the required board tags
tileHasRequiredTag := func(tile board.Tile, requiredTags []string) bool {
return slices.ContainsFunc(requiredTags, func(reqTag string) bool {
return slices.Contains(tile.Tags, reqTag)
})
}
// Helper to check if tile has any tags (is a reserved area)
tileHasAnyTag := func(tile board.Tile) bool {
return len(tile.Tags) > 0
}
// Helper to check if a tile has any adjacent occupied tiles
hasAnyAdjacentOccupied := func(tile board.Tile) bool {
for _, neighborPos := range tile.Coordinates.GetNeighbors() {
for _, neighborTile := range tiles {
if neighborTile.Coordinates.Equals(neighborPos) && neighborTile.OccupiedBy != nil {
return true
}
}
}
return false
}
// Helper to check if tile is reserved by another player
isReservedByOther := func(tile board.Tile) bool {
return tile.ReservedBy != nil && *tile.ReservedBy != playerID
}
// Helper to count adjacent occupied tiles of a specific type
countAdjacentOfType := func(tile board.Tile, tileOccupantType string) int {
count := 0
var targetType shared.ResourceType
switch tileOccupantType {
case "city":
targetType = shared.ResourceCityTile
case "greenery":
targetType = shared.ResourceGreeneryTile
case "ocean":
targetType = shared.ResourceOceanTile
default:
targetType = shared.ResourceType(tileOccupantType + "-tile")
}
for _, neighborPos := range tile.Coordinates.GetNeighbors() {
for _, neighborTile := range tiles {
if neighborTile.Coordinates.Equals(neighborPos) && neighborTile.OccupiedBy != nil && neighborTile.OccupiedBy.Type == targetType {
count++
break
}
}
}
return count
}
// Helper to check if tile has an adjacent tile of a specific type owned by the player
hasAdjacentOwnedOfType := func(tile board.Tile, tileOccupantType string) bool {
var targetType shared.ResourceType
switch tileOccupantType {
case "city":
targetType = shared.ResourceCityTile
case "greenery":
targetType = shared.ResourceGreeneryTile
case "ocean":
targetType = shared.ResourceOceanTile
default:
targetType = shared.ResourceType(tileOccupantType + "-tile")
}
for _, neighborPos := range tile.Coordinates.GetNeighbors() {
for _, neighborTile := range tiles {
if neighborTile.Coordinates.Equals(neighborPos) &&
neighborTile.OccupiedBy != nil &&
neighborTile.OccupiedBy.Type == targetType &&
neighborTile.OwnerID != nil &&
*neighborTile.OwnerID == playerID {
return true
}
}
}
return false
}
// Helper to check if tile has any adjacent tile owned by the player (regardless of type)
hasAnyAdjacentOwned := func(tile board.Tile) bool {
for _, neighborPos := range tile.Coordinates.GetNeighbors() {
for _, neighborTile := range tiles {
if neighborTile.Coordinates.Equals(neighborPos) &&
neighborTile.OccupiedBy != nil &&
neighborTile.OwnerID != nil &&
*neighborTile.OwnerID == playerID {
return true
}
}
}
return false
}
// Helper to check if a tile has one of the specified bonus types
hasBonusOfType := func(tile board.Tile, bonusTypes []string) bool {
for _, bonus := range tile.Bonuses {
if slices.Contains(bonusTypes, string(bonus.Type)) {
return true
}
}
return false
}
// Helper to check if a tile passes adjacentToType/minAdjacentOfType/adjacentToOwned restrictions
passesAdjacentRestrictions := func(tile board.Tile) bool {
if tileRestrictions == nil {
return true
}
// Check adjacentToType + minAdjacentOfType
if tileRestrictions.AdjacentToType != "" {
minRequired := 1
if tileRestrictions.MinAdjacentOfType != nil {
minRequired = *tileRestrictions.MinAdjacentOfType
}
if countAdjacentOfType(tile, tileRestrictions.AdjacentToType) < minRequired {
return false
}
// If both adjacentToType and adjacentToOwned are set, check owned of that type
if tileRestrictions.AdjacentToOwned && !hasAdjacentOwnedOfType(tile, tileRestrictions.AdjacentToType) {
return false
}
} else if tileRestrictions.AdjacentToOwned {
// AdjacentToOwned without AdjacentToType: adjacent to any owned tile
if !hasAnyAdjacentOwned(tile) {
return false
}
}
return true
}
for _, tile := range tiles {
// Clear targets occupied or reserved tiles (inverse of normal placement)
if tileType == "clear" {
if tile.OccupiedBy != nil || tile.ReservedBy != nil {
availableHexes = append(availableHexes, tile.Coordinates.String())
}
continue
}
// Tile replacement targets any occupied non-ocean tile
if strings.HasPrefix(tileType, "tile-replacement:") {
if tile.OccupiedBy != nil && tile.OccupiedBy.Type != shared.ResourceOceanTile {
availableHexes = append(availableHexes, tile.Coordinates.String())
}
continue
}
// Tile destruction targets any occupied tile on the board
if tileType == "tile-destruction" {
if tile.OccupiedBy != nil {
availableHexes = append(availableHexes, tile.Coordinates.String())
}
continue
}
// Skip tiles that are already occupied
if tile.OccupiedBy != nil {
continue
}
switch tileType {
case "land-claim":
// Land claim can only be placed on unoccupied, unreserved land tiles
if tile.Type != shared.ResourceLandTile {
continue
}
// Exclude reserved areas (tagged tiles like Noctis City)
if tileHasAnyTag(tile) {
continue
}
// Exclude tiles already reserved by anyone
if tile.ReservedBy != nil {
continue
}
availableHexes = append(availableHexes, tile.Coordinates.String())
case "city":
if tile.Type != shared.ResourceLandTile {
continue
}
// If boardTags specified, only allow tiles with matching tags (Noctis City case)
if len(boardTags) > 0 {
if tileHasRequiredTag(tile, boardTags) {
availableHexes = append(availableHexes, tile.Coordinates.String())
logger.Get().Debug("Tile available for city (board tag match)",
zap.String("tile", tile.Coordinates.String()),
zap.Strings("board_tags", boardTags))
}
continue
}
// Skip tiles reserved by other players (current player can use their own reserved tiles)
if isReservedByOther(tile) {
continue
}
// Normal city placement: exclude reserved areas (tagged tiles)
if tileHasAnyTag(tile) {
logger.Get().Debug("Skipping reserved tile for normal city placement",
zap.String("tile", tile.Coordinates.String()),
zap.Strings("tile_tags", tile.Tags))
continue
}
// Handle "no adjacent tiles" restriction (Research Outpost)
if adjacency == "none" {
if !hasAnyAdjacentOccupied(tile) {
availableHexes = append(availableHexes, tile.Coordinates.String())
logger.Get().Debug("Tile available for city (no adjacent tiles)",
zap.String("tile", tile.Coordinates.String()))
}
continue // Skip normal city adjacency rules
}
// Handle adjacentToType restriction (e.g., Urbanized Area: adjacent to 2+ cities)
// This overrides the normal "no adjacent cities" rule
if tileRestrictions != nil && tileRestrictions.AdjacentToType != "" {
if passesAdjacentRestrictions(tile) {
availableHexes = append(availableHexes, tile.Coordinates.String())
}
continue
}
// Check city adjacency rule (no adjacent cities)
hasAdjacentCity := false
neighbors := tile.Coordinates.GetNeighbors()
logger.Get().Debug("Checking city placement",
zap.String("tile", tile.Coordinates.String()),
zap.Int("neighbor_count", len(neighbors)))
for _, neighborPos := range neighbors {
for _, neighborTile := range tiles {
if neighborTile.Coordinates.Equals(neighborPos) {
occupantType := ""
if neighborTile.OccupiedBy != nil {
occupantType = string(neighborTile.OccupiedBy.Type)
}
logger.Get().Debug("Checking neighbor",
zap.String("neighbor_pos", neighborPos.String()),
zap.String("neighbor_tile", neighborTile.Coordinates.String()),
zap.Bool("occupied", neighborTile.OccupiedBy != nil),
zap.String("occupant_type", occupantType))
if neighborTile.OccupiedBy != nil && neighborTile.OccupiedBy.Type == shared.ResourceCityTile {
hasAdjacentCity = true
break
}
}
}
if hasAdjacentCity {
break
}
}
if !hasAdjacentCity {
availableHexes = append(availableHexes, tile.Coordinates.String())
logger.Get().Debug("Tile available for city",
zap.String("tile", tile.Coordinates.String()))
} else {
logger.Get().Debug("Tile unavailable for city (adjacent city)",
zap.String("tile", tile.Coordinates.String()))
}
case "greenery", "world-tree":
// Check if restricted to ocean tiles (Mangrove card)
if tileRestrictions != nil && tileRestrictions.OnTileType == "ocean" {
if tile.Type == shared.ResourceOceanSpace {
availableHexes = append(availableHexes, tile.Coordinates.String())
}
continue
}
// Skip tiles reserved by other players (current player can use their own reserved tiles)
if isReservedByOther(tile) {
continue
}
// Exclude reserved areas from normal greenery placement
if len(boardTags) == 0 && tileHasAnyTag(tile) {
continue
}
if tile.Type == shared.ResourceLandTile {
// Apply adjacency restrictions if set (e.g., Ecological Zone: adjacent to greenery)
if !passesAdjacentRestrictions(tile) {
continue
}
availableHexes = append(availableHexes, tile.Coordinates.String())
}
case "ocean":
if tile.Type == shared.ResourceOceanSpace {
availableHexes = append(availableHexes, tile.Coordinates.String())
}
case "volcano":
if !tileHasRequiredTag(tile, []string{board.BoardTagVolcanic}) {
continue
}
if tile.Type == shared.ResourceLandTile {
availableHexes = append(availableHexes, tile.Coordinates.String())
}
case "mohole":
if tile.Type == shared.ResourceOceanSpace {
availableHexes = append(availableHexes, tile.Coordinates.String())
}
default:
// Handle ocean-space placement (e.g., Mohole Area)
if tileRestrictions != nil && tileRestrictions.OnTileType == "ocean" {
if tile.Type == shared.ResourceOceanSpace {
availableHexes = append(availableHexes, tile.Coordinates.String())
}
continue
}
// Skip tiles reserved by other players (current player can use their own reserved tiles)
if isReservedByOther(tile) {
continue
}
// If boardTags specified, only allow tiles with matching tags
if len(boardTags) > 0 {
if tileHasRequiredTag(tile, boardTags) {
availableHexes = append(availableHexes, tile.Coordinates.String())
}
continue
}
// Exclude reserved areas from normal placement
if tileHasAnyTag(tile) {
continue
}
// Must be on land
if tile.Type != shared.ResourceLandTile {
continue
}
// Check bonus type restriction (e.g., Mining Area/Mining Rights)
if tileRestrictions != nil && len(tileRestrictions.OnBonusType) > 0 {
if !hasBonusOfType(tile, tileRestrictions.OnBonusType) {
continue
}
}
// Handle "no adjacent tiles" restriction (e.g., Natural Preserve)
if adjacency == "none" {
if !hasAnyAdjacentOccupied(tile) {
availableHexes = append(availableHexes, tile.Coordinates.String())
}
continue
}
// Apply adjacency restrictions if set
if !passesAdjacentRestrictions(tile) {
continue
}
availableHexes = append(availableHexes, tile.Coordinates.String())
}
}
if len(availableHexes) == 0 && tileRestrictions != nil {
// Board tags fallback (e.g., Noctis City already occupied)
canFallback := len(boardTags) > 0
// AdjacentToOwned-only fallback: greenery must be placed adjacent to own tiles if possible,
// but if no owned tiles exist, placement is allowed anywhere (TM rules)
if tileRestrictions.AdjacentToOwned && tileRestrictions.AdjacentToType == "" && len(tileRestrictions.OnBonusType) == 0 {
canFallback = true
}
if canFallback {
logger.Get().Debug("No tiles match restrictions, falling back to normal placement",
zap.String("tile_type", tileType))
return g.calculateAvailableHexesForTile(tileType, playerID, nil)
}
}
return availableHexes
}
// CountAvailableHexesForTile returns the number of valid hex positions for placing a tile
// This is used by state calculators to determine if tile-placing actions are available
func (g *Game) CountAvailableHexesForTile(tileType string, playerID string, tileRestrictions *shared.TileRestrictions) int {
return len(g.calculateAvailableHexesForTile(tileType, playerID, tileRestrictions))
}
// CalculateAvailableHexesForTile returns the list of valid hex coordinate strings for placing a tile
func (g *Game) CalculateAvailableHexesForTile(tileType string, playerID string, tileRestrictions *shared.TileRestrictions) []string {
return g.calculateAvailableHexesForTile(tileType, playerID, tileRestrictions)
}
func (g *Game) AddTriggeredEffect(effect shared.TriggeredEffect) {
g.update(func(s *datastore.GameState) {
s.TriggeredEffects = append(s.TriggeredEffects, effect)
})
}
// AddOrMergeTriggeredEffect adds a triggered effect, merging calculated outputs into the last
// effect if it has the same CardName, PlayerID, and SourceType.
func (g *Game) AddOrMergeTriggeredEffect(effect shared.TriggeredEffect) {
g.update(func(s *datastore.GameState) {
if len(s.TriggeredEffects) > 0 {
last := &s.TriggeredEffects[len(s.TriggeredEffects)-1]
if last.CardName == effect.CardName && last.PlayerID == effect.PlayerID && last.SourceType == effect.SourceType {
last.CalculatedOutputs = append(last.CalculatedOutputs, effect.CalculatedOutputs...)
return
}
}
s.TriggeredEffects = append(s.TriggeredEffects, effect)
})
}
func (g *Game) AppendToLastTriggeredEffect(playerID string, outputs []shared.CalculatedOutput) {
g.update(func(s *datastore.GameState) {
for i := len(s.TriggeredEffects) - 1; i >= 0; i-- {
if s.TriggeredEffects[i].PlayerID == playerID {
s.TriggeredEffects[i].CalculatedOutputs = append(s.TriggeredEffects[i].CalculatedOutputs, outputs...)
return
}
}
})
}
func (g *Game) GetTriggeredEffects() []shared.TriggeredEffect {
var result []shared.TriggeredEffect
g.read(func(s *datastore.GameState) {
result = make([]shared.TriggeredEffect, len(s.TriggeredEffects))
copy(result, s.TriggeredEffects)
})
return result
}
func (g *Game) ClearTriggeredEffects() {
g.update(func(s *datastore.GameState) {
s.TriggeredEffects = nil
})
}
func (g *Game) SetVPCardLookup(lookup VPCardLookup) {
g.mu.Lock()
defer g.mu.Unlock()
g.vpCardLookup = lookup
g.subscribeToVPEvents()
}
// RegisterCorporationVPGranter registers VP conditions from a corporation card.
func (g *Game) RegisterCorporationVPGranter(playerID string, corporationID string) {
if g.vpCardLookup == nil {
return
}
cardInfo, err := g.vpCardLookup.LookupVPCard(corporationID)
if err != nil || len(cardInfo.VPConditions) == 0 {
return
}
p, err := g.GetPlayer(playerID)
if err != nil {
return
}
granter := shared.VPGranter{
CardID: cardInfo.CardID,
CardName: cardInfo.CardName,
Description: cardInfo.Description,
VPConditions: cardInfo.VPConditions,
}
p.VPGranters().Prepend(granter)
g.recalculatePlayerVP(p)
}
func (g *Game) recalculatePlayerVP(p *player.Player) {
if g.vpCardLookup == nil {
return
}
ctx := &gameVPRecalculationContext{game: g}
p.VPGranters().RecalculateAll(ctx)
}
func (g *Game) recalculateAllPlayersVP() {
for _, p := range g.GetAllPlayers() {
g.recalculatePlayerVP(p)
}
}
func (g *Game) subscribeToVPEvents() {
events.Subscribe(g.eventBus, func(e events.CardPlayedEvent) {
if g.vpCardLookup == nil {
return
}
cardInfo, err := g.vpCardLookup.LookupVPCard(e.CardID)
if err != nil {
return
}
if len(cardInfo.VPConditions) == 0 {
return
}
p, err := g.GetPlayer(e.PlayerID)
if err != nil {
return
}
granter := shared.VPGranter{
CardID: cardInfo.CardID,
CardName: cardInfo.CardName,
Description: cardInfo.Description,
VPConditions: cardInfo.VPConditions,
}
p.VPGranters().Add(granter)
g.recalculatePlayerVP(p)
})
events.Subscribe(g.eventBus, func(e events.ResourceStorageChangedEvent) {
p, err := g.GetPlayer(e.PlayerID)
if err != nil {
return
}
g.recalculatePlayerVP(p)
})
events.Subscribe(g.eventBus, func(e events.TagPlayedEvent) {
p, err := g.GetPlayer(e.PlayerID)
if err != nil {
return
}
g.recalculatePlayerVP(p)
})
events.Subscribe(g.eventBus, func(e events.TilePlacedEvent) {
g.recalculateAllPlayersVP()
})
events.Subscribe(g.eventBus, func(e events.ColonyBuiltEvent) {
g.recalculateAllPlayersVP()
})
}
func (g *Game) subscribeToGenerationalEvents() {
events.Subscribe(g.eventBus, func(e events.TerraformRatingChangedEvent) {
if e.NewRating > e.OldRating {
p, err := g.GetPlayer(e.PlayerID)
if err != nil {
return
}
p.GenerationalEvents().Increment(shared.GenerationalEventTRRaise)
}
})
events.Subscribe(g.eventBus, func(e events.TilePlacedEvent) {
p, err := g.GetPlayer(e.PlayerID)
if err != nil {
return
}
switch e.TileType {
case "ocean":
p.GenerationalEvents().Increment(shared.GenerationalEventOceanPlacement)
case "city":
p.GenerationalEvents().Increment(shared.GenerationalEventCityPlacement)
case "greenery":
p.GenerationalEvents().Increment(shared.GenerationalEventGreeneryPlacement)
}
})
events.Subscribe(g.eventBus, func(e events.GenerationAdvancedEvent) {
for _, p := range g.GetAllPlayers() {
p.GenerationalEvents().Clear()
// Clear temporary "generation-end" effects
p.Effects().RemoveTemporaryEffects(shared.TemporaryGenerationEnd)
// Also clear any "next-card" effects that weren't consumed
p.Effects().RemoveTemporaryEffects(shared.TemporaryNextCard)
}
})
}
func (g *Game) subscribeToOceanSpaceEvents() {
events.Subscribe(g.eventBus, func(e events.TilePlacedEvent) {
if e.TileType == string(shared.ResourceOceanTile) || e.TileType == "ocean" {
return
}
coords := shared.HexPosition{Q: e.Q, R: e.R, S: e.S}
tile, err := g.board.GetTile(coords)
if err != nil {
return
}
if tile.Type != shared.ResourceOceanSpace {
return
}
freeOceanSpaces := g.board.FreeOceanSpaces()
gp := g.globalParameters
oceansRemaining := gp.GetMaxOceans() - gp.Oceans()
if freeOceanSpaces < oceansRemaining {
gp.ReduceMaxOceans(gp.Oceans() + freeOceanSpaces)
}
})
}
func (g *Game) subscribeToGlobalParameterBonuses() {
log := logger.Get()
events.Subscribe(g.eventBus, func(e events.TemperatureChangedEvent) {
if e.ChangedBy == "" {
return
}
p, err := g.GetPlayer(e.ChangedBy)
if err != nil {
return
}
if e.OldValue < -24 && e.NewValue >= -24 {
p.Resources().AddProduction(map[shared.ResourceType]int{
shared.ResourceHeatProduction: 1,
})
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: "Temperature Bonus",
PlayerID: e.ChangedBy,
SourceType: shared.SourceTypeGlobalBonus,
CalculatedOutputs: []shared.CalculatedOutput{
{ResourceType: string(shared.ResourceHeatProduction), Amount: 1},
},
})
log.Debug("Temperature bonus: +1 heat production at -24C", zap.String("player_id", e.ChangedBy))
}
if e.OldValue < -20 && e.NewValue >= -20 {
p.Resources().AddProduction(map[shared.ResourceType]int{
shared.ResourceHeatProduction: 1,
})
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: "Temperature Bonus",
PlayerID: e.ChangedBy,
SourceType: shared.SourceTypeGlobalBonus,
CalculatedOutputs: []shared.CalculatedOutput{
{ResourceType: string(shared.ResourceHeatProduction), Amount: 1},
},
})
log.Debug("Temperature bonus: +1 heat production at -20C", zap.String("player_id", e.ChangedBy))
}
if e.OldValue < 0 && e.NewValue >= 0 {
ctx := context.Background()
if err := g.AppendToPendingTileSelectionQueue(ctx, e.ChangedBy, []string{"ocean"}, "Temperature Bonus", "", nil); err != nil {
log.Warn("Failed to queue ocean tile for temperature bonus", zap.Error(err))
}
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: "Temperature Bonus",
PlayerID: e.ChangedBy,
SourceType: shared.SourceTypeGlobalBonus,
CalculatedOutputs: []shared.CalculatedOutput{
{ResourceType: string(shared.ResourceOceanTile), Amount: 1},
},
})
log.Debug("Temperature bonus: place ocean at 0C", zap.String("player_id", e.ChangedBy))
}
})
events.Subscribe(g.eventBus, func(e events.OxygenChangedEvent) {
if e.ChangedBy == "" {
return
}
if e.OldValue < 8 && e.NewValue >= 8 {
ctx := context.Background()
actualSteps, err := g.globalParameters.IncreaseTemperature(ctx, 1, e.ChangedBy)
if err != nil {
logger.Get().Warn("Failed to increase temperature from oxygen bonus", zap.Error(err))
}
if actualSteps > 0 {
p, pErr := g.GetPlayer(e.ChangedBy)
if pErr == nil {
p.Resources().UpdateTerraformRating(actualSteps)
}
}
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: "Oxygen Bonus",
PlayerID: e.ChangedBy,
SourceType: shared.SourceTypeGlobalBonus,
CalculatedOutputs: []shared.CalculatedOutput{
{ResourceType: string(shared.ResourceTemperature), Amount: 1},
},
})
log.Debug("Oxygen bonus: +1 temperature step at 8%", zap.String("player_id", e.ChangedBy))
}
})
events.Subscribe(g.eventBus, func(e events.VenusChangedEvent) {
if e.ChangedBy == "" {
return
}
p, err := g.GetPlayer(e.ChangedBy)
if err != nil {
return
}
if e.OldValue < 8 && e.NewValue >= 8 {
ctx := context.Background()
cardIDs, drawErr := g.deck.DrawProjectCards(ctx, 1)
if drawErr == nil && len(cardIDs) > 0 {
p.Hand().AddCard(cardIDs[0])
}
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: "Venus Bonus",
PlayerID: e.ChangedBy,
SourceType: shared.SourceTypeGlobalBonus,
CalculatedOutputs: []shared.CalculatedOutput{
{ResourceType: "card-draw", Amount: 1},
},
})
log.Debug("Venus bonus: draw 1 card at 8%", zap.String("player_id", e.ChangedBy))
}
if e.OldValue < 16 && e.NewValue >= 16 {
p.Resources().UpdateTerraformRating(1)
g.AddTriggeredEffect(shared.TriggeredEffect{
CardName: "Venus Bonus",
PlayerID: e.ChangedBy,
SourceType: shared.SourceTypeGlobalBonus,
CalculatedOutputs: []shared.CalculatedOutput{
{ResourceType: string(shared.ResourceTR), Amount: 1},
},
})
log.Debug("Venus bonus: +1 TR at 16%", zap.String("player_id", e.ChangedBy))
}
})
}
func (g *Game) GetDeferredStartingChoices(playerID string) *shared.DeferredStartingChoices {
var result *shared.DeferredStartingChoices
g.read(func(s *datastore.GameState) {
choices, exists := s.DeferredStartingChoices[playerID]
if !exists || choices == nil {
return
}
choicesCopy := *choices
result = &choicesCopy
})
return result
}
func (g *Game) SetDeferredStartingChoices(ctx context.Context, playerID string, choices *shared.DeferredStartingChoices) error {
if err := ctx.Err(); err != nil {
return err
}
g.update(func(s *datastore.GameState) {
if choices == nil {
delete(s.DeferredStartingChoices, playerID)
} else {
choicesCopy := *choices
s.DeferredStartingChoices[playerID] = &choicesCopy
}
s.UpdatedAt = time.Now()
})
if g.eventBus != nil {
events.Publish(g.eventBus, events.GameStateChangedEvent{
GameID: g.id,
Timestamp: time.Now(),
})
}
return nil
}
func (g *Game) MarkCorpApplied(playerID string) {
g.update(func(s *datastore.GameState) {
if choices, ok := s.DeferredStartingChoices[playerID]; ok && choices != nil {
choices.CorpApplied = true
}
})
}
func (g *Game) MarkPreludesApplied(playerID string) {
g.update(func(s *datastore.GameState) {
if choices, ok := s.DeferredStartingChoices[playerID]; ok && choices != nil {
choices.PreludesApplied = true
}
})
}
func (g *Game) InitPhasePlayerIndex() int {
var v int
g.read(func(s *datastore.GameState) { v = s.InitPhasePlayerIndex })
return v
}
func (g *Game) SetInitPhasePlayerIndex(ctx context.Context, index int) error {
if err := ctx.Err(); err != nil {
return err
}
g.update(func(s *datastore.GameState) {
s.InitPhasePlayerIndex = index
s.UpdatedAt = time.Now()
})
if g.eventBus != nil {
events.Publish(g.eventBus, events.GameStateChangedEvent{
GameID: g.id,
Timestamp: time.Now(),
})
}
return nil
}
func (g *Game) InitPhaseWaitingForConfirm() bool {
var v bool
g.read(func(s *datastore.GameState) { v = s.InitPhaseWaitingForConfirm })
return v
}
func (g *Game) InitPhaseConfirmVersion() int {
var v int
g.read(func(s *datastore.GameState) { v = s.InitPhaseConfirmVersion })
return v
}
func (g *Game) SetInitPhaseWaitingForConfirm(ctx context.Context, waiting bool) error {
if err := ctx.Err(); err != nil {
return err
}
g.update(func(s *datastore.GameState) {
s.InitPhaseWaitingForConfirm = waiting
if waiting {
s.InitPhaseConfirmVersion++
}
s.UpdatedAt = time.Now()
})
if g.eventBus != nil {
events.Publish(g.eventBus, events.GameStateChangedEvent{
GameID: g.id,
Timestamp: time.Now(),
})
}
return nil
}
func (g *Game) HasColonies() bool {
return g.Settings().HasColonies()
}
// CountAllColonies delegates to the Colonies component.
// This allows Game to satisfy the BoardContext interface for VP calculation.
func (g *Game) CountAllColonies() int {
return g.colonies.CountAllColonies()
}
func (g *Game) InitializeTradeFleets(playerIDs []string) {
g.update(func(s *datastore.GameState) {
s.TradeFleets = make(map[string]bool, len(playerIDs))
for _, id := range playerIDs {
s.TradeFleets[id] = true
}
s.UpdatedAt = time.Now()
})
}
// HasProjectFunding returns true if the project funding expansion is enabled
func (g *Game) HasProjectFunding() bool {
return g.Settings().HasProjectFunding()
}
// ProjectFundingStates returns the project funding states
func (g *Game) ProjectFundingStates() []*projectfunding.ProjectState {
var result []*projectfunding.ProjectState
g.read(func(s *datastore.GameState) { result = s.ProjectFundingStates })
return result
}
// SetProjectFundingStates sets the project funding states
func (g *Game) SetProjectFundingStates(states []*projectfunding.ProjectState) {
g.update(func(s *datastore.GameState) {
s.ProjectFundingStates = states
s.UpdatedAt = time.Now()
})
}
// IsNextGenTurnOrderFrozen returns true if turn order rotation is skipped next generation.
func (g *Game) IsNextGenTurnOrderFrozen() bool {
var v bool
g.read(func(s *datastore.GameState) { v = s.NextGenTurnOrderFrozen })
return v
}
// SetNextGenTurnOrderFrozen sets whether turn order rotation is skipped next generation.
func (g *Game) SetNextGenTurnOrderFrozen(frozen bool) {
g.update(func(s *datastore.GameState) {
s.NextGenTurnOrderFrozen = frozen
s.UpdatedAt = time.Now()
})
}
// GetProjectFundingState returns the state for a specific project
func (g *Game) GetProjectFundingState(projectID string) *projectfunding.ProjectState {
var result *projectfunding.ProjectState
g.read(func(s *datastore.GameState) {
for _, state := range s.ProjectFundingStates {
if state.DefinitionID == projectID {
result = state
return
}
}
})
return result
}
package global_parameters
import (
"context"
"go.uber.org/zap"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game/datastore"
"terraforming-mars-backend/internal/logger"
)
const (
MinTemperature = -30
MaxTemperature = 8
MinOxygen = 0
MaxOxygen = 14
MinOceans = 0
MaxOceans = 9
MinVenus = 0
MaxVenus = 30
)
// GlobalParameters manages the global parameter fields of a game.
type GlobalParameters struct {
ds *datastore.DataStore
gameID string
eventBus *events.EventBusImpl
}
func NewGlobalParameters(ds *datastore.DataStore, gameID string, eventBus *events.EventBusImpl) *GlobalParameters {
return &GlobalParameters{ds: ds, gameID: gameID, eventBus: eventBus}
}
func (gp *GlobalParameters) update(fn func(s *datastore.GameState)) {
if err := gp.ds.UpdateGame(gp.gameID, fn); err != nil {
logger.Get().Warn("Failed to update game state", zap.String("game_id", gp.gameID), zap.Error(err))
}
}
func (gp *GlobalParameters) read(fn func(s *datastore.GameState)) {
if err := gp.ds.ReadGame(gp.gameID, fn); err != nil {
logger.Get().Warn("Failed to read game state", zap.String("game_id", gp.gameID), zap.Error(err))
}
}
func (gp *GlobalParameters) Temperature() int {
var v int
gp.read(func(s *datastore.GameState) { v = s.Temperature })
return v
}
func (gp *GlobalParameters) Oxygen() int {
var v int
gp.read(func(s *datastore.GameState) { v = s.Oxygen })
return v
}
func (gp *GlobalParameters) Oceans() int {
var v int
gp.read(func(s *datastore.GameState) { v = s.Oceans })
return v
}
func (gp *GlobalParameters) Venus() int {
var v int
gp.read(func(s *datastore.GameState) { v = s.Venus })
return v
}
func (gp *GlobalParameters) GetMaxOceans() int {
var v int
gp.read(func(s *datastore.GameState) { v = s.MaxOceans })
return v
}
// ReduceMaxOceans lowers the max oceans limit when non-ocean tiles occupy ocean spaces.
func (gp *GlobalParameters) ReduceMaxOceans(newMax int) {
var oldMax, currentMax int
gp.update(func(s *datastore.GameState) {
oldMax = s.MaxOceans
if newMax < s.MaxOceans {
s.MaxOceans = newMax
}
currentMax = s.MaxOceans
})
if gp.eventBus != nil && oldMax != currentMax {
var oceans int
gp.read(func(s *datastore.GameState) { oceans = s.Oceans })
events.Publish(gp.eventBus, events.OceansChangedEvent{
GameID: gp.gameID,
OldValue: oceans,
NewValue: oceans,
})
}
}
func (gp *GlobalParameters) IsMaxed() bool {
var maxed bool
gp.read(func(s *datastore.GameState) {
maxed = s.Temperature >= MaxTemperature &&
s.Oxygen >= MaxOxygen &&
s.Oceans >= s.MaxOceans
})
return maxed
}
// IncreaseTemperature raises the temperature by the specified number of steps.
// Each step is 2 degrees. Returns the actual number of steps raised.
func (gp *GlobalParameters) IncreaseTemperature(ctx context.Context, steps int, playerID string) (int, error) {
if err := ctx.Err(); err != nil {
return 0, err
}
var oldTemp, newTemp int
gp.update(func(s *datastore.GameState) {
oldTemp = s.Temperature
newTemp = min(s.Temperature+steps*2, MaxTemperature)
s.Temperature = newTemp
})
actualSteps := (newTemp - oldTemp) / 2
if gp.eventBus != nil && oldTemp != newTemp {
events.Publish(gp.eventBus, events.TemperatureChangedEvent{
GameID: gp.gameID,
OldValue: oldTemp,
NewValue: newTemp,
ChangedBy: playerID,
})
}
return actualSteps, nil
}
// IncreaseOxygen raises the oxygen by the specified number of steps.
// Returns the actual number of steps raised.
func (gp *GlobalParameters) IncreaseOxygen(ctx context.Context, steps int, playerID string) (int, error) {
if err := ctx.Err(); err != nil {
return 0, err
}
var oldOxygen, newOxygen int
gp.update(func(s *datastore.GameState) {
oldOxygen = s.Oxygen
newOxygen = min(s.Oxygen+steps, MaxOxygen)
s.Oxygen = newOxygen
})
actualSteps := newOxygen - oldOxygen
if gp.eventBus != nil && oldOxygen != newOxygen {
events.Publish(gp.eventBus, events.OxygenChangedEvent{
GameID: gp.gameID,
OldValue: oldOxygen,
NewValue: newOxygen,
ChangedBy: playerID,
})
}
return actualSteps, nil
}
// PlaceOcean places an ocean tile (increments ocean count).
// Returns true if successful, false if limit reached.
func (gp *GlobalParameters) PlaceOcean(ctx context.Context, playerID string) (bool, error) {
if err := ctx.Err(); err != nil {
return false, err
}
var oldOceans, newOceans int
var placed bool
gp.update(func(s *datastore.GameState) {
oldOceans = s.Oceans
if s.Oceans >= s.MaxOceans {
newOceans = s.Oceans
return
}
s.Oceans++
newOceans = s.Oceans
placed = true
})
if gp.eventBus != nil && placed {
events.Publish(gp.eventBus, events.OceansChangedEvent{
GameID: gp.gameID,
OldValue: oldOceans,
NewValue: newOceans,
ChangedBy: playerID,
})
}
return placed, nil
}
func (gp *GlobalParameters) SetTemperature(ctx context.Context, newTemp int) error {
if err := ctx.Err(); err != nil {
return err
}
var oldTemp int
gp.update(func(s *datastore.GameState) {
oldTemp = s.Temperature
s.Temperature = newTemp
})
if gp.eventBus != nil && oldTemp != newTemp {
events.Publish(gp.eventBus, events.TemperatureChangedEvent{
GameID: gp.gameID,
OldValue: oldTemp,
NewValue: newTemp,
})
}
return nil
}
func (gp *GlobalParameters) SetOxygen(ctx context.Context, newOxygen int) error {
if err := ctx.Err(); err != nil {
return err
}
var oldOxygen int
gp.update(func(s *datastore.GameState) {
oldOxygen = s.Oxygen
s.Oxygen = newOxygen
})
if gp.eventBus != nil && oldOxygen != newOxygen {
events.Publish(gp.eventBus, events.OxygenChangedEvent{
GameID: gp.gameID,
OldValue: oldOxygen,
NewValue: newOxygen,
})
}
return nil
}
func (gp *GlobalParameters) SetOceans(ctx context.Context, newOceans int) error {
if err := ctx.Err(); err != nil {
return err
}
var oldOceans int
gp.update(func(s *datastore.GameState) {
oldOceans = s.Oceans
s.Oceans = newOceans
})
if gp.eventBus != nil && oldOceans != newOceans {
events.Publish(gp.eventBus, events.OceansChangedEvent{
GameID: gp.gameID,
OldValue: oldOceans,
NewValue: newOceans,
})
}
return nil
}
// IncreaseVenus raises the venus level by the specified number of steps.
// Each step is 2%. Returns the actual number of steps raised.
func (gp *GlobalParameters) IncreaseVenus(ctx context.Context, steps int, playerID string) (int, error) {
if err := ctx.Err(); err != nil {
return 0, err
}
var oldVenus, newVenus int
gp.update(func(s *datastore.GameState) {
oldVenus = s.Venus
newVenus = min(s.Venus+steps*2, MaxVenus)
s.Venus = newVenus
})
actualSteps := (newVenus - oldVenus) / 2
if gp.eventBus != nil && oldVenus != newVenus {
events.Publish(gp.eventBus, events.VenusChangedEvent{
GameID: gp.gameID,
OldValue: oldVenus,
NewValue: newVenus,
ChangedBy: playerID,
})
}
return actualSteps, nil
}
func (gp *GlobalParameters) SetVenus(ctx context.Context, newVenus int) error {
if err := ctx.Err(); err != nil {
return err
}
var oldVenus int
gp.update(func(s *datastore.GameState) {
oldVenus = s.Venus
s.Venus = newVenus
})
if gp.eventBus != nil && oldVenus != newVenus {
events.Publish(gp.eventBus, events.VenusChangedEvent{
GameID: gp.gameID,
OldValue: oldVenus,
NewValue: newVenus,
})
}
return nil
}
package game
import (
"context"
"fmt"
"sync"
"terraforming-mars-backend/internal/game/datastore"
"terraforming-mars-backend/internal/game/shared"
)
// MemDBGameRepository implements GameRepository using go-memdb for storage
// and a local cache for returning the same *Game pointers with runtime state.
type MemDBGameRepository struct {
ds *datastore.DataStore
rm *datastore.RuntimeManager
mu sync.RWMutex
cache map[string]*Game
}
func NewMemDBGameRepository(ds *datastore.DataStore, rm *datastore.RuntimeManager) *MemDBGameRepository {
return &MemDBGameRepository{
ds: ds,
rm: rm,
cache: make(map[string]*Game),
}
}
// DataStore returns the underlying DataStore for use by actions that create games.
func (r *MemDBGameRepository) DataStore() *datastore.DataStore {
return r.ds
}
func (r *MemDBGameRepository) Get(ctx context.Context, gameID string) (*Game, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
r.mu.RLock()
defer r.mu.RUnlock()
g, exists := r.cache[gameID]
if !exists {
return nil, fmt.Errorf("game %s not found", gameID)
}
return g, nil
}
// Create registers an already-constructed Game (whose state is already in the DataStore).
func (r *MemDBGameRepository) Create(ctx context.Context, g *Game) error {
if err := ctx.Err(); err != nil {
return err
}
if g == nil {
return fmt.Errorf("game cannot be nil")
}
r.rm.Register(g.ID(), g.EventBus())
r.mu.Lock()
r.cache[g.ID()] = g
r.mu.Unlock()
return nil
}
func (r *MemDBGameRepository) Delete(ctx context.Context, gameID string) error {
if err := ctx.Err(); err != nil {
return err
}
txn := r.ds.BeginTxn()
defer txn.Abort()
if err := txn.DeleteGame(gameID); err != nil {
return err
}
txn.Commit()
r.rm.Delete(gameID)
r.mu.Lock()
delete(r.cache, gameID)
r.mu.Unlock()
return nil
}
func (r *MemDBGameRepository) List(ctx context.Context, status *shared.GameStatus) ([]*Game, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
r.mu.RLock()
defer r.mu.RUnlock()
games := make([]*Game, 0, len(r.cache))
for _, g := range r.cache {
if status != nil && g.Status() != *status {
continue
}
games = append(games, g)
}
return games, nil
}
func (r *MemDBGameRepository) Exists(ctx context.Context, gameID string) bool {
return r.ds.GameExists(gameID)
}
package milestone
import (
"terraforming-mars-backend/internal/game/award"
"terraforming-mars-backend/internal/game/shared"
)
// MilestoneDefinition is the static template loaded from JSON
type MilestoneDefinition struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Pack string `json:"pack"`
ClaimCost int `json:"claimCost"`
Reward []award.RewardOutput `json:"reward"`
Requirement MilestoneRequirement `json:"requirement"`
Style shared.Style `json:"style"`
}
const (
RequirementKindCountable = "countable"
RequirementKindState = "state"
StateTypeAllProduction = "all-production"
)
// MilestoneRequirement is a discriminated union for milestone requirements.
// The Kind field selects which sub-field is active.
type MilestoneRequirement struct {
Kind string `json:"kind"`
Countable *CountableRequirement `json:"countable,omitempty"`
State *StateRequirement `json:"state,omitempty"`
}
// StateRequirement defines a state-based requirement (e.g., all productions at minimum).
type StateRequirement struct {
Type string `json:"type"`
Min int `json:"min"`
}
// CountableRequirement defines a countable threshold requirement.
// Uses PerCondition to determine what to count and MinMaxValue for the threshold.
type CountableRequirement struct {
shared.PerCondition
shared.MinMaxValue
}
// GetRewardVP returns the total VP from the reward outputs
func (d *MilestoneDefinition) GetRewardVP() int {
vp := 0
for _, o := range d.Reward {
if o.Type == "vp" {
vp += o.Amount
}
}
return vp
}
// GetRequired returns the threshold value for the milestone requirement
func (d *MilestoneDefinition) GetRequired() int {
switch d.Requirement.Kind {
case RequirementKindCountable:
if d.Requirement.Countable != nil {
if d.Requirement.Countable.Min != nil {
return *d.Requirement.Countable.Min
}
if d.Requirement.Countable.Max != nil {
return *d.Requirement.Countable.Max
}
}
case RequirementKindState:
return 6
}
return 0
}
package game
import (
"context"
"fmt"
"time"
"go.uber.org/zap"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game/datastore"
"terraforming-mars-backend/internal/game/shared"
"terraforming-mars-backend/internal/logger"
)
const (
MaxClaimedMilestones = 3
)
type Milestones struct {
ds *datastore.DataStore
gameID string
eventBus *events.EventBusImpl
}
func NewMilestones(ds *datastore.DataStore, gameID string, eventBus *events.EventBusImpl) *Milestones {
return &Milestones{
ds: ds,
gameID: gameID,
eventBus: eventBus,
}
}
func (m *Milestones) update(fn func(s *datastore.GameState)) {
if err := m.ds.UpdateGame(m.gameID, fn); err != nil {
logger.Get().Warn("Failed to update game state", zap.String("game_id", m.gameID), zap.Error(err))
}
}
func (m *Milestones) read(fn func(s *datastore.GameState)) {
if err := m.ds.ReadGame(m.gameID, fn); err != nil {
logger.Get().Warn("Failed to read game state", zap.String("game_id", m.gameID), zap.Error(err))
}
}
func (m *Milestones) ClaimedMilestones() []shared.ClaimedMilestone {
var result []shared.ClaimedMilestone
m.read(func(s *datastore.GameState) {
result = make([]shared.ClaimedMilestone, len(s.ClaimedMilestones))
copy(result, s.ClaimedMilestones)
})
return result
}
func (m *Milestones) IsClaimed(milestoneType shared.MilestoneType) bool {
var claimed bool
m.read(func(s *datastore.GameState) {
for _, c := range s.ClaimedMilestones {
if c.Type == milestoneType {
claimed = true
return
}
}
})
return claimed
}
func (m *Milestones) IsClaimedBy(milestoneType shared.MilestoneType, playerID string) bool {
var claimed bool
m.read(func(s *datastore.GameState) {
for _, c := range s.ClaimedMilestones {
if c.Type == milestoneType && c.PlayerID == playerID {
claimed = true
return
}
}
})
return claimed
}
func (m *Milestones) CanClaimMore() bool {
var can bool
m.read(func(s *datastore.GameState) {
can = len(s.ClaimedMilestones) < MaxClaimedMilestones
})
return can
}
func (m *Milestones) ClaimedCount() int {
var count int
m.read(func(s *datastore.GameState) { count = len(s.ClaimedMilestones) })
return count
}
func (m *Milestones) GetClaimedByPlayer(playerID string) []shared.ClaimedMilestone {
var result []shared.ClaimedMilestone
m.read(func(s *datastore.GameState) {
for _, c := range s.ClaimedMilestones {
if c.PlayerID == playerID {
result = append(result, c)
}
}
})
return result
}
func (m *Milestones) ClaimMilestone(ctx context.Context, milestoneType shared.MilestoneType, playerID string, generation int) error {
if err := ctx.Err(); err != nil {
return err
}
var claimErr error
m.update(func(s *datastore.GameState) {
if len(s.ClaimedMilestones) >= MaxClaimedMilestones {
claimErr = fmt.Errorf("maximum milestones (%d) already claimed", MaxClaimedMilestones)
return
}
for _, c := range s.ClaimedMilestones {
if c.Type == milestoneType {
claimErr = fmt.Errorf("milestone %s is already claimed", milestoneType)
return
}
}
s.ClaimedMilestones = append(s.ClaimedMilestones, shared.ClaimedMilestone{
Type: milestoneType,
PlayerID: playerID,
Generation: generation,
ClaimedAt: time.Now(),
})
})
if claimErr != nil {
return claimErr
}
if m.eventBus != nil {
events.Publish(m.eventBus, events.MilestoneClaimedEvent{
GameID: m.gameID,
PlayerID: playerID,
MilestoneType: string(milestoneType),
Timestamp: time.Now(),
})
}
return nil
}
package player
import (
"go.uber.org/zap"
"terraforming-mars-backend/internal/game/datastore"
"terraforming-mars-backend/internal/game/shared"
"terraforming-mars-backend/internal/logger"
)
// Actions manages available manual actions.
type Actions struct {
ds *datastore.DataStore
gameID string
playerID string
}
// NewActions creates a new Actions view backed by the DataStore.
func NewActions(ds *datastore.DataStore, gameID, playerID string) *Actions {
return &Actions{ds: ds, gameID: gameID, playerID: playerID}
}
func (a *Actions) update(fn func(s *datastore.PlayerState)) {
if err := a.ds.UpdatePlayer(a.gameID, a.playerID, fn); err != nil {
logger.Get().Warn("Failed to update player state", zap.String("game_id", a.gameID), zap.String("player_id", a.playerID), zap.Error(err))
}
}
func (a *Actions) read(fn func(s *datastore.PlayerState)) {
if err := a.ds.ReadPlayer(a.gameID, a.playerID, fn); err != nil {
logger.Get().Warn("Failed to read player state", zap.String("game_id", a.gameID), zap.String("player_id", a.playerID), zap.Error(err))
}
}
func (a *Actions) List() []shared.CardAction {
var actionsCopy []shared.CardAction
a.read(func(s *datastore.PlayerState) {
actionsCopy = make([]shared.CardAction, len(s.Actions))
copy(actionsCopy, s.Actions)
})
return actionsCopy
}
func (a *Actions) SetActions(actions []shared.CardAction) {
a.update(func(s *datastore.PlayerState) {
if actions == nil {
s.Actions = []shared.CardAction{}
} else {
s.Actions = make([]shared.CardAction, len(actions))
copy(s.Actions, actions)
}
})
}
func (a *Actions) AddAction(action shared.CardAction) {
a.update(func(s *datastore.PlayerState) {
s.Actions = append(s.Actions, action)
})
}
// ResetGenerationCounts resets the generation counts for all actions to 0
func (a *Actions) ResetGenerationCounts() {
a.update(func(s *datastore.PlayerState) {
for i := range s.Actions {
s.Actions[i].TimesUsedThisGeneration = 0
}
})
}
// ResetTurnCounts resets the turn counts for all actions to 0
func (a *Actions) ResetTurnCounts() {
a.update(func(s *datastore.PlayerState) {
for i := range s.Actions {
s.Actions[i].TimesUsedThisTurn = 0
}
})
}
// RemoveActionsByCardID removes all actions from a specific card
func (a *Actions) RemoveActionsByCardID(cardID string) {
a.update(func(s *datastore.PlayerState) {
filtered := make([]shared.CardAction, 0, len(s.Actions))
for _, action := range s.Actions {
if action.CardID != cardID {
filtered = append(filtered, action)
}
}
s.Actions = filtered
})
}
package player
import "sync"
// CardStateStore manages computed EntityState for cards in a player's hand.
// This is a runtime cache driven by events — the DataStore owns card IDs,
// and this store holds the calculated playability state for each card.
type CardStateStore struct {
states map[string]EntityState
mu sync.RWMutex
}
// NewCardStateStore creates a new empty CardStateStore.
func NewCardStateStore() *CardStateStore {
return &CardStateStore{
states: make(map[string]EntityState),
}
}
// GetState returns the cached EntityState for a card.
func (s *CardStateStore) GetState(cardID string) (EntityState, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
state, ok := s.states[cardID]
return state, ok
}
// SetState stores the EntityState for a card.
func (s *CardStateStore) SetState(cardID string, state EntityState) {
s.mu.Lock()
defer s.mu.Unlock()
s.states[cardID] = state
}
// RemoveState removes a card's state from the store.
func (s *CardStateStore) RemoveState(cardID string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.states, cardID)
}
// SyncWithHand removes entries not present in the given card ID list.
// Called when the hand changes to clean up stale entries.
func (s *CardStateStore) SyncWithHand(cardIDs []string) {
s.mu.Lock()
defer s.mu.Unlock()
active := make(map[string]struct{}, len(cardIDs))
for _, id := range cardIDs {
active[id] = struct{}{}
}
for id := range s.states {
if _, ok := active[id]; !ok {
delete(s.states, id)
}
}
}
// RecalculateAll recomputes state for every card in the store using the provided function.
func (s *CardStateStore) RecalculateAll(fn func(cardID string) EntityState) {
s.mu.Lock()
defer s.mu.Unlock()
for cardID := range s.states {
s.states[cardID] = fn(cardID)
}
}
// AllCardIDs returns all card IDs tracked by the store.
func (s *CardStateStore) AllCardIDs() []string {
s.mu.RLock()
defer s.mu.RUnlock()
ids := make([]string, 0, len(s.states))
for id := range s.states {
ids = append(ids, id)
}
return ids
}
package player
import (
"go.uber.org/zap"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game/datastore"
"terraforming-mars-backend/internal/game/shared"
"terraforming-mars-backend/internal/logger"
)
// Effects manages passive effects from played cards.
type Effects struct {
ds *datastore.DataStore
gameID string
playerID string
subscriptions map[string][]events.SubscriptionID
eventBus *events.EventBusImpl
}
// NewEffects creates a new Effects view backed by the DataStore.
func NewEffects(ds *datastore.DataStore, eventBus *events.EventBusImpl, gameID, playerID string) *Effects {
return &Effects{
ds: ds,
gameID: gameID,
playerID: playerID,
subscriptions: make(map[string][]events.SubscriptionID),
eventBus: eventBus,
}
}
func (e *Effects) update(fn func(s *datastore.PlayerState)) {
if err := e.ds.UpdatePlayer(e.gameID, e.playerID, fn); err != nil {
logger.Get().Warn("Failed to update player state", zap.String("game_id", e.gameID), zap.String("player_id", e.playerID), zap.Error(err))
}
}
func (e *Effects) read(fn func(s *datastore.PlayerState)) {
if err := e.ds.ReadPlayer(e.gameID, e.playerID, fn); err != nil {
logger.Get().Warn("Failed to read player state", zap.String("game_id", e.gameID), zap.String("player_id", e.playerID), zap.Error(err))
}
}
func (e *Effects) List() []shared.CardEffect {
var effectsCopy []shared.CardEffect
e.read(func(s *datastore.PlayerState) {
effectsCopy = make([]shared.CardEffect, len(s.Effects))
copy(effectsCopy, s.Effects)
})
return effectsCopy
}
func (e *Effects) SetEffects(effects []shared.CardEffect) {
e.update(func(s *datastore.PlayerState) {
if effects == nil {
s.Effects = []shared.CardEffect{}
} else {
s.Effects = make([]shared.CardEffect, len(effects))
copy(s.Effects, effects)
}
})
}
func (e *Effects) AddEffect(effect shared.CardEffect) {
e.update(func(s *datastore.PlayerState) {
s.Effects = append(s.Effects, effect)
})
}
// RegisterSubscription tracks an event subscription for a card so it can be unsubscribed later
func (e *Effects) RegisterSubscription(cardID string, subID events.SubscriptionID) {
e.subscriptions[cardID] = append(e.subscriptions[cardID], subID)
}
// RemoveEffectsByCardID removes all effects from a specific card and unsubscribes from events
func (e *Effects) RemoveEffectsByCardID(cardID string) {
e.update(func(s *datastore.PlayerState) {
filtered := make([]shared.CardEffect, 0, len(s.Effects))
for _, effect := range s.Effects {
if effect.CardID != cardID {
filtered = append(filtered, effect)
}
}
s.Effects = filtered
})
if subs, exists := e.subscriptions[cardID]; exists {
for _, subID := range subs {
e.eventBus.Unsubscribe(subID)
}
delete(e.subscriptions, cardID)
}
}
// RemoveTemporaryEffects removes all effects that have outputs with the given temporary type.
// Returns the card IDs of removed effects.
func (e *Effects) RemoveTemporaryEffects(temporaryType string) []string {
var removedCardIDs []string
e.update(func(s *datastore.PlayerState) {
filtered := make([]shared.CardEffect, 0, len(s.Effects))
for _, effect := range s.Effects {
hasTemporary := false
for _, output := range effect.Behavior.Outputs {
if shared.GetTemporary(output) == temporaryType {
hasTemporary = true
break
}
}
if hasTemporary {
removedCardIDs = append(removedCardIDs, effect.CardID)
} else {
filtered = append(filtered, effect)
}
}
s.Effects = filtered
})
for _, cardID := range removedCardIDs {
if subs, exists := e.subscriptions[cardID]; exists {
for _, subID := range subs {
e.eventBus.Unsubscribe(subID)
}
delete(e.subscriptions, cardID)
}
}
return removedCardIDs
}
package player
import (
"terraforming-mars-backend/internal/game/shared"
"time"
)
// EntityState holds calculated state for any entity (card, action, project).
// This generic structure eliminates redundant boolean flags and works across all entity types.
// The single source of truth is the Errors slice - availability is computed from it.
type EntityState struct {
Errors []StateError
Warnings []StateWarning
Cost map[string]int
Metadata map[string]any
ComputedValues []ComputedBehaviorValue
LastCalculated time.Time
}
// ComputedBehaviorValue holds pre-computed output values for a specific behavior.
// Target uses the format "behaviors::N" where N is the behavior index.
type ComputedBehaviorValue struct {
Target string
Outputs []shared.CalculatedOutput
}
// Available returns true if there are no errors (computed, not stored).
// This prevents contradictory state between availability flags and error lists.
func (e EntityState) Available() bool {
return len(e.Errors) == 0
}
// StateError represents a specific reason why an entity is unavailable.
// Errors are categorized for UI filtering and display.
type StateError struct {
Code StateErrorCode
Category StateErrorCategory
Message string
}
// StateWarning represents a non-blocking warning about an action.
// Warnings inform the player of potential issues without preventing the action.
type StateWarning struct {
Code StateWarningCode
Message string
}
package player
import (
"go.uber.org/zap"
"terraforming-mars-backend/internal/game/datastore"
"terraforming-mars-backend/internal/game/shared"
"terraforming-mars-backend/internal/logger"
)
// GenerationalEvents tracks per-generation player events.
type GenerationalEvents struct {
ds *datastore.DataStore
gameID string
playerID string
}
func newGenerationalEvents(ds *datastore.DataStore, gameID, playerID string) *GenerationalEvents {
return &GenerationalEvents{ds: ds, gameID: gameID, playerID: playerID}
}
func (ge *GenerationalEvents) update(fn func(s *datastore.PlayerState)) {
if err := ge.ds.UpdatePlayer(ge.gameID, ge.playerID, fn); err != nil {
logger.Get().Warn("Failed to update player state", zap.String("game_id", ge.gameID), zap.String("player_id", ge.playerID), zap.Error(err))
}
}
func (ge *GenerationalEvents) read(fn func(s *datastore.PlayerState)) {
if err := ge.ds.ReadPlayer(ge.gameID, ge.playerID, fn); err != nil {
logger.Get().Warn("Failed to read player state", zap.String("game_id", ge.gameID), zap.String("player_id", ge.playerID), zap.Error(err))
}
}
func (ge *GenerationalEvents) Increment(event shared.GenerationalEvent) {
ge.update(func(s *datastore.PlayerState) {
if s.GenerationalEvents == nil {
s.GenerationalEvents = make(map[shared.GenerationalEvent]int)
}
s.GenerationalEvents[event]++
})
}
func (ge *GenerationalEvents) GetCount(event shared.GenerationalEvent) int {
var count int
ge.read(func(s *datastore.PlayerState) {
if s.GenerationalEvents == nil {
return
}
count = s.GenerationalEvents[event]
})
return count
}
func (ge *GenerationalEvents) GetAll() []shared.PlayerGenerationalEventEntry {
var entries []shared.PlayerGenerationalEventEntry
ge.read(func(s *datastore.PlayerState) {
entries = make([]shared.PlayerGenerationalEventEntry, 0, len(s.GenerationalEvents))
for event, count := range s.GenerationalEvents {
entries = append(entries, shared.PlayerGenerationalEventEntry{
Event: event,
Count: count,
})
}
})
return entries
}
func (ge *GenerationalEvents) Clear() {
ge.update(func(s *datastore.PlayerState) {
s.GenerationalEvents = make(map[shared.GenerationalEvent]int)
})
}
package player
import (
"go.uber.org/zap"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game/datastore"
"terraforming-mars-backend/internal/game/shared"
"terraforming-mars-backend/internal/logger"
)
// PlayerType represents the type of player (human or bot)
type PlayerType string
const (
PlayerTypeHuman PlayerType = "human"
PlayerTypeBot PlayerType = "bot"
)
// BotStatus represents the readiness state of a bot player
type BotStatus string
const (
BotStatusNone BotStatus = ""
BotStatusLoading BotStatus = "loading"
BotStatusReady BotStatus = "ready"
BotStatusFailed BotStatus = "failed"
BotStatusThinking BotStatus = "thinking"
)
// BotDifficulty represents the difficulty level of a bot player
type BotDifficulty string
const (
BotDifficultyNormal BotDifficulty = "normal"
BotDifficultyHard BotDifficulty = "hard"
BotDifficultyExtreme BotDifficulty = "extreme"
)
// BotSpeed represents the speed/model tier of a bot player
type BotSpeed string
const (
BotSpeedFast BotSpeed = "fast"
BotSpeedNormal BotSpeed = "normal"
BotSpeedThinker BotSpeed = "thinker"
)
// Player represents a player in the game.
type Player struct {
ds *datastore.DataStore
gameID string
playerID string
eventBus *events.EventBusImpl
hand *Hand
playedCards *PlayedCards
resources *PlayerResources
selection *Selection
actions *Actions
effects *Effects
generationalEvents *GenerationalEvents
vpGranters *VPGranters
cardStateStore *CardStateStore
}
// NewPlayer creates a new human player view backed by the DataStore.
func NewPlayer(ds *datastore.DataStore, gameID string, playerID string, eventBus *events.EventBusImpl) *Player {
return &Player{
ds: ds,
gameID: gameID,
playerID: playerID,
eventBus: eventBus,
hand: newHand(ds, eventBus, gameID, playerID),
playedCards: newPlayedCards(ds, eventBus, gameID, playerID),
resources: newResources(ds, eventBus, gameID, playerID),
selection: newSelection(ds, eventBus, gameID, playerID),
actions: NewActions(ds, gameID, playerID),
effects: NewEffects(ds, eventBus, gameID, playerID),
generationalEvents: newGenerationalEvents(ds, gameID, playerID),
vpGranters: NewVPGranters(ds, eventBus, gameID, playerID),
cardStateStore: NewCardStateStore(),
}
}
func (p *Player) update(fn func(s *datastore.PlayerState)) {
if err := p.ds.UpdatePlayer(p.gameID, p.playerID, fn); err != nil {
logger.Get().Warn("Failed to update player state", zap.String("game_id", p.gameID), zap.String("player_id", p.playerID), zap.Error(err))
}
}
func (p *Player) read(fn func(s *datastore.PlayerState)) {
if err := p.ds.ReadPlayer(p.gameID, p.playerID, fn); err != nil {
logger.Get().Warn("Failed to read player state", zap.String("game_id", p.gameID), zap.String("player_id", p.playerID), zap.Error(err))
}
}
func (p *Player) ID() string {
return p.playerID
}
func (p *Player) Name() string {
var name string
p.read(func(s *datastore.PlayerState) {
name = s.Name
})
return name
}
func (p *Player) GameID() string {
return p.gameID
}
func (p *Player) PlayerType() PlayerType {
var pt PlayerType
p.read(func(s *datastore.PlayerState) {
pt = PlayerType(s.PlayerType)
})
return pt
}
func (p *Player) IsBot() bool {
var isBot bool
p.read(func(s *datastore.PlayerState) {
isBot = s.PlayerType == string(PlayerTypeBot)
})
return isBot
}
func (p *Player) BotStatus() BotStatus {
var status BotStatus
p.read(func(s *datastore.PlayerState) {
status = BotStatus(s.BotStatus)
})
return status
}
func (p *Player) SetBotStatus(status BotStatus) {
p.update(func(s *datastore.PlayerState) {
s.BotStatus = string(status)
})
}
func (p *Player) BotDifficulty() BotDifficulty {
var diff BotDifficulty
p.read(func(s *datastore.PlayerState) {
diff = BotDifficulty(s.BotDifficulty)
})
return diff
}
func (p *Player) BotSpeed() BotSpeed {
var speed BotSpeed
p.read(func(s *datastore.PlayerState) {
speed = BotSpeed(s.BotSpeed)
})
return speed
}
func (p *Player) SetPlayerType(playerType PlayerType) {
p.update(func(s *datastore.PlayerState) {
s.PlayerType = string(playerType)
})
}
func (p *Player) SetBotDifficulty(difficulty BotDifficulty) {
p.update(func(s *datastore.PlayerState) {
s.BotDifficulty = string(difficulty)
})
}
func (p *Player) SetBotSpeed(speed BotSpeed) {
p.update(func(s *datastore.PlayerState) {
s.BotSpeed = string(speed)
})
}
func (p *Player) IsConnected() bool {
var connected bool
p.read(func(s *datastore.PlayerState) {
connected = s.Connected
})
return connected
}
func (p *Player) SetConnected(connected bool) {
p.update(func(s *datastore.PlayerState) {
s.Connected = connected
})
}
func (p *Player) CorporationID() string {
var corpID string
p.read(func(s *datastore.PlayerState) {
corpID = s.CorporationID
})
return corpID
}
func (p *Player) SetCorporationID(corporationID string) {
p.update(func(s *datastore.PlayerState) {
s.CorporationID = corporationID
})
}
func (p *Player) HasCorporation() bool {
var has bool
p.read(func(s *datastore.PlayerState) {
has = s.CorporationID != ""
})
return has
}
func (p *Player) Hand() *Hand {
return p.hand
}
func (p *Player) PlayedCards() *PlayedCards {
return p.playedCards
}
func (p *Player) Resources() *PlayerResources {
return p.resources
}
func (p *Player) Selection() *Selection {
return p.selection
}
func (p *Player) Actions() *Actions {
return p.actions
}
func (p *Player) Effects() *Effects {
return p.effects
}
func (p *Player) GenerationalEvents() *GenerationalEvents {
return p.generationalEvents
}
func (p *Player) VPGranters() *VPGranters {
return p.vpGranters
}
func (p *Player) CardStateStore() *CardStateStore {
return p.cardStateStore
}
func (p *Player) Color() string {
var color string
p.read(func(s *datastore.PlayerState) {
color = s.Color
})
return color
}
func (p *Player) SetColor(color string) {
p.update(func(s *datastore.PlayerState) {
s.Color = color
})
}
func (p *Player) HasPassed() bool {
var passed bool
p.read(func(s *datastore.PlayerState) {
passed = s.HasPassed
})
return passed
}
func (p *Player) SetPassed(passed bool) {
p.update(func(s *datastore.PlayerState) {
s.HasPassed = passed
})
}
func (p *Player) HasExited() bool {
var exited bool
p.read(func(s *datastore.PlayerState) {
exited = s.HasExited
})
return exited
}
func (p *Player) SetExited(exited bool) {
p.update(func(s *datastore.PlayerState) {
s.HasExited = exited
})
}
// PendingDemoChoices returns the player's pending demo lobby selections
func (p *Player) PendingDemoChoices() *shared.PendingDemoChoices {
var choices *shared.PendingDemoChoices
p.read(func(s *datastore.PlayerState) {
choices = s.PendingDemoChoices
})
return choices
}
// SetPendingDemoChoices stores the player's demo lobby card selections
func (p *Player) SetPendingDemoChoices(choices *shared.PendingDemoChoices) {
p.update(func(s *datastore.PlayerState) {
s.PendingDemoChoices = choices
})
}
// HasPendingDemoChoices returns true if the player has made demo lobby selections
func (p *Player) HasPendingDemoChoices() bool {
var has bool
p.read(func(s *datastore.PlayerState) {
has = s.PendingDemoChoices != nil
})
return has
}
// BonusTags returns the player's bonus tags map (tag type -> count)
func (p *Player) BonusTags() map[shared.CardTag]int {
var result map[shared.CardTag]int
p.read(func(s *datastore.PlayerState) {
result = make(map[shared.CardTag]int, len(s.BonusTags))
for k, v := range s.BonusTags {
result[k] = v
}
})
return result
}
// AddBonusTags adds bonus tags of the specified type
func (p *Player) AddBonusTags(tag shared.CardTag, count int) {
p.update(func(s *datastore.PlayerState) {
if s.BonusTags == nil {
s.BonusTags = make(map[shared.CardTag]int)
}
s.BonusTags[tag] = s.BonusTags[tag] + count
})
}
// BonusTagCount returns the number of bonus tags of the specified type
func (p *Player) BonusTagCount(tag shared.CardTag) int {
var count int
p.read(func(s *datastore.PlayerState) {
if s.BonusTags == nil {
return
}
count = s.BonusTags[tag]
})
return count
}
// EventBus returns the event bus (for use by actions that need to subscribe)
func (p *Player) EventBus() *events.EventBusImpl {
return p.eventBus
}
package player
import (
"time"
"go.uber.org/zap"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game/datastore"
"terraforming-mars-backend/internal/logger"
)
// Hand manages player card hand as a pure DataStore adapter.
type Hand struct {
ds *datastore.DataStore
eventBus *events.EventBusImpl
gameID string
playerID string
}
func newHand(ds *datastore.DataStore, eventBus *events.EventBusImpl, gameID, playerID string) *Hand {
return &Hand{
ds: ds,
eventBus: eventBus,
gameID: gameID,
playerID: playerID,
}
}
func (h *Hand) update(fn func(s *datastore.PlayerState)) {
if err := h.ds.UpdatePlayer(h.gameID, h.playerID, fn); err != nil {
logger.Get().Warn("Failed to update player state", zap.String("game_id", h.gameID), zap.String("player_id", h.playerID), zap.Error(err))
}
}
func (h *Hand) read(fn func(s *datastore.PlayerState)) {
if err := h.ds.ReadPlayer(h.gameID, h.playerID, fn); err != nil {
logger.Get().Warn("Failed to read player state", zap.String("game_id", h.gameID), zap.String("player_id", h.playerID), zap.Error(err))
}
}
func (h *Hand) Cards() []string {
var cardsCopy []string
h.read(func(s *datastore.PlayerState) {
cardsCopy = make([]string, len(s.HandCardIDs))
copy(cardsCopy, s.HandCardIDs)
})
return cardsCopy
}
func (h *Hand) CardCount() int {
var count int
h.read(func(s *datastore.PlayerState) {
count = len(s.HandCardIDs)
})
return count
}
func (h *Hand) HasCard(cardID string) bool {
var found bool
h.read(func(s *datastore.PlayerState) {
for _, id := range s.HandCardIDs {
if id == cardID {
found = true
return
}
}
})
return found
}
func (h *Hand) SetCards(cards []string) {
var cardsCopy []string
h.update(func(s *datastore.PlayerState) {
if cards == nil {
s.HandCardIDs = []string{}
} else {
s.HandCardIDs = make([]string, len(cards))
copy(s.HandCardIDs, cards)
}
cardsCopy = make([]string, len(s.HandCardIDs))
copy(cardsCopy, s.HandCardIDs)
})
if h.eventBus != nil {
events.Publish(h.eventBus, events.CardHandUpdatedEvent{
GameID: h.gameID,
PlayerID: h.playerID,
CardIDs: cardsCopy,
Timestamp: time.Now(),
})
}
}
func (h *Hand) AddCard(cardID string) {
var cardsCopy []string
h.update(func(s *datastore.PlayerState) {
s.HandCardIDs = append(s.HandCardIDs, cardID)
cardsCopy = make([]string, len(s.HandCardIDs))
copy(cardsCopy, s.HandCardIDs)
})
if h.eventBus != nil {
events.Publish(h.eventBus, events.CardAddedToHandEvent{
GameID: h.gameID,
PlayerID: h.playerID,
CardID: cardID,
Timestamp: time.Now(),
})
events.Publish(h.eventBus, events.CardHandUpdatedEvent{
GameID: h.gameID,
PlayerID: h.playerID,
CardIDs: cardsCopy,
Timestamp: time.Now(),
})
}
}
func (h *Hand) RemoveCard(cardID string) bool {
var removed bool
var cardsCopy []string
h.update(func(s *datastore.PlayerState) {
for i, id := range s.HandCardIDs {
if id == cardID {
s.HandCardIDs = append(s.HandCardIDs[:i], s.HandCardIDs[i+1:]...)
removed = true
break
}
}
cardsCopy = make([]string, len(s.HandCardIDs))
copy(cardsCopy, s.HandCardIDs)
})
if removed && h.eventBus != nil {
events.Publish(h.eventBus, events.CardHandUpdatedEvent{
GameID: h.gameID,
PlayerID: h.playerID,
CardIDs: cardsCopy,
Timestamp: time.Now(),
})
}
return removed
}
package player
import (
"time"
"go.uber.org/zap"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game/datastore"
"terraforming-mars-backend/internal/logger"
)
// PlayedCards manages all cards a player has played.
type PlayedCards struct {
ds *datastore.DataStore
eventBus *events.EventBusImpl
gameID string
playerID string
}
func newPlayedCards(ds *datastore.DataStore, eventBus *events.EventBusImpl, gameID, playerID string) *PlayedCards {
return &PlayedCards{
ds: ds,
eventBus: eventBus,
gameID: gameID,
playerID: playerID,
}
}
func (pc *PlayedCards) update(fn func(s *datastore.PlayerState)) {
if err := pc.ds.UpdatePlayer(pc.gameID, pc.playerID, fn); err != nil {
logger.Get().Warn("Failed to update player state", zap.String("game_id", pc.gameID), zap.String("player_id", pc.playerID), zap.Error(err))
}
}
func (pc *PlayedCards) read(fn func(s *datastore.PlayerState)) {
if err := pc.ds.ReadPlayer(pc.gameID, pc.playerID, fn); err != nil {
logger.Get().Warn("Failed to read player state", zap.String("game_id", pc.gameID), zap.String("player_id", pc.playerID), zap.Error(err))
}
}
// Cards returns a copy of all played cards
func (pc *PlayedCards) Cards() []string {
var cardsCopy []string
pc.read(func(s *datastore.PlayerState) {
cardsCopy = make([]string, len(s.PlayedCardIDs))
copy(cardsCopy, s.PlayedCardIDs)
})
return cardsCopy
}
// Contains checks if a specific card has been played
func (pc *PlayedCards) Contains(cardID string) bool {
var found bool
pc.read(func(s *datastore.PlayerState) {
for _, id := range s.PlayedCardIDs {
if id == cardID {
found = true
return
}
}
})
return found
}
// AddCard adds a card to played cards
func (pc *PlayedCards) AddCard(cardID, cardName, cardType string, tags []string) {
pc.update(func(s *datastore.PlayerState) {
s.PlayedCardIDs = append(s.PlayedCardIDs, cardID)
})
if pc.eventBus != nil {
events.Publish(pc.eventBus, events.CardPlayedEvent{
GameID: pc.gameID,
PlayerID: pc.playerID,
CardID: cardID,
CardName: cardName,
CardType: cardType,
Timestamp: time.Now(),
})
for _, tag := range tags {
events.Publish(pc.eventBus, events.TagPlayedEvent{
GameID: pc.gameID,
PlayerID: pc.playerID,
CardID: cardID,
CardName: cardName,
Tag: tag,
Timestamp: time.Now(),
})
}
}
}
// RemoveCard removes a card from played cards
func (pc *PlayedCards) RemoveCard(cardID string) bool {
var removed bool
pc.update(func(s *datastore.PlayerState) {
for i, id := range s.PlayedCardIDs {
if id == cardID {
s.PlayedCardIDs = append(s.PlayedCardIDs[:i], s.PlayedCardIDs[i+1:]...)
removed = true
return
}
}
})
return removed
}
// SetCards replaces all played cards
func (pc *PlayedCards) SetCards(cards []string) {
pc.update(func(s *datastore.PlayerState) {
if cards == nil {
s.PlayedCardIDs = []string{}
} else {
s.PlayedCardIDs = make([]string, len(cards))
copy(s.PlayedCardIDs, cards)
}
})
}
// Count returns the number of played cards
func (pc *PlayedCards) Count() int {
var count int
pc.read(func(s *datastore.PlayerState) {
count = len(s.PlayedCardIDs)
})
return count
}
package player
import (
"time"
"go.uber.org/zap"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game/datastore"
"terraforming-mars-backend/internal/game/shared"
"terraforming-mars-backend/internal/logger"
)
// PlayerResources manages player resources, production, and scoring.
type PlayerResources struct {
ds *datastore.DataStore
eventBus *events.EventBusImpl
gameID string
playerID string
}
func newResources(ds *datastore.DataStore, eventBus *events.EventBusImpl, gameID, playerID string) *PlayerResources {
return &PlayerResources{
ds: ds,
eventBus: eventBus,
gameID: gameID,
playerID: playerID,
}
}
func (r *PlayerResources) update(fn func(s *datastore.PlayerState)) {
if err := r.ds.UpdatePlayer(r.gameID, r.playerID, fn); err != nil {
logger.Get().Warn("Failed to update player state", zap.String("game_id", r.gameID), zap.String("player_id", r.playerID), zap.Error(err))
}
}
func (r *PlayerResources) read(fn func(s *datastore.PlayerState)) {
if err := r.ds.ReadPlayer(r.gameID, r.playerID, fn); err != nil {
logger.Get().Warn("Failed to read player state", zap.String("game_id", r.gameID), zap.String("player_id", r.playerID), zap.Error(err))
}
}
func (r *PlayerResources) Get() shared.Resources {
var res shared.Resources
r.read(func(s *datastore.PlayerState) {
res = s.Resources
})
return res
}
func (r *PlayerResources) Production() shared.Production {
var prod shared.Production
r.read(func(s *datastore.PlayerState) {
prod = s.Production
})
return prod
}
func (r *PlayerResources) TerraformRating() int {
var tr int
r.read(func(s *datastore.PlayerState) {
tr = s.TerraformRating
})
return tr
}
func (r *PlayerResources) Storage() map[string]int {
var storageCopy map[string]int
r.read(func(s *datastore.PlayerState) {
storageCopy = make(map[string]int, len(s.ResourceStorage))
for k, v := range s.ResourceStorage {
storageCopy[k] = v
}
})
return storageCopy
}
const (
baseSteelValue = 2
baseTitaniumValue = 3
)
// PaymentSubstitutes returns all payment substitutes including steel/titanium with dynamic values.
func (r *PlayerResources) PaymentSubstitutes() []shared.PaymentSubstitute {
var substitutes []shared.PaymentSubstitute
r.read(func(s *datastore.PlayerState) {
substitutes = []shared.PaymentSubstitute{
{ResourceType: shared.ResourceSteel, ConversionRate: baseSteelValue + s.ValueModifiers[shared.ResourceSteel]},
{ResourceType: shared.ResourceTitanium, ConversionRate: baseTitaniumValue + s.ValueModifiers[shared.ResourceTitanium]},
}
substitutes = append(substitutes, s.PaymentSubstitutes...)
})
return substitutes
}
func (r *PlayerResources) AddPaymentSubstitute(resourceType shared.ResourceType, conversionRate int) {
r.update(func(s *datastore.PlayerState) {
s.PaymentSubstitutes = append(s.PaymentSubstitutes, shared.PaymentSubstitute{
ResourceType: resourceType,
ConversionRate: conversionRate,
})
})
}
// ValueModifiers returns a copy of the value modifiers map
func (r *PlayerResources) ValueModifiers() map[shared.ResourceType]int {
var modifiersCopy map[shared.ResourceType]int
r.read(func(s *datastore.PlayerState) {
modifiersCopy = make(map[shared.ResourceType]int, len(s.ValueModifiers))
for k, v := range s.ValueModifiers {
modifiersCopy[k] = v
}
})
return modifiersCopy
}
// AddValueModifier adds a value modifier for a resource type
func (r *PlayerResources) AddValueModifier(resourceType shared.ResourceType, amount int) {
r.update(func(s *datastore.PlayerState) {
if s.ValueModifiers == nil {
s.ValueModifiers = make(map[shared.ResourceType]int)
}
s.ValueModifiers[resourceType] += amount
})
}
// GetValueModifier returns the total value modifier for a resource type
func (r *PlayerResources) GetValueModifier(resourceType shared.ResourceType) int {
var val int
r.read(func(s *datastore.PlayerState) {
val = s.ValueModifiers[resourceType]
})
return val
}
func (r *PlayerResources) Set(resources shared.Resources) {
r.update(func(s *datastore.PlayerState) {
s.Resources = resources
})
if r.eventBus != nil {
events.Publish(r.eventBus, events.ResourcesChangedEvent{
GameID: r.gameID,
PlayerID: r.playerID,
Changes: make(map[string]int),
Timestamp: time.Now(),
})
}
}
func (r *PlayerResources) SetProduction(production shared.Production) {
var oldProduction, newProduction shared.Production
r.update(func(s *datastore.PlayerState) {
oldProduction = s.Production
s.Production = production
newProduction = s.Production
})
if r.eventBus != nil {
resourceTypes := []struct {
name string
oldValue int
newValue int
}{
{"credits", oldProduction.Credits, newProduction.Credits},
{"steel", oldProduction.Steel, newProduction.Steel},
{"titanium", oldProduction.Titanium, newProduction.Titanium},
{"plants", oldProduction.Plants, newProduction.Plants},
{"energy", oldProduction.Energy, newProduction.Energy},
{"heat", oldProduction.Heat, newProduction.Heat},
}
for _, rt := range resourceTypes {
events.Publish(r.eventBus, events.ProductionChangedEvent{
GameID: r.gameID,
PlayerID: r.playerID,
ResourceType: rt.name,
OldProduction: rt.oldValue,
NewProduction: rt.newValue,
Timestamp: time.Now(),
})
}
}
}
func (r *PlayerResources) SetTerraformRating(tr int) {
var oldRating int
r.update(func(s *datastore.PlayerState) {
oldRating = s.TerraformRating
s.TerraformRating = tr
})
if r.eventBus != nil {
events.Publish(r.eventBus, events.TerraformRatingChangedEvent{
GameID: r.gameID,
PlayerID: r.playerID,
OldRating: oldRating,
NewRating: tr,
Timestamp: time.Now(),
})
}
}
func (r *PlayerResources) Add(changes map[shared.ResourceType]int) {
r.update(func(s *datastore.PlayerState) {
for resourceType, amount := range changes {
switch resourceType {
case shared.ResourceCredit:
s.Resources.Credits += amount
case shared.ResourceSteel:
s.Resources.Steel += amount
case shared.ResourceTitanium:
s.Resources.Titanium += amount
case shared.ResourcePlant:
s.Resources.Plants += amount
case shared.ResourceEnergy:
s.Resources.Energy += amount
case shared.ResourceHeat:
s.Resources.Heat += amount
}
}
})
if r.eventBus != nil {
changesMap := make(map[string]int, len(changes))
for resourceType, amount := range changes {
changesMap[string(resourceType)] = amount
}
events.Publish(r.eventBus, events.ResourcesChangedEvent{
GameID: r.gameID,
PlayerID: r.playerID,
Changes: changesMap,
Timestamp: time.Now(),
})
}
}
func (r *PlayerResources) AddProduction(changes map[shared.ResourceType]int) {
var oldProduction, newProduction shared.Production
r.update(func(s *datastore.PlayerState) {
oldProduction = s.Production
for resourceType, amount := range changes {
switch resourceType {
case shared.ResourceCreditProduction:
s.Production.Credits += amount
if s.Production.Credits < shared.MinCreditProduction {
s.Production.Credits = shared.MinCreditProduction
}
case shared.ResourceSteelProduction:
s.Production.Steel += amount
if s.Production.Steel < shared.MinOtherProduction {
s.Production.Steel = shared.MinOtherProduction
}
case shared.ResourceTitaniumProduction:
s.Production.Titanium += amount
if s.Production.Titanium < shared.MinOtherProduction {
s.Production.Titanium = shared.MinOtherProduction
}
case shared.ResourcePlantProduction:
s.Production.Plants += amount
if s.Production.Plants < shared.MinOtherProduction {
s.Production.Plants = shared.MinOtherProduction
}
case shared.ResourceEnergyProduction:
s.Production.Energy += amount
if s.Production.Energy < shared.MinOtherProduction {
s.Production.Energy = shared.MinOtherProduction
}
case shared.ResourceHeatProduction:
s.Production.Heat += amount
if s.Production.Heat < shared.MinOtherProduction {
s.Production.Heat = shared.MinOtherProduction
}
}
}
newProduction = s.Production
})
if r.eventBus != nil {
for resourceType := range changes {
var oldValue, newValue int
resourceName := string(resourceType)
switch resourceType {
case shared.ResourceCreditProduction:
oldValue = oldProduction.Credits
newValue = newProduction.Credits
resourceName = "credits"
case shared.ResourceSteelProduction:
oldValue = oldProduction.Steel
newValue = newProduction.Steel
resourceName = "steel"
case shared.ResourceTitaniumProduction:
oldValue = oldProduction.Titanium
newValue = newProduction.Titanium
resourceName = "titanium"
case shared.ResourcePlantProduction:
oldValue = oldProduction.Plants
newValue = newProduction.Plants
resourceName = "plants"
case shared.ResourceEnergyProduction:
oldValue = oldProduction.Energy
newValue = newProduction.Energy
resourceName = "energy"
case shared.ResourceHeatProduction:
oldValue = oldProduction.Heat
newValue = newProduction.Heat
resourceName = "heat"
}
events.Publish(r.eventBus, events.ProductionChangedEvent{
GameID: r.gameID,
PlayerID: r.playerID,
ResourceType: resourceName,
OldProduction: oldValue,
NewProduction: newValue,
Timestamp: time.Now(),
})
}
}
}
func (r *PlayerResources) UpdateTerraformRating(delta int) {
var oldRating, newRating int
r.update(func(s *datastore.PlayerState) {
oldRating = s.TerraformRating
s.TerraformRating += delta
newRating = s.TerraformRating
})
if r.eventBus != nil {
events.Publish(r.eventBus, events.TerraformRatingChangedEvent{
GameID: r.gameID,
PlayerID: r.playerID,
OldRating: oldRating,
NewRating: newRating,
Timestamp: time.Now(),
})
}
}
// AddToStorage adds resources to a specific card's storage
func (r *PlayerResources) AddToStorage(cardID string, amount int) {
var oldAmount, newAmount int
r.update(func(s *datastore.PlayerState) {
if s.ResourceStorage == nil {
s.ResourceStorage = make(map[string]int)
}
oldAmount = s.ResourceStorage[cardID]
s.ResourceStorage[cardID] += amount
newAmount = s.ResourceStorage[cardID]
})
if r.eventBus != nil {
events.Publish(r.eventBus, events.ResourceStorageChangedEvent{
GameID: r.gameID,
PlayerID: r.playerID,
CardID: cardID,
OldAmount: oldAmount,
NewAmount: newAmount,
Timestamp: time.Now(),
})
}
}
// GetCardStorage returns the amount of resources stored on a specific card
func (r *PlayerResources) GetCardStorage(cardID string) int {
var val int
r.read(func(s *datastore.PlayerState) {
val = s.ResourceStorage[cardID]
})
return val
}
// RemoveCardStorage removes the storage entry for a specific card
func (r *PlayerResources) RemoveCardStorage(cardID string) {
r.update(func(s *datastore.PlayerState) {
delete(s.ResourceStorage, cardID)
})
}
// ClearPaymentSubstitutes removes all non-standard payment substitutes
func (r *PlayerResources) ClearPaymentSubstitutes() {
r.update(func(s *datastore.PlayerState) {
s.PaymentSubstitutes = []shared.PaymentSubstitute{}
})
}
// ClearValueModifiers resets all value modifiers to zero
func (r *PlayerResources) ClearValueModifiers() {
r.update(func(s *datastore.PlayerState) {
s.ValueModifiers = make(map[shared.ResourceType]int)
})
}
// AddStoragePaymentSubstitute registers a card's storage resources as usable for payment
func (r *PlayerResources) AddStoragePaymentSubstitute(sub shared.StoragePaymentSubstitute) {
r.update(func(s *datastore.PlayerState) {
s.StoragePaymentSubstitutes = append(s.StoragePaymentSubstitutes, sub)
})
}
// StoragePaymentSubstitutes returns all storage payment substitutes
func (r *PlayerResources) StoragePaymentSubstitutes() []shared.StoragePaymentSubstitute {
var result []shared.StoragePaymentSubstitute
r.read(func(s *datastore.PlayerState) {
result = make([]shared.StoragePaymentSubstitute, len(s.StoragePaymentSubstitutes))
copy(result, s.StoragePaymentSubstitutes)
})
return result
}
// GetStoragePaymentSubstitute returns the storage payment substitute for a specific card, or nil
func (r *PlayerResources) GetStoragePaymentSubstitute(cardID string) *shared.StoragePaymentSubstitute {
var result *shared.StoragePaymentSubstitute
r.read(func(s *datastore.PlayerState) {
for _, sub := range s.StoragePaymentSubstitutes {
if sub.CardID == cardID {
subCopy := sub
result = &subCopy
return
}
}
})
return result
}
package player
import (
"go.uber.org/zap"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game/datastore"
"terraforming-mars-backend/internal/game/shared"
"terraforming-mars-backend/internal/logger"
)
// Selection manages player-specific card selection state.
type Selection struct {
ds *datastore.DataStore
eventBus *events.EventBusImpl
gameID string
playerID string
}
func newSelection(ds *datastore.DataStore, eventBus *events.EventBusImpl, gameID, playerID string) *Selection {
return &Selection{
ds: ds,
eventBus: eventBus,
gameID: gameID,
playerID: playerID,
}
}
func (s *Selection) update(fn func(st *datastore.PlayerState)) {
if err := s.ds.UpdatePlayer(s.gameID, s.playerID, fn); err != nil {
logger.Get().Warn("Failed to update player state", zap.String("game_id", s.gameID), zap.String("player_id", s.playerID), zap.Error(err))
}
if s.eventBus != nil {
events.Publish(s.eventBus, events.PlayerSelectionChangedEvent{
GameID: s.gameID,
PlayerID: s.playerID,
})
}
}
func (s *Selection) read(fn func(st *datastore.PlayerState)) {
if err := s.ds.ReadPlayer(s.gameID, s.playerID, fn); err != nil {
logger.Get().Warn("Failed to read player state", zap.String("game_id", s.gameID), zap.String("player_id", s.playerID), zap.Error(err))
}
}
func (s *Selection) GetSelectCorporationPhase() *shared.SelectCorporationPhase {
var phase *shared.SelectCorporationPhase
s.read(func(st *datastore.PlayerState) {
phase = st.SelectCorporationPhase
})
return phase
}
func (s *Selection) SetSelectCorporationPhase(phase *shared.SelectCorporationPhase) {
s.update(func(st *datastore.PlayerState) {
st.SelectCorporationPhase = phase
})
}
func (s *Selection) GetSelectStartingCardsPhase() *shared.SelectStartingCardsPhase {
var phase *shared.SelectStartingCardsPhase
s.read(func(st *datastore.PlayerState) {
phase = st.SelectStartingCardsPhase
})
return phase
}
func (s *Selection) SetSelectStartingCardsPhase(phase *shared.SelectStartingCardsPhase) {
s.update(func(st *datastore.PlayerState) {
st.SelectStartingCardsPhase = phase
})
}
func (s *Selection) GetSelectPreludeCardsPhase() *shared.SelectPreludeCardsPhase {
var phase *shared.SelectPreludeCardsPhase
s.read(func(st *datastore.PlayerState) {
phase = st.SelectPreludeCardsPhase
})
return phase
}
func (s *Selection) SetSelectPreludeCardsPhase(phase *shared.SelectPreludeCardsPhase) {
s.update(func(st *datastore.PlayerState) {
st.SelectPreludeCardsPhase = phase
})
}
func (s *Selection) GetPendingCardSelection() *shared.PendingCardSelection {
var sel *shared.PendingCardSelection
s.read(func(st *datastore.PlayerState) {
sel = st.PendingCardSelection
})
return sel
}
func (s *Selection) SetPendingCardSelection(selection *shared.PendingCardSelection) {
s.update(func(st *datastore.PlayerState) {
st.PendingCardSelection = selection
})
}
func (s *Selection) GetPendingCardDrawSelection() *shared.PendingCardDrawSelection {
var sel *shared.PendingCardDrawSelection
s.read(func(st *datastore.PlayerState) {
sel = st.PendingCardDrawSelection
})
return sel
}
func (s *Selection) SetPendingCardDrawSelection(selection *shared.PendingCardDrawSelection) {
s.update(func(st *datastore.PlayerState) {
st.PendingCardDrawSelection = selection
})
}
func (s *Selection) GetPendingCardDiscardSelection() *shared.PendingCardDiscardSelection {
var sel *shared.PendingCardDiscardSelection
s.read(func(st *datastore.PlayerState) {
sel = st.PendingCardDiscardSelection
})
return sel
}
func (s *Selection) SetPendingCardDiscardSelection(selection *shared.PendingCardDiscardSelection) {
s.update(func(st *datastore.PlayerState) {
st.PendingCardDiscardSelection = selection
})
}
func (s *Selection) GetPendingBehaviorChoiceSelection() *shared.PendingBehaviorChoiceSelection {
var sel *shared.PendingBehaviorChoiceSelection
s.read(func(st *datastore.PlayerState) {
sel = st.PendingBehaviorChoiceSelection
})
return sel
}
func (s *Selection) SetPendingBehaviorChoiceSelection(selection *shared.PendingBehaviorChoiceSelection) {
s.update(func(st *datastore.PlayerState) {
st.PendingBehaviorChoiceSelection = selection
})
}
func (s *Selection) GetPendingStealTargetSelection() *shared.PendingStealTargetSelection {
var sel *shared.PendingStealTargetSelection
s.read(func(st *datastore.PlayerState) {
sel = st.PendingStealTargetSelection
})
return sel
}
func (s *Selection) SetPendingStealTargetSelection(selection *shared.PendingStealTargetSelection) {
s.update(func(st *datastore.PlayerState) {
st.PendingStealTargetSelection = selection
})
}
func (s *Selection) GetPendingColonyResourceSelection() *shared.PendingColonyResourceSelection {
var sel *shared.PendingColonyResourceSelection
s.read(func(st *datastore.PlayerState) {
sel = st.PendingColonyResourceSelection
})
return sel
}
func (s *Selection) SetPendingColonyResourceSelection(selection *shared.PendingColonyResourceSelection) {
s.update(func(st *datastore.PlayerState) {
st.PendingColonyResourceSelection = selection
})
}
func (s *Selection) GetPendingColonyResourceQueue() []shared.PendingColonyResourceSelection {
var queue []shared.PendingColonyResourceSelection
s.read(func(st *datastore.PlayerState) {
queue = st.PendingColonyResourceQueue
})
return queue
}
func (s *Selection) AppendPendingColonyResource(selection shared.PendingColonyResourceSelection) {
s.update(func(st *datastore.PlayerState) {
st.PendingColonyResourceQueue = append(st.PendingColonyResourceQueue, selection)
})
}
func (s *Selection) PopPendingColonyResource() *shared.PendingColonyResourceSelection {
// Check first without triggering event
var hasItems bool
s.read(func(st *datastore.PlayerState) {
hasItems = len(st.PendingColonyResourceQueue) > 0
})
if !hasItems {
return nil
}
var result *shared.PendingColonyResourceSelection
s.update(func(st *datastore.PlayerState) {
if len(st.PendingColonyResourceQueue) == 0 {
return
}
first := st.PendingColonyResourceQueue[0]
result = &first
st.PendingColonyResourceQueue = st.PendingColonyResourceQueue[1:]
})
return result
}
func (s *Selection) GetPendingAwardFundSelection() *shared.PendingAwardFundSelection {
var sel *shared.PendingAwardFundSelection
s.read(func(st *datastore.PlayerState) {
sel = st.PendingAwardFundSelection
})
return sel
}
func (s *Selection) SetPendingAwardFundSelection(selection *shared.PendingAwardFundSelection) {
s.update(func(st *datastore.PlayerState) {
st.PendingAwardFundSelection = selection
})
}
func (s *Selection) GetPendingColonySelection() *shared.PendingColonySelection {
var sel *shared.PendingColonySelection
s.read(func(st *datastore.PlayerState) {
sel = st.PendingColonySelection
})
return sel
}
func (s *Selection) SetPendingColonySelection(selection *shared.PendingColonySelection) {
s.update(func(st *datastore.PlayerState) {
st.PendingColonySelection = selection
})
}
func (s *Selection) GetPendingFreeTradeSelection() *shared.PendingFreeTradeSelection {
var sel *shared.PendingFreeTradeSelection
s.read(func(st *datastore.PlayerState) {
sel = st.PendingFreeTradeSelection
})
return sel
}
func (s *Selection) SetPendingFreeTradeSelection(selection *shared.PendingFreeTradeSelection) {
s.update(func(st *datastore.PlayerState) {
st.PendingFreeTradeSelection = selection
})
}
// HasPendingSelection returns true if the player has any pending action selection.
// Does not include setup-phase selections (corporation, starting cards, prelude, production).
func (s *Selection) HasPendingSelection() bool {
var has bool
s.read(func(st *datastore.PlayerState) {
has = st.PendingCardSelection != nil ||
st.PendingCardDrawSelection != nil ||
st.PendingCardDiscardSelection != nil ||
st.PendingBehaviorChoiceSelection != nil ||
st.PendingStealTargetSelection != nil ||
len(st.PendingColonyResourceQueue) > 0 ||
st.PendingAwardFundSelection != nil ||
st.PendingColonySelection != nil ||
st.PendingFreeTradeSelection != nil
})
return has
}
package player
import (
"time"
"go.uber.org/zap"
"terraforming-mars-backend/internal/events"
"terraforming-mars-backend/internal/game/datastore"
"terraforming-mars-backend/internal/game/shared"
"terraforming-mars-backend/internal/logger"
)
// VPRecalculationContext provides data needed to recalculate VP
type VPRecalculationContext interface {
GetCardStorage(playerID string, cardID string) int
CountPlayerTagsByType(playerID string, tagType shared.CardTag) int
CountAllTilesOfType(tileType shared.ResourceType) int
CountPlayerTilesOfType(playerID string, tileType shared.ResourceType) int
CountAdjacentTilesForCard(cardID string, tileType shared.ResourceType) int
CountAdjacentTilesToTileType(playerID string, countType, adjacentToType shared.ResourceType) int
CountAllColonies() int
}
// VPGranters manages VP-granting cards.
type VPGranters struct {
ds *datastore.DataStore
eventBus *events.EventBusImpl
gameID string
playerID string
}
// NewVPGranters creates a new VPGranters view backed by the DataStore.
func NewVPGranters(ds *datastore.DataStore, eventBus *events.EventBusImpl, gameID, playerID string) *VPGranters {
return &VPGranters{
ds: ds,
eventBus: eventBus,
gameID: gameID,
playerID: playerID,
}
}
func (vg *VPGranters) update(fn func(s *datastore.PlayerState)) {
if err := vg.ds.UpdatePlayer(vg.gameID, vg.playerID, fn); err != nil {
logger.Get().Warn("Failed to update player state", zap.String("game_id", vg.gameID), zap.String("player_id", vg.playerID), zap.Error(err))
}
}
func (vg *VPGranters) read(fn func(s *datastore.PlayerState)) {
if err := vg.ds.ReadPlayer(vg.gameID, vg.playerID, fn); err != nil {
logger.Get().Warn("Failed to read player state", zap.String("game_id", vg.gameID), zap.String("player_id", vg.playerID), zap.Error(err))
}
}
func (vg *VPGranters) Add(granter shared.VPGranter) {
vg.update(func(s *datastore.PlayerState) {
s.VPGranters = append(s.VPGranters, granter)
})
}
func (vg *VPGranters) Prepend(granter shared.VPGranter) {
vg.update(func(s *datastore.PlayerState) {
s.VPGranters = append([]shared.VPGranter{granter}, s.VPGranters...)
})
}
// RemoveByCardID removes all VP granters for the given card ID.
func (vg *VPGranters) RemoveByCardID(cardID string) {
vg.update(func(s *datastore.PlayerState) {
filtered := s.VPGranters[:0]
for _, g := range s.VPGranters {
if g.CardID != cardID {
filtered = append(filtered, g)
}
}
s.VPGranters = filtered
})
}
func (vg *VPGranters) GetAll() []shared.VPGranter {
var result []shared.VPGranter
vg.read(func(s *datastore.PlayerState) {
result = make([]shared.VPGranter, len(s.VPGranters))
copy(result, s.VPGranters)
})
return result
}
func (vg *VPGranters) TotalComputedVP() int {
var total int
vg.read(func(s *datastore.PlayerState) {
for _, g := range s.VPGranters {
total += g.ComputedValue
}
})
return total
}
const vpTargetSelfCard = "self-card"
func (vg *VPGranters) RecalculateAll(ctx VPRecalculationContext) {
var oldTotal, newTotal int
vg.update(func(s *datastore.PlayerState) {
for _, g := range s.VPGranters {
oldTotal += g.ComputedValue
}
for i := range s.VPGranters {
s.VPGranters[i].ComputedValue = evaluateGranterVP(&s.VPGranters[i], vg.playerID, ctx)
}
for _, g := range s.VPGranters {
newTotal += g.ComputedValue
}
})
if oldTotal != newTotal {
events.Publish(vg.eventBus, events.VictoryPointsChangedEvent{
GameID: vg.gameID,
PlayerID: vg.playerID,
OldPoints: oldTotal,
NewPoints: newTotal,
Source: "vp-granters",
Timestamp: time.Now(),
})
}
}
func evaluateGranterVP(granter *shared.VPGranter, playerID string, ctx VPRecalculationContext) int {
total := 0
for _, cond := range granter.VPConditions {
total += evaluateVPCondition(cond, granter.CardID, playerID, ctx)
}
return total
}
func evaluateVPCondition(cond shared.VPCondition, cardID string, playerID string, ctx VPRecalculationContext) int {
switch cond.Condition {
case "fixed", "once":
return cond.Amount
case "per":
if cond.Per == nil {
return 0
}
count := countPerCondition(cond.Per, cardID, playerID, ctx)
if cond.Per.Amount <= 0 {
return 0
}
triggers := count / cond.Per.Amount
if cond.MaxTrigger != nil && *cond.MaxTrigger >= 0 && triggers > *cond.MaxTrigger {
triggers = *cond.MaxTrigger
}
return cond.Amount * triggers
default:
return 0
}
}
func countPerCondition(per *shared.PerCondition, cardID string, playerID string, ctx VPRecalculationContext) int {
if per.Target != nil && *per.Target == vpTargetSelfCard {
return ctx.GetCardStorage(playerID, cardID)
}
if per.AdjacentToSelfTile {
return ctx.CountAdjacentTilesForCard(cardID, per.ResourceType)
}
if per.AdjacentToTileType != nil {
return ctx.CountAdjacentTilesToTileType(playerID, per.ResourceType, *per.AdjacentToTileType)
}
if per.Tag != nil {
return ctx.CountPlayerTagsByType(playerID, *per.Tag)
}
switch per.ResourceType {
case shared.ResourceCityTile:
if per.Target != nil && *per.Target == "self-player" {
return ctx.CountPlayerTilesOfType(playerID, shared.ResourceCityTile)
}
return ctx.CountAllTilesOfType(per.ResourceType)
case shared.ResourceGreeneryTile:
if per.Target != nil && *per.Target == "self-player" {
return ctx.CountPlayerTilesOfType(playerID, shared.ResourceGreeneryTile)
}
return ctx.CountAllTilesOfType(per.ResourceType)
case shared.ResourceOceanTile:
return ctx.CountAllTilesOfType(per.ResourceType)
case shared.ResourceColony:
return ctx.CountAllColonies()
default:
return ctx.CountPlayerTagsByType(playerID, shared.CardTag(per.ResourceType))
}
}
package projectfunding
import "terraforming-mars-backend/internal/game/shared"
// ProjectDefinition is the static template loaded from JSON
type ProjectDefinition struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Seats []SeatDefinition `json:"seats"`
RewardTiers []RewardTier `json:"rewardTiers"`
CompletionEffect CompletionEffect `json:"completionEffect"`
Style shared.Style `json:"style"`
}
// SeatDefinition represents one purchasable seat in a project
type SeatDefinition struct {
Cost int `json:"cost"`
PaymentSubstitutes []PaymentSubstitute `json:"paymentSubstitutes,omitempty"`
}
// PaymentSubstitute allows a resource to be used instead of credits at a conversion rate
type PaymentSubstitute struct {
ResourceType string `json:"resourceType"`
ConversionRate int `json:"conversionRate"`
}
// RewardTier defines rewards granted to a funder based on seats owned when project completes
type RewardTier struct {
SeatsOwned int `json:"seatsOwned"`
Rewards []Output `json:"rewards"`
}
// CompletionEffect defines rewards granted to ALL players when a project completes
type CompletionEffect struct {
Description string `json:"description"`
Rewards []Output `json:"rewards"`
GlobalEffects []GlobalOutput `json:"globalEffects,omitempty"`
}
// GlobalOutput represents a one-time game-wide effect on project completion
type GlobalOutput struct {
Type string `json:"type"` // "temperature", "oxygen", "freeze-turn-order", "production-choice", "card-draw"
Amount int `json:"amount,omitempty"` // used for card-draw (N cards)
}
// Output represents a resource gain (type + amount)
type Output struct {
Type string `json:"type"`
Amount int `json:"amount"`
}
// FindBestTier returns the highest reward tier the player qualifies for based on seats owned.
func FindBestTier(tiers []RewardTier, seatsOwned int) *RewardTier {
var best *RewardTier
for i := range tiers {
if tiers[i].SeatsOwned <= seatsOwned {
if best == nil || tiers[i].SeatsOwned > best.SeatsOwned {
best = &tiers[i]
}
}
}
return best
}
// ProjectState is the runtime mutable state per project in a game
type ProjectState struct {
DefinitionID string
SeatOwners []string // PlayerIDs in purchase order (same player can appear multiple times)
IsCompleted bool
}
package shared
import "encoding/json"
// ChoicePolicyType identifies the kind of choice policy.
type ChoicePolicyType string
const (
ChoicePolicyTypeLowest ChoicePolicyType = "lowest"
ChoicePolicyTypeAuto ChoicePolicyType = "auto"
)
// ChoicePolicySelect describes an auto-selection rule: pick Option when count satisfies MinMax.
type ChoicePolicySelect struct {
Option int `json:"option"`
MinMax MinMax `json:"minMax"`
ResourceType string `json:"resourceType"`
Tag *CardTag `json:"tag,omitempty"`
}
// ChoicePolicy governs how choices are filtered or auto-selected for a behavior.
type ChoicePolicy struct {
Type ChoicePolicyType `json:"type"`
Default *int `json:"default,omitempty"`
Select *ChoicePolicySelect `json:"select,omitempty"`
}
// CardBehavior represents card behaviors (immediate and repeatable)
type CardBehavior struct {
Description string `json:"description,omitempty" ts:"string | undefined"`
Triggers []Trigger `json:"triggers,omitempty"`
Inputs []BehaviorCondition `json:"inputs,omitempty"`
Outputs []BehaviorCondition `json:"outputs,omitempty"`
Choices []Choice `json:"choices,omitempty"`
ChoicePolicy *ChoicePolicy `json:"choicePolicy,omitempty"`
GenerationalEventRequirements []GenerationalEventRequirement `json:"generationalEventRequirements,omitempty" ts:"GenerationalEventRequirement[] | undefined"`
Group string `json:"group,omitempty" ts:"string | undefined"`
}
// DeepCopy creates a deep copy of the CardBehavior
func (cb CardBehavior) DeepCopy() CardBehavior {
var result CardBehavior
result.Description = cb.Description
result.Group = cb.Group
if cb.ChoicePolicy != nil {
cp := *cb.ChoicePolicy
if cp.Default != nil {
d := *cp.Default
cp.Default = &d
}
if cp.Select != nil {
s := *cp.Select
if s.MinMax.Min != nil {
v := *s.MinMax.Min
s.MinMax.Min = &v
}
if s.MinMax.Max != nil {
v := *s.MinMax.Max
s.MinMax.Max = &v
}
if s.Tag != nil {
t := *s.Tag
s.Tag = &t
}
cp.Select = &s
}
result.ChoicePolicy = &cp
}
if cb.Triggers != nil {
result.Triggers = make([]Trigger, len(cb.Triggers))
for i, trigger := range cb.Triggers {
result.Triggers[i] = trigger
}
}
if cb.Inputs != nil {
result.Inputs = make([]BehaviorCondition, len(cb.Inputs))
for i, input := range cb.Inputs {
result.Inputs[i] = input.deepCopyCondition()
}
}
if cb.Outputs != nil {
result.Outputs = make([]BehaviorCondition, len(cb.Outputs))
for i, output := range cb.Outputs {
result.Outputs[i] = output.deepCopyCondition()
}
}
if cb.Choices != nil {
result.Choices = make([]Choice, len(cb.Choices))
for i, choice := range cb.Choices {
choiceCopy := Choice{}
if choice.Inputs != nil {
choiceCopy.Inputs = make([]BehaviorCondition, len(choice.Inputs))
for j, input := range choice.Inputs {
choiceCopy.Inputs[j] = input.deepCopyCondition()
}
}
if choice.Outputs != nil {
choiceCopy.Outputs = make([]BehaviorCondition, len(choice.Outputs))
for j, output := range choice.Outputs {
choiceCopy.Outputs[j] = output.deepCopyCondition()
}
}
if choice.Requirements != nil {
items := make([]ChoiceRequirement, len(choice.Requirements.Items))
copy(items, choice.Requirements.Items)
choiceCopy.Requirements = &ChoiceRequirements{Items: items}
}
result.Choices[i] = choiceCopy
}
}
if cb.GenerationalEventRequirements != nil {
result.GenerationalEventRequirements = make([]GenerationalEventRequirement, len(cb.GenerationalEventRequirements))
for i, req := range cb.GenerationalEventRequirements {
result.GenerationalEventRequirements[i] = deepCopyGenerationalEventRequirement(req)
}
}
return result
}
// ExtractInputsOutputs extracts the combined inputs and outputs for a behavior,
// optionally incorporating a selected choice. Returns base + choice inputs/outputs.
// If choiceIndex is nil or out of range, only base inputs/outputs are returned.
func (cb CardBehavior) ExtractInputsOutputs(choiceIndex *int) (inputs []BehaviorCondition, outputs []BehaviorCondition) {
if len(cb.Inputs) > 0 {
inputs = make([]BehaviorCondition, len(cb.Inputs))
copy(inputs, cb.Inputs)
}
if len(cb.Outputs) > 0 {
outputs = make([]BehaviorCondition, len(cb.Outputs))
copy(outputs, cb.Outputs)
}
if choiceIndex != nil && *choiceIndex >= 0 && *choiceIndex < len(cb.Choices) {
selectedChoice := cb.Choices[*choiceIndex]
// Append choice inputs to base inputs
if len(selectedChoice.Inputs) > 0 {
inputs = append(inputs, selectedChoice.Inputs...)
}
// Append choice outputs to base outputs
if len(selectedChoice.Outputs) > 0 {
outputs = append(outputs, selectedChoice.Outputs...)
}
}
return inputs, outputs
}
// productionResourceTypes lists all production resource types for policy evaluation.
var productionResourceTypes = []ResourceType{
ResourceCreditProduction,
ResourceSteelProduction,
ResourceTitaniumProduction,
ResourcePlantProduction,
ResourceEnergyProduction,
ResourceHeatProduction,
}
// FilterChoiceIndicesByPolicy returns the indices of choices that are valid under the given policy.
// For nil policy, all indices are returned.
// For "lowest", only choices whose production output is at the player's minimum production level.
func FilterChoiceIndicesByPolicy(choices []Choice, policy *ChoicePolicy, production Production) []int {
if policy == nil {
return allChoiceIndices(choices)
}
switch policy.Type {
case ChoicePolicyTypeLowest:
return filterLowestProductionChoices(choices, production)
case ChoicePolicyTypeAuto:
return allChoiceIndices(choices)
default:
return allChoiceIndices(choices)
}
}
// IsChoiceValidForPolicy checks whether a specific choice index is valid under the given policy.
func IsChoiceValidForPolicy(choiceIndex int, choices []Choice, policy *ChoicePolicy, production Production) bool {
if policy == nil || choiceIndex < 0 || choiceIndex >= len(choices) {
return choiceIndex >= 0 && choiceIndex < len(choices)
}
validIndices := FilterChoiceIndicesByPolicy(choices, policy, production)
for _, idx := range validIndices {
if idx == choiceIndex {
return true
}
}
return false
}
func allChoiceIndices(choices []Choice) []int {
indices := make([]int, len(choices))
for i := range choices {
indices[i] = i
}
return indices
}
// AutoSelectChoiceIndex returns an auto-selected choice index for "auto" policies.
// The count parameter is the resolved count for the policy's resource/tag type.
// Returns -1 if the policy is nil or not an auto policy.
func AutoSelectChoiceIndex(policy *ChoicePolicy, count int) int {
if policy == nil || policy.Type != ChoicePolicyTypeAuto || policy.Select == nil {
return -1
}
sel := policy.Select
if sel.MinMax.Min != nil && count >= *sel.MinMax.Min {
return sel.Option
}
if sel.MinMax.Max != nil && count <= *sel.MinMax.Max {
return sel.Option
}
if policy.Default != nil {
return *policy.Default
}
return -1
}
func filterLowestProductionChoices(choices []Choice, production Production) []int {
// Find the minimum production value across all 6 types
minValue := production.Credits
for _, rt := range productionResourceTypes {
val := production.GetAmount(rt)
if val < minValue {
minValue = val
}
}
// Return indices of choices whose production output type is at the minimum level
var valid []int
for i, choice := range choices {
for _, output := range choice.Outputs {
rt := output.GetResourceType()
if IsProductionResourceType(rt) && production.GetAmount(rt) == minValue {
valid = append(valid, i)
break
}
}
}
return valid
}
// IsProductionResourceType returns true if the resource type is a production type.
func IsProductionResourceType(rt ResourceType) bool {
switch rt {
case ResourceCreditProduction, ResourceSteelProduction, ResourceTitaniumProduction,
ResourcePlantProduction, ResourceEnergyProduction, ResourceHeatProduction:
return true
default:
return false
}
}
// UnmarshalJSON implements custom JSON unmarshaling for CardBehavior.
// This is needed because []BehaviorCondition (interface slices) cannot be
// directly unmarshaled. We unmarshal into []resourceConditionJSON first, then
// convert to []BehaviorCondition with pointer elements.
func (cb *CardBehavior) UnmarshalJSON(data []byte) error {
type cardBehaviorJSON struct {
Description string `json:"description,omitempty"`
Triggers []Trigger `json:"triggers,omitempty"`
Inputs []resourceConditionJSON `json:"inputs,omitempty"`
Outputs []resourceConditionJSON `json:"outputs,omitempty"`
Choices []choiceJSON `json:"choices,omitempty"`
ChoicePolicy *ChoicePolicy `json:"choicePolicy,omitempty"`
GenerationalEventRequirements []GenerationalEventRequirement `json:"generationalEventRequirements,omitempty"`
Group string `json:"group,omitempty"`
}
var raw cardBehaviorJSON
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
cb.Description = raw.Description
cb.Triggers = raw.Triggers
cb.ChoicePolicy = raw.ChoicePolicy
cb.GenerationalEventRequirements = raw.GenerationalEventRequirements
cb.Group = raw.Group
cb.Inputs = resourceConditionsToInterface(raw.Inputs)
cb.Outputs = resourceConditionsToInterface(raw.Outputs)
cb.Choices = make([]Choice, len(raw.Choices))
for i, c := range raw.Choices {
cb.Choices[i] = Choice{
Inputs: resourceConditionsToInterface(c.Inputs),
Outputs: resourceConditionsToInterface(c.Outputs),
Requirements: c.Requirements,
}
}
return nil
}
// choiceJSON is the JSON deserialization format for Choice (uses flat resourceConditionJSON).
type choiceJSON struct {
Inputs []resourceConditionJSON `json:"inputs,omitempty"`
Outputs []resourceConditionJSON `json:"outputs,omitempty"`
Requirements *ChoiceRequirements `json:"requirements,omitempty"`
}
// resourceConditionsToInterface converts a flat []resourceConditionJSON to []BehaviorCondition.
func resourceConditionsToInterface(rcs []resourceConditionJSON) []BehaviorCondition {
if len(rcs) == 0 {
return nil
}
result := make([]BehaviorCondition, len(rcs))
for i := range rcs {
result[i] = categorizeCondition(rcs[i])
}
return result
}
func deepCopyGenerationalEventRequirement(req GenerationalEventRequirement) GenerationalEventRequirement {
result := GenerationalEventRequirement{
Event: req.Event,
}
if req.Count != nil {
countCopy := MinMax{}
if req.Count.Min != nil {
minCopy := *req.Count.Min
countCopy.Min = &minCopy
}
if req.Count.Max != nil {
maxCopy := *req.Count.Max
countCopy.Max = &maxCopy
}
result.Count = &countCopy
}
if req.Target != nil {
targetCopy := *req.Target
result.Target = &targetCopy
}
return result
}
package shared
// BehaviorCondition is the interface for all resource condition types (inputs and outputs).
// Implemented by typed category structs (BasicResourceCondition, ProductionCondition, etc.).
type BehaviorCondition interface {
GetResourceType() ResourceType
GetAmount() int
GetTarget() string
SetAmount(int)
deepCopyCondition() BehaviorCondition
isBehaviorCondition()
}
// ConditionBase holds the common fields shared by all condition categories.
// Category-specific structs embed this.
type ConditionBase struct {
ResourceType ResourceType `json:"type"`
Amount int `json:"amount"`
Target string `json:"target"`
}
func (b *ConditionBase) GetResourceType() ResourceType { return b.ResourceType }
func (b *ConditionBase) GetAmount() int { return b.Amount }
func (b *ConditionBase) GetTarget() string { return b.Target }
func (b *ConditionBase) SetAmount(a int) { b.Amount = a }
// CopyCondition creates a deep copy of a BehaviorCondition.
func CopyCondition(bc BehaviorCondition) BehaviorCondition {
return bc.deepCopyCondition()
}
// GetPerCondition extracts the Per field from a typed condition, or nil.
func GetPerCondition(bc BehaviorCondition) *PerCondition {
switch c := bc.(type) {
case *BasicResourceCondition:
return c.Per
case *ProductionCondition:
return c.Per
case *GlobalParameterCondition:
return c.Per
case *CardStorageCondition:
return c.Per
case *MiscCondition:
return c.Per
default:
return nil
}
}
// IsVariableAmount returns whether a condition has VariableAmount set.
func IsVariableAmount(bc BehaviorCondition) bool {
switch c := bc.(type) {
case *BasicResourceCondition:
return c.VariableAmount
case *ProductionCondition:
return c.VariableAmount
case *CardStorageCondition:
return c.VariableAmount
case *CardOperationCondition:
return c.VariableAmount
default:
return false
}
}
// IsOptional returns whether a condition has Optional set.
func IsOptional(bc BehaviorCondition) bool {
switch c := bc.(type) {
case *BasicResourceCondition:
return c.Optional
case *TilePlacementCondition:
return c.Optional
case *CardOperationCondition:
return c.Optional
case *CardStorageCondition:
return c.Optional
default:
return false
}
}
// GetPaymentAllowed extracts PaymentAllowed from a typed condition, or nil.
func GetPaymentAllowed(bc BehaviorCondition) []ResourceType {
switch c := bc.(type) {
case *BasicResourceCondition:
return c.PaymentAllowed
case *CardOperationCondition:
return c.PaymentAllowed
default:
return nil
}
}
// GetTemporary extracts the Temporary field from a typed condition, or empty string.
func GetTemporary(bc BehaviorCondition) string {
switch c := bc.(type) {
case *EffectCondition:
return c.Temporary
default:
return ""
}
}
// GetSelectors extracts the Selectors field from a typed condition, or nil.
func GetSelectors(bc BehaviorCondition) []Selector {
switch c := bc.(type) {
case *CardOperationCondition:
return c.Selectors
case *CardStorageCondition:
return c.Selectors
case *EffectCondition:
return c.Selectors
case *MiscCondition:
return c.Selectors
default:
return nil
}
}
// GetTileRestrictions extracts the TileRestrictions field from a typed condition, or nil.
func GetTileRestrictions(bc BehaviorCondition) *TileRestrictions {
switch c := bc.(type) {
case *TilePlacementCondition:
return c.TileRestrictions
default:
return nil
}
}
package shared
// BasicResourceCondition covers credit, steel, titanium, plant, energy, heat.
type BasicResourceCondition struct {
ConditionBase
Per *PerCondition `json:"per,omitempty"`
VariableAmount bool `json:"variableAmount,omitempty"`
MaxTrigger *int `json:"maxTrigger,omitempty"`
Optional bool `json:"optional,omitempty"`
TargetRestriction *TargetRestriction `json:"targetRestriction,omitempty"`
PaymentAllowed []ResourceType `json:"paymentAllowed,omitempty"`
}
func NewBasicResourceCondition(rt ResourceType, amount int, target string) *BasicResourceCondition {
return &BasicResourceCondition{ConditionBase: ConditionBase{ResourceType: rt, Amount: amount, Target: target}}
}
func (c *BasicResourceCondition) isBehaviorCondition() {}
func (c *BasicResourceCondition) deepCopyCondition() BehaviorCondition {
cp := *c
if c.Per != nil {
p := *c.Per
cp.Per = &p
}
if c.MaxTrigger != nil {
v := *c.MaxTrigger
cp.MaxTrigger = &v
}
if c.TargetRestriction != nil {
tr := *c.TargetRestriction
cp.TargetRestriction = &tr
}
if c.PaymentAllowed != nil {
pa := make([]ResourceType, len(c.PaymentAllowed))
copy(pa, c.PaymentAllowed)
cp.PaymentAllowed = pa
}
return &cp
}
// ProductionCondition covers credit-production through any-production.
type ProductionCondition struct {
ConditionBase
Per *PerCondition `json:"per,omitempty"`
VariableAmount bool `json:"variableAmount,omitempty"`
}
func NewProductionCondition(rt ResourceType, amount int, target string) *ProductionCondition {
return &ProductionCondition{ConditionBase: ConditionBase{ResourceType: rt, Amount: amount, Target: target}}
}
func (c *ProductionCondition) isBehaviorCondition() {}
func (c *ProductionCondition) deepCopyCondition() BehaviorCondition {
cp := *c
if c.Per != nil {
p := *c.Per
cp.Per = &p
}
return &cp
}
// TilePlacementCondition covers city/greenery/ocean/volcano/tile-placement and land-claim.
type TilePlacementCondition struct {
ConditionBase
TileRestrictions *TileRestrictions `json:"tileRestrictions,omitempty"`
TileType string `json:"tileType,omitempty"`
Optional bool `json:"optional,omitempty"`
}
func NewTilePlacementCondition(rt ResourceType, amount int, target string) *TilePlacementCondition {
return &TilePlacementCondition{ConditionBase: ConditionBase{ResourceType: rt, Amount: amount, Target: target}}
}
func (c *TilePlacementCondition) isBehaviorCondition() {}
func (c *TilePlacementCondition) deepCopyCondition() BehaviorCondition {
cp := *c
if c.TileRestrictions != nil {
tr := *c.TileRestrictions
if tr.BoardTags != nil {
bt := make([]string, len(tr.BoardTags))
copy(bt, tr.BoardTags)
tr.BoardTags = bt
}
if tr.OnBonusType != nil {
ob := make([]string, len(tr.OnBonusType))
copy(ob, tr.OnBonusType)
tr.OnBonusType = ob
}
if tr.MinAdjacentOfType != nil {
v := *tr.MinAdjacentOfType
tr.MinAdjacentOfType = &v
}
cp.TileRestrictions = &tr
}
return &cp
}
// GlobalParameterCondition covers temperature, oxygen, ocean, venus, tr, global-parameter.
type GlobalParameterCondition struct {
ConditionBase
Per *PerCondition `json:"per,omitempty"`
}
func NewGlobalParameterCondition(rt ResourceType, amount int, target string) *GlobalParameterCondition {
return &GlobalParameterCondition{ConditionBase: ConditionBase{ResourceType: rt, Amount: amount, Target: target}}
}
func (c *GlobalParameterCondition) isBehaviorCondition() {}
func (c *GlobalParameterCondition) deepCopyCondition() BehaviorCondition {
cp := *c
if c.Per != nil {
p := *c.Per
cp.Per = &p
}
return &cp
}
// CardOperationCondition covers card-draw, card-take, card-peek, card-buy, card-discard.
type CardOperationCondition struct {
ConditionBase
Selectors []Selector `json:"selectors,omitempty"`
Optional bool `json:"optional,omitempty"`
PaymentAllowed []ResourceType `json:"paymentAllowed,omitempty"`
VariableAmount bool `json:"variableAmount,omitempty"`
}
func NewCardOperationCondition(rt ResourceType, amount int, target string) *CardOperationCondition {
return &CardOperationCondition{ConditionBase: ConditionBase{ResourceType: rt, Amount: amount, Target: target}}
}
func (c *CardOperationCondition) isBehaviorCondition() {}
func (c *CardOperationCondition) deepCopyCondition() BehaviorCondition {
cp := *c
if c.Selectors != nil {
s := make([]Selector, len(c.Selectors))
copy(s, c.Selectors)
cp.Selectors = s
}
if c.PaymentAllowed != nil {
pa := make([]ResourceType, len(c.PaymentAllowed))
copy(pa, c.PaymentAllowed)
cp.PaymentAllowed = pa
}
return &cp
}
// CardStorageCondition covers microbe, animal, floater, science, asteroid, fighter, disease, card-resource.
type CardStorageCondition struct {
ConditionBase
Selectors []Selector `json:"selectors,omitempty"`
Per *PerCondition `json:"per,omitempty"`
Optional bool `json:"optional,omitempty"`
VariableAmount bool `json:"variableAmount,omitempty"`
}
func NewCardStorageCondition(rt ResourceType, amount int, target string) *CardStorageCondition {
return &CardStorageCondition{ConditionBase: ConditionBase{ResourceType: rt, Amount: amount, Target: target}}
}
func (c *CardStorageCondition) isBehaviorCondition() {}
func (c *CardStorageCondition) deepCopyCondition() BehaviorCondition {
cp := *c
if c.Selectors != nil {
s := make([]Selector, len(c.Selectors))
copy(s, c.Selectors)
cp.Selectors = s
}
if c.Per != nil {
p := *c.Per
cp.Per = &p
}
return &cp
}
// EffectCondition covers discount, payment-substitute, storage-payment-substitute, value-modifier,
// global-parameter-lenience, ignore-global-requirements, ocean-adjacency-bonus, defense, action-reuse, effect, tag.
type EffectCondition struct {
ConditionBase
Selectors []Selector `json:"selectors,omitempty"`
Temporary string `json:"temporary,omitempty"`
}
func NewEffectCondition(rt ResourceType, amount int, target string) *EffectCondition {
return &EffectCondition{ConditionBase: ConditionBase{ResourceType: rt, Amount: amount, Target: target}}
}
func (c *EffectCondition) isBehaviorCondition() {}
func (c *EffectCondition) deepCopyCondition() BehaviorCondition {
cp := *c
if c.Selectors != nil {
s := make([]Selector, len(c.Selectors))
copy(s, c.Selectors)
cp.Selectors = s
}
return &cp
}
// ColonyCondition covers colony, colony-count, colony-bonus, colony-track-step.
type ColonyCondition struct {
ConditionBase
AllowDuplicatePlayerColony bool `json:"allowDuplicatePlayerColony,omitempty"`
}
func NewColonyCondition(rt ResourceType, amount int, target string) *ColonyCondition {
return &ColonyCondition{ConditionBase: ConditionBase{ResourceType: rt, Amount: amount, Target: target}}
}
func (c *ColonyCondition) isBehaviorCondition() {}
func (c *ColonyCondition) deepCopyCondition() BehaviorCondition {
cp := *c
return &cp
}
// TileModificationCondition covers tile-destruction and tile-replacement.
type TileModificationCondition struct {
ConditionBase
TileType string `json:"tileType,omitempty"`
}
func NewTileModificationCondition(rt ResourceType, amount int, target string) *TileModificationCondition {
return &TileModificationCondition{ConditionBase: ConditionBase{ResourceType: rt, Amount: amount, Target: target}}
}
func (c *TileModificationCondition) isBehaviorCondition() {}
func (c *TileModificationCondition) deepCopyCondition() BehaviorCondition {
cp := *c
return &cp
}
// MiscCondition covers extra-actions, bonus-tags, world-tree-tile, award-fund, trade.
type MiscCondition struct {
ConditionBase
Per *PerCondition `json:"per,omitempty"`
Selectors []Selector `json:"selectors,omitempty"`
}
func NewMiscCondition(rt ResourceType, amount int, target string) *MiscCondition {
return &MiscCondition{ConditionBase: ConditionBase{ResourceType: rt, Amount: amount, Target: target}}
}
func (c *MiscCondition) isBehaviorCondition() {}
func (c *MiscCondition) deepCopyCondition() BehaviorCondition {
cp := *c
if c.Per != nil {
p := *c.Per
cp.Per = &p
}
if c.Selectors != nil {
s := make([]Selector, len(c.Selectors))
copy(s, c.Selectors)
cp.Selectors = s
}
return &cp
}
// classifyResourceType maps a ResourceType to its category string.
func classifyResourceType(rt ResourceType) string {
switch rt {
case ResourceCredit, ResourceSteel, ResourceTitanium, ResourcePlant, ResourceEnergy, ResourceHeat:
return "basic-resource"
case ResourceCreditProduction, ResourceSteelProduction, ResourceTitaniumProduction,
ResourcePlantProduction, ResourceEnergyProduction, ResourceHeatProduction, ResourceAnyProduction:
return "production"
case ResourceCityPlacement, ResourceOceanPlacement, ResourceGreeneryPlacement,
ResourceVolcanoPlacement, ResourceTilePlacement, ResourceLandClaim:
return "tile-placement"
case ResourceTemperature, ResourceOxygen, ResourceOcean, ResourceVenus, ResourceTR, ResourceGlobalParameter:
return "global-parameter"
case ResourceCardDraw, ResourceCardTake, ResourceCardPeek, ResourceCardBuy, ResourceCardDiscard:
return "card-operation"
case ResourceMicrobe, ResourceAnimal, ResourceFloater, ResourceScience, ResourceAsteroid,
ResourceFighter, ResourceDisease, ResourceCardResource:
return "card-storage"
case ResourceDiscount, ResourcePaymentSubstitute, ResourceStoragePaymentSubstitute,
ResourceValueModifier, ResourceGlobalParameterLenience, ResourceIgnoreGlobalRequirements,
ResourceOceanAdjacencyBonus, ResourceDefense, ResourceActionReuse, ResourceEffect, ResourceTag:
return "effect"
case ResourceColony, ResourceColonyCount, ResourceColonyBonus, ResourceColonyTrackStep:
return "colony"
case ResourceTileDestruction, ResourceTileReplacement:
return "tile-modification"
case ResourceExtraActions, ResourceBonusTags, ResourceWorldTreeTile, ResourceAwardFund, ResourceFreeTrade:
return "misc"
default:
return "misc"
}
}
// categorizeCondition converts a flat resourceConditionJSON to the appropriate typed category struct.
func categorizeCondition(rc resourceConditionJSON) BehaviorCondition {
base := ConditionBase{
ResourceType: rc.ResourceType,
Amount: rc.Amount,
Target: rc.Target,
}
switch classifyResourceType(rc.ResourceType) {
case "basic-resource":
return &BasicResourceCondition{
ConditionBase: base,
Per: rc.Per,
VariableAmount: rc.VariableAmount,
MaxTrigger: rc.MaxTrigger,
Optional: rc.Optional,
TargetRestriction: rc.TargetRestriction,
PaymentAllowed: rc.PaymentAllowed,
}
case "production":
return &ProductionCondition{
ConditionBase: base,
Per: rc.Per,
VariableAmount: rc.VariableAmount,
}
case "tile-placement":
return &TilePlacementCondition{
ConditionBase: base,
TileRestrictions: rc.TileRestrictions,
TileType: rc.TileType,
Optional: rc.Optional,
}
case "global-parameter":
return &GlobalParameterCondition{
ConditionBase: base,
Per: rc.Per,
}
case "card-operation":
return &CardOperationCondition{
ConditionBase: base,
Selectors: rc.Selectors,
Optional: rc.Optional,
PaymentAllowed: rc.PaymentAllowed,
VariableAmount: rc.VariableAmount,
}
case "card-storage":
return &CardStorageCondition{
ConditionBase: base,
Selectors: rc.Selectors,
Per: rc.Per,
Optional: rc.Optional,
VariableAmount: rc.VariableAmount,
}
case "effect":
return &EffectCondition{
ConditionBase: base,
Selectors: rc.Selectors,
Temporary: rc.Temporary,
}
case "colony":
return &ColonyCondition{
ConditionBase: base,
AllowDuplicatePlayerColony: rc.AllowDuplicatePlayerColony,
}
case "tile-modification":
return &TileModificationCondition{
ConditionBase: base,
TileType: rc.TileType,
}
default:
return &MiscCondition{
ConditionBase: base,
Per: rc.Per,
Selectors: rc.Selectors,
}
}
}
// flattenCondition converts any typed condition back to a flat resourceConditionJSON.
func flattenCondition(bc BehaviorCondition) resourceConditionJSON {
switch c := bc.(type) {
case *BasicResourceCondition:
return resourceConditionJSON{
ResourceType: c.ResourceType,
Amount: c.Amount,
Target: c.Target,
Per: c.Per,
VariableAmount: c.VariableAmount,
MaxTrigger: c.MaxTrigger,
Optional: c.Optional,
TargetRestriction: c.TargetRestriction,
PaymentAllowed: c.PaymentAllowed,
}
case *ProductionCondition:
return resourceConditionJSON{
ResourceType: c.ResourceType,
Amount: c.Amount,
Target: c.Target,
Per: c.Per,
VariableAmount: c.VariableAmount,
}
case *TilePlacementCondition:
return resourceConditionJSON{
ResourceType: c.ResourceType,
Amount: c.Amount,
Target: c.Target,
TileRestrictions: c.TileRestrictions,
TileType: c.TileType,
Optional: c.Optional,
}
case *GlobalParameterCondition:
return resourceConditionJSON{
ResourceType: c.ResourceType,
Amount: c.Amount,
Target: c.Target,
Per: c.Per,
}
case *CardOperationCondition:
return resourceConditionJSON{
ResourceType: c.ResourceType,
Amount: c.Amount,
Target: c.Target,
Selectors: c.Selectors,
Optional: c.Optional,
PaymentAllowed: c.PaymentAllowed,
VariableAmount: c.VariableAmount,
}
case *CardStorageCondition:
return resourceConditionJSON{
ResourceType: c.ResourceType,
Amount: c.Amount,
Target: c.Target,
Selectors: c.Selectors,
Per: c.Per,
Optional: c.Optional,
VariableAmount: c.VariableAmount,
}
case *EffectCondition:
return resourceConditionJSON{
ResourceType: c.ResourceType,
Amount: c.Amount,
Target: c.Target,
Selectors: c.Selectors,
Temporary: c.Temporary,
}
case *ColonyCondition:
return resourceConditionJSON{
ResourceType: c.ResourceType,
Amount: c.Amount,
Target: c.Target,
AllowDuplicatePlayerColony: c.AllowDuplicatePlayerColony,
}
case *TileModificationCondition:
return resourceConditionJSON{
ResourceType: c.ResourceType,
Amount: c.Amount,
Target: c.Target,
TileType: c.TileType,
}
case *MiscCondition:
return resourceConditionJSON{
ResourceType: c.ResourceType,
Amount: c.Amount,
Target: c.Target,
Per: c.Per,
Selectors: c.Selectors,
}
default:
return resourceConditionJSON{
ResourceType: bc.GetResourceType(),
Amount: bc.GetAmount(),
Target: bc.GetTarget(),
}
}
}
package shared
import (
"slices"
"time"
)
// GameStatus represents the current status of a game
type GameStatus string
const (
GameStatusLobby GameStatus = "lobby"
GameStatusActive GameStatus = "active"
GameStatusCompleted GameStatus = "completed"
)
// GamePhase represents the current phase of the game
type GamePhase string
const (
GamePhaseWaitingForGameStart GamePhase = "waiting_for_game_start"
GamePhaseStartingSelection GamePhase = "starting_selection"
GamePhaseStartGameSelection GamePhase = "start_game_selection"
GamePhaseInitApplyCorp GamePhase = "init_apply_corp"
GamePhaseInitApplyPrelude GamePhase = "init_apply_prelude"
GamePhaseAction GamePhase = "action"
GamePhaseProductionAndCardDraw GamePhase = "production_and_card_draw"
GamePhaseFinalPhase GamePhase = "final_phase"
GamePhaseComplete GamePhase = "complete"
)
// GameSettings contains configurable game parameters
type GameSettings struct {
MaxPlayers int
Temperature *int
Oxygen *int
Oceans *int
Venus *int
VenusNextEnabled bool
DevelopmentMode bool
DemoGame bool
CardPacks []string
Generation *int
ClaudeAPIKey string
ClaudeModel string
SelectedMilestones []string
SelectedAwards []string
}
// Card pack constants
const (
PackBaseGame = "base-game"
PackFuture = "future"
PackPrelude = "prelude"
PackVenus = "venus-next"
PackExperimental = "experimental"
PackColonies = "colonies"
PackProjectFunding = "project-funding"
)
// EnabledPacks returns a set of all enabled pack names for filtering.
func (s GameSettings) EnabledPacks() map[string]bool {
packs := make(map[string]bool, len(s.CardPacks)+1)
for _, pack := range s.CardPacks {
packs[pack] = true
}
if s.VenusNextEnabled {
packs[PackVenus] = true
}
return packs
}
// HasPrelude returns true if the prelude card pack is enabled
func (s GameSettings) HasPrelude() bool {
return slices.Contains(s.CardPacks, PackPrelude)
}
// HasColonies returns true if the Colonies expansion is enabled
func (s GameSettings) HasColonies() bool {
return slices.Contains(s.CardPacks, PackColonies)
}
// HasProjectFunding returns true if the Project Funding expansion is enabled
func (s GameSettings) HasProjectFunding() bool {
return slices.Contains(s.CardPacks, PackProjectFunding)
}
// HasVenus returns true if the Venus expansion is enabled
func (s GameSettings) HasVenus() bool {
return s.VenusNextEnabled
}
// DefaultCardPacks returns the default card packs
func DefaultCardPacks() []string {
return []string{PackBaseGame, PackPrelude}
}
// SourceType identifies the type of action that caused a state change
type SourceType string
const (
SourceTypeCardPlay SourceType = "card_play"
SourceTypeCardAction SourceType = "card_action"
SourceTypeStandardProject SourceType = "standard_project"
SourceTypePassiveEffect SourceType = "passive_effect"
SourceTypeResourceConvert SourceType = "resource_convert"
SourceTypeGameEvent SourceType = "game_event"
SourceTypeInitial SourceType = "initial"
SourceTypeAward SourceType = "award"
SourceTypeMilestone SourceType = "milestone"
SourceTypeActionAdded SourceType = "action_added"
SourceTypeEffectAdded SourceType = "effect_added"
SourceTypeGlobalBonus SourceType = "global_bonus"
SourceTypeColonyTrade SourceType = "colony_trade"
SourceTypeColonyBuild SourceType = "colony_build"
SourceTypeProjectFundingSeat SourceType = "project_funding_seat"
SourceTypeProjectFundingCompletion SourceType = "project_funding_completion"
)
// Chat constants
const (
MaxChatMessages = 200
MaxChatMessageLength = 500
)
// Spectator constants and colors
const MaxSpectators = 4
// PlayerColors is the palette of 10 visually distinct colors available to players.
var PlayerColors = []string{
"#e53935", "#1e88e5", "#43a047", "#ffb300", "#8e24aa",
"#00acc1", "#f4511e", "#3949ab", "#c0ca33", "#d81b60",
}
// SpectatorColors is the palette of colors assigned to spectators.
var SpectatorColors = []string{"#9b9b9b", "#7eb8da", "#c4a6e8", "#e8c4a6"}
// SpectatorState represents a spectator's data
type SpectatorState struct {
ID string
Name string
Color string
}
// ChatMessage represents a single chat message
type ChatMessage struct {
SenderID string
SenderName string
SenderColor string
Message string
Timestamp time.Time
IsSpectator bool
}
// ClaimedMilestone represents a milestone that has been claimed
type ClaimedMilestone struct {
Type MilestoneType
PlayerID string
Generation int
ClaimedAt time.Time
}
// FundedAward represents an award that has been funded
type FundedAward struct {
Type AwardType
FundedByPlayer string
FundingOrder int
FundingCost int
FundedAt time.Time
}
// FinalScore holds the final scoring breakdown for a player
type FinalScore struct {
PlayerID string
PlayerName string
Breakdown VPBreakdown
Credits int
Placement int
IsWinner bool
}
// VPBreakdown contains the victory point breakdown
type VPBreakdown struct {
TerraformRating int
CardVP int
CardVPDetails []CardVPDetail
MilestoneVP int
AwardVP int
GreeneryVP int
GreeneryVPDetails []GreeneryVPDetail
CityVP int
CityVPDetails []CityVPDetail
TotalVP int
}
// CardVPDetail contains VP details for a single card
type CardVPDetail struct {
CardID string
CardName string
Conditions []CardVPConditionDetail
TotalVP int
}
// CardVPConditionDetail contains details of a single VP condition evaluation
type CardVPConditionDetail struct {
ConditionType string
Amount int
Count int
MaxTrigger *int
ActualTriggers int
TotalVP int
Explanation string
}
// GreeneryVPDetail contains VP details for a greenery tile
type GreeneryVPDetail struct {
Coordinate string
VP int
}
// CityVPDetail contains VP details for a city tile
type CityVPDetail struct {
CityCoordinate string
AdjacentGreeneries []string
VP int
}
// TriggeredEffect records a triggered passive effect for notification
type TriggeredEffect struct {
CardName string
PlayerID string
SourceType SourceType
Outputs []BehaviorCondition
CalculatedOutputs []CalculatedOutput
Behaviors []CardBehavior
VPConditions []VPConditionForLog
}
// CalculatedOutput represents an actual output value that was applied
type CalculatedOutput struct {
ResourceType string
Amount int
IsScaled bool
}
// VPConditionForLog is a simplified VP condition for log display
type VPConditionForLog struct {
Amount int
Condition string
MaxTrigger *int
Per *PerCondition
}
// PendingDemoChoices holds a player's card selections made during the demo lobby phase
type PendingDemoChoices struct {
CorporationID string
PreludeIDs []string
CardIDs []string
Resources Resources
Production Production
TerraformRating int
}
// DeferredStartingChoices holds choices that are applied after init
type DeferredStartingChoices struct {
CorporationID string
PreludeIDs []string
CardIDs []string
CorpApplied bool
PreludesApplied bool
}
package shared
import "fmt"
// HexPosition represents a position on the Mars board using cube coordinates
type HexPosition struct {
Q int `json:"q"` // Column coordinate
R int `json:"r"` // Row coordinate
S int `json:"s"` // Third coordinate (Q + R + S = 0)
}
// String returns a string representation of the hex position
func (h HexPosition) String() string {
return fmt.Sprintf("%d,%d,%d", h.Q, h.R, h.S)
}
// GetNeighbors returns all 6 adjacent hex positions using cube coordinate system
func (h HexPosition) GetNeighbors() []HexPosition {
directions := []HexPosition{
{Q: 1, R: -1, S: 0}, // East
{Q: 1, R: 0, S: -1}, // Northeast
{Q: 0, R: 1, S: -1}, // Northwest
{Q: -1, R: 1, S: 0}, // West
{Q: -1, R: 0, S: 1}, // Southwest
{Q: 0, R: -1, S: 1}, // Southeast
}
neighbors := make([]HexPosition, 0, 6)
for _, dir := range directions {
neighbors = append(neighbors, HexPosition{
Q: h.Q + dir.Q,
R: h.R + dir.R,
S: h.S + dir.S,
})
}
return neighbors
}
// Equals checks if two hex positions are equal
func (h HexPosition) Equals(other HexPosition) bool {
return h.Q == other.Q && h.R == other.R && h.S == other.S
}
package shared
const (
// MinCreditProduction is the minimum MC production allowed (-5 in TM rules)
MinCreditProduction = -5
// MinOtherProduction is the minimum production for non-MC resources
MinOtherProduction = 0
)
// Production represents a player's production values
type Production struct {
Credits int
Steel int
Titanium int
Plants int
Energy int
Heat int
}
// DeepCopy creates a deep copy of the Production struct
func (p Production) DeepCopy() Production {
return Production{
Credits: p.Credits,
Steel: p.Steel,
Titanium: p.Titanium,
Plants: p.Plants,
Energy: p.Energy,
Heat: p.Heat,
}
}
// GetAmount returns the production amount for a specific resource type.
// Accepts both base resources (e.g., "titanium") and production types (e.g., "titanium-production").
func (p Production) GetAmount(resourceType ResourceType) int {
switch resourceType {
case ResourceCredit, ResourceCreditProduction:
return p.Credits
case ResourceSteel, ResourceSteelProduction:
return p.Steel
case ResourceTitanium, ResourceTitaniumProduction:
return p.Titanium
case ResourcePlant, ResourcePlantProduction:
return p.Plants
case ResourceEnergy, ResourceEnergyProduction:
return p.Energy
case ResourceHeat, ResourceHeatProduction:
return p.Heat
default:
return 0
}
}
package shared
import "fmt"
// FieldProfile declares which optional fields are valid for a ResourceType category.
// Amount and ResourceType are always valid and omitted from the profile.
type FieldProfile struct {
AllowTarget bool
AllowSelectors bool
AllowMaxTrigger bool
AllowPer bool
AllowTileRestrictions bool
AllowTileType bool
AllowVariableAmount bool
AllowTemporary bool
AllowOptional bool
AllowPaymentAllowed bool
AllowTargetRestriction bool
AllowAllowDuplicatePlayerColony bool
}
// Category-level output profiles
var basicResourceOutputProfile = FieldProfile{
AllowTarget: true,
AllowPer: true,
AllowVariableAmount: true,
AllowMaxTrigger: true,
AllowTargetRestriction: true,
}
var basicResourceInputProfile = FieldProfile{
AllowTarget: true,
AllowVariableAmount: true,
AllowOptional: true,
}
var creditInputProfile = FieldProfile{
AllowTarget: true,
AllowPaymentAllowed: true,
}
var cardStorageOutputProfile = FieldProfile{
AllowTarget: true,
AllowSelectors: true,
AllowPer: true,
}
var cardStorageInputProfile = FieldProfile{
AllowTarget: true,
AllowVariableAmount: true,
}
var productionOutputProfile = FieldProfile{
AllowTarget: true,
AllowPer: true,
AllowVariableAmount: true,
}
var productionInputProfile = FieldProfile{
AllowTarget: true,
}
var tilePlacementOutputProfile = FieldProfile{
AllowTarget: true,
AllowTileRestrictions: true,
}
var genericTilePlacementOutputProfile = FieldProfile{
AllowTarget: true,
AllowTileRestrictions: true,
AllowTileType: true,
}
var globalParameterOutputProfile = FieldProfile{
AllowTarget: true,
}
var cardOperationOutputProfile = FieldProfile{
AllowTarget: true,
AllowSelectors: true,
}
var discountOutputProfile = FieldProfile{
AllowTarget: true,
AllowSelectors: true,
AllowTemporary: true,
}
var paymentSubstituteOutputProfile = FieldProfile{
AllowTarget: true,
AllowSelectors: true,
}
var storagePaymentSubstituteOutputProfile = FieldProfile{
AllowSelectors: true,
}
var valueModifierOutputProfile = FieldProfile{
AllowTarget: true,
AllowSelectors: true,
}
var effectOutputProfile = FieldProfile{
AllowTarget: true,
AllowSelectors: true,
AllowTemporary: true,
}
var colonyTileOutputProfile = FieldProfile{
AllowTarget: true,
AllowAllowDuplicatePlayerColony: true,
}
var tileReplacementOutputProfile = FieldProfile{
AllowTarget: true,
AllowTileType: true,
}
var bonusTagsOutputProfile = FieldProfile{
AllowTarget: true,
AllowPer: true,
AllowSelectors: true,
}
var targetOnlyOutputProfile = FieldProfile{
AllowTarget: true,
}
var emptyProfile = FieldProfile{}
// outputFieldProfiles maps each ResourceType to its valid field profile for outputs.
var outputFieldProfiles = map[ResourceType]FieldProfile{
// Basic resources
ResourceCredit: basicResourceOutputProfile,
ResourceSteel: basicResourceOutputProfile,
ResourceTitanium: basicResourceOutputProfile,
ResourcePlant: basicResourceOutputProfile,
ResourceEnergy: basicResourceOutputProfile,
ResourceHeat: basicResourceOutputProfile,
// Card storage resources
ResourceMicrobe: cardStorageOutputProfile,
ResourceAnimal: cardStorageOutputProfile,
ResourceFloater: cardStorageOutputProfile,
ResourceScience: cardStorageOutputProfile,
ResourceAsteroid: cardStorageOutputProfile,
ResourceFighter: cardStorageOutputProfile,
ResourceDisease: cardStorageOutputProfile,
ResourceCardResource: cardStorageOutputProfile,
// Card operations
ResourceCardDraw: cardOperationOutputProfile,
ResourceCardTake: cardOperationOutputProfile,
ResourceCardPeek: cardOperationOutputProfile,
ResourceCardBuy: cardOperationOutputProfile,
ResourceCardDiscard: cardOperationOutputProfile,
// Tile placements
ResourceCityPlacement: tilePlacementOutputProfile,
ResourceOceanPlacement: tilePlacementOutputProfile,
ResourceGreeneryPlacement: tilePlacementOutputProfile,
ResourceVolcanoPlacement: tilePlacementOutputProfile,
ResourceTilePlacement: genericTilePlacementOutputProfile,
// Tile count types (used in PerCondition, not directly as conditions)
ResourceCityTile: emptyProfile,
ResourceOceanTile: emptyProfile,
ResourceGreeneryTile: emptyProfile,
ResourceVolcanoTile: emptyProfile,
ResourceNaturalPreserveTile: emptyProfile,
ResourceMiningTile: emptyProfile,
ResourceNuclearZoneTile: emptyProfile,
ResourceEcologicalZoneTile: emptyProfile,
ResourceMoholeTile: emptyProfile,
ResourceRestrictedTile: emptyProfile,
ResourceLandTile: emptyProfile,
ResourceNonOceanTile: emptyProfile,
ResourceOceanSpace: emptyProfile,
// Colony
ResourceColony: colonyTileOutputProfile,
ResourceColonyCount: targetOnlyOutputProfile,
ResourceColonyBonus: targetOnlyOutputProfile,
// Global parameters
ResourceTemperature: globalParameterOutputProfile,
ResourceOxygen: globalParameterOutputProfile,
ResourceOcean: globalParameterOutputProfile,
ResourceVenus: globalParameterOutputProfile,
ResourceTR: {AllowTarget: true, AllowPer: true},
ResourceGlobalParameter: globalParameterOutputProfile,
// Production
ResourceCreditProduction: productionOutputProfile,
ResourceSteelProduction: productionOutputProfile,
ResourceTitaniumProduction: productionOutputProfile,
ResourcePlantProduction: productionOutputProfile,
ResourceEnergyProduction: productionOutputProfile,
ResourceHeatProduction: productionOutputProfile,
ResourceAnyProduction: productionOutputProfile,
// Effects
ResourceEffect: effectOutputProfile,
ResourceTag: effectOutputProfile,
ResourceDiscount: discountOutputProfile,
ResourcePaymentSubstitute: paymentSubstituteOutputProfile,
ResourceStoragePaymentSubstitute: storagePaymentSubstituteOutputProfile,
ResourceValueModifier: valueModifierOutputProfile,
ResourceGlobalParameterLenience: effectOutputProfile,
ResourceIgnoreGlobalRequirements: effectOutputProfile,
ResourceOceanAdjacencyBonus: effectOutputProfile,
ResourceDefense: effectOutputProfile,
ResourceActionReuse: targetOnlyOutputProfile,
// Special
ResourceLandClaim: targetOnlyOutputProfile,
ResourceExtraActions: targetOnlyOutputProfile,
ResourceTileDestruction: targetOnlyOutputProfile,
ResourceTileReplacement: tileReplacementOutputProfile,
ResourceBonusTags: bonusTagsOutputProfile,
ResourceWorldTreeTile: targetOnlyOutputProfile,
ResourceAwardFund: targetOnlyOutputProfile,
ResourceFreeTrade: targetOnlyOutputProfile,
ResourceCardCount: emptyProfile,
ResourceColonyTrackStep: targetOnlyOutputProfile,
}
// inputFieldProfiles maps each ResourceType to its valid field profile for inputs.
// Most resource types never appear as inputs. Types not in this map are invalid as inputs.
var inputFieldProfiles = map[ResourceType]FieldProfile{
// Basic resources
ResourceCredit: creditInputProfile,
ResourceSteel: basicResourceInputProfile,
ResourceTitanium: basicResourceInputProfile,
ResourcePlant: basicResourceInputProfile,
ResourceEnergy: basicResourceInputProfile,
ResourceHeat: basicResourceInputProfile,
// Card storage resources (can be spent from cards)
ResourceMicrobe: cardStorageInputProfile,
ResourceAnimal: cardStorageInputProfile,
ResourceFloater: cardStorageInputProfile,
ResourceScience: cardStorageInputProfile,
ResourceAsteroid: cardStorageInputProfile,
ResourceFighter: cardStorageInputProfile,
// Card operations
ResourceCardDiscard: {AllowTarget: true, AllowOptional: true, AllowVariableAmount: true},
// Production (can be reduced)
ResourceCreditProduction: productionInputProfile,
ResourceEnergyProduction: productionInputProfile,
}
// ValidTargets is the set of known valid target values.
var validTargets = map[string]bool{
"self-player": true,
"self-card": true,
"any-card": true,
"any-player": true,
"all-opponents": true,
"none": true,
"steal-any-player": true,
"steal-from-any-card": true,
"": true,
}
// AllResourceTypes contains every ResourceType constant for exhaustiveness testing.
var AllResourceTypes = []ResourceType{
ResourceCredit, ResourceSteel, ResourceTitanium, ResourcePlant, ResourceEnergy, ResourceHeat,
ResourceMicrobe, ResourceAnimal, ResourceFloater, ResourceScience, ResourceAsteroid, ResourceFighter, ResourceDisease,
ResourceCardDraw, ResourceCardTake, ResourceCardPeek, ResourceCardBuy, ResourceCardDiscard,
ResourceCityPlacement, ResourceOceanPlacement, ResourceGreeneryPlacement, ResourceVolcanoPlacement, ResourceTilePlacement,
ResourceCityTile, ResourceOceanTile, ResourceGreeneryTile, ResourceVolcanoTile,
ResourceColony, ResourceColonyCount, ResourceColonyBonus,
ResourceNaturalPreserveTile, ResourceMiningTile, ResourceNuclearZoneTile, ResourceEcologicalZoneTile, ResourceMoholeTile, ResourceRestrictedTile,
ResourceLandTile, ResourceNonOceanTile, ResourceOceanSpace,
ResourceTemperature, ResourceOxygen, ResourceOcean, ResourceVenus, ResourceTR, ResourceGlobalParameter,
ResourceCreditProduction, ResourceSteelProduction, ResourceTitaniumProduction, ResourcePlantProduction, ResourceEnergyProduction, ResourceHeatProduction, ResourceAnyProduction,
ResourceEffect, ResourceTag,
ResourceGlobalParameterLenience, ResourceIgnoreGlobalRequirements, ResourceDefense, ResourceDiscount, ResourceValueModifier, ResourcePaymentSubstitute, ResourceOceanAdjacencyBonus,
ResourceLandClaim, ResourceStoragePaymentSubstitute, ResourceCardResource, ResourceActionReuse,
ResourceExtraActions, ResourceTileDestruction, ResourceTileReplacement, ResourceBonusTags,
ResourceWorldTreeTile, ResourceAwardFund, ResourceFreeTrade, ResourceCardCount,
ResourceColonyTrackStep,
}
// ValidateResourceCondition checks that only valid fields are set for the given ResourceType.
// isInput indicates whether this is an input (true) or output (false).
// Returns a list of violation descriptions (empty = valid).
func ValidateResourceCondition(bc BehaviorCondition, isInput bool) []string {
rc := flattenCondition(bc)
var violations []string
profiles := outputFieldProfiles
direction := "output"
if isInput {
profiles = inputFieldProfiles
direction = "input"
}
profile, ok := profiles[rc.ResourceType]
if !ok {
return []string{fmt.Sprintf("resource type %q is not valid as %s", rc.ResourceType, direction)}
}
if rc.Target != "" && rc.Target != "none" && !profile.AllowTarget {
violations = append(violations, fmt.Sprintf("field 'target' (%q) not allowed for %s %q", rc.Target, direction, rc.ResourceType))
}
if rc.Target != "" && !validTargets[rc.Target] {
violations = append(violations, fmt.Sprintf("unknown target value %q", rc.Target))
}
if len(rc.Selectors) > 0 && !profile.AllowSelectors {
violations = append(violations, fmt.Sprintf("field 'selectors' not allowed for %s %q", direction, rc.ResourceType))
}
if rc.MaxTrigger != nil && !profile.AllowMaxTrigger {
violations = append(violations, fmt.Sprintf("field 'maxTrigger' not allowed for %s %q", direction, rc.ResourceType))
}
if rc.Per != nil && !profile.AllowPer {
violations = append(violations, fmt.Sprintf("field 'per' not allowed for %s %q", direction, rc.ResourceType))
}
if rc.TileRestrictions != nil && !profile.AllowTileRestrictions {
violations = append(violations, fmt.Sprintf("field 'tileRestrictions' not allowed for %s %q", direction, rc.ResourceType))
}
if rc.TileType != "" && !profile.AllowTileType {
violations = append(violations, fmt.Sprintf("field 'tileType' not allowed for %s %q", direction, rc.ResourceType))
}
if rc.VariableAmount && !profile.AllowVariableAmount {
violations = append(violations, fmt.Sprintf("field 'variableAmount' not allowed for %s %q", direction, rc.ResourceType))
}
if rc.Temporary != "" && !profile.AllowTemporary {
violations = append(violations, fmt.Sprintf("field 'temporary' not allowed for %s %q", direction, rc.ResourceType))
}
if rc.Optional && !profile.AllowOptional {
violations = append(violations, fmt.Sprintf("field 'optional' not allowed for %s %q", direction, rc.ResourceType))
}
if len(rc.PaymentAllowed) > 0 && !profile.AllowPaymentAllowed {
violations = append(violations, fmt.Sprintf("field 'paymentAllowed' not allowed for %s %q", direction, rc.ResourceType))
}
if rc.TargetRestriction != nil && !profile.AllowTargetRestriction {
violations = append(violations, fmt.Sprintf("field 'targetRestriction' not allowed for %s %q", direction, rc.ResourceType))
}
if rc.AllowDuplicatePlayerColony && !profile.AllowAllowDuplicatePlayerColony {
violations = append(violations, fmt.Sprintf("field 'allowDuplicatePlayerColony' not allowed for %s %q", direction, rc.ResourceType))
}
return violations
}
// GetOutputProfile returns the output FieldProfile for a ResourceType and whether it exists.
func GetOutputProfile(rt ResourceType) (FieldProfile, bool) {
p, ok := outputFieldProfiles[rt]
return p, ok
}
// GetInputProfile returns the input FieldProfile for a ResourceType and whether it exists.
func GetInputProfile(rt ResourceType) (FieldProfile, bool) {
p, ok := inputFieldProfiles[rt]
return p, ok
}
package shared
// ResourceType represents different types of resources in the game
type ResourceType string
const (
ResourceCredit ResourceType = "credit"
ResourceSteel ResourceType = "steel"
ResourceTitanium ResourceType = "titanium"
ResourcePlant ResourceType = "plant"
ResourceEnergy ResourceType = "energy"
ResourceHeat ResourceType = "heat"
ResourceMicrobe ResourceType = "microbe"
ResourceAnimal ResourceType = "animal"
ResourceFloater ResourceType = "floater"
ResourceScience ResourceType = "science"
ResourceAsteroid ResourceType = "asteroid"
ResourceFighter ResourceType = "fighter"
ResourceDisease ResourceType = "disease"
ResourceCardDraw ResourceType = "card-draw"
ResourceCardTake ResourceType = "card-take"
ResourceCardPeek ResourceType = "card-peek"
ResourceCardBuy ResourceType = "card-buy"
ResourceCardDiscard ResourceType = "card-discard"
ResourceCityPlacement ResourceType = "city-placement"
ResourceOceanPlacement ResourceType = "ocean-placement"
ResourceGreeneryPlacement ResourceType = "greenery-placement"
ResourceVolcanoPlacement ResourceType = "volcano-placement"
ResourceTilePlacement ResourceType = "tile-placement"
ResourceCityTile ResourceType = "city-tile"
ResourceOceanTile ResourceType = "ocean-tile"
ResourceGreeneryTile ResourceType = "greenery-tile"
ResourceVolcanoTile ResourceType = "volcano-tile"
ResourceColony ResourceType = "colony"
ResourceColonyCount ResourceType = "colony-count"
ResourceColonyBonus ResourceType = "colony-bonus"
ResourceNaturalPreserveTile ResourceType = "natural-preserve-tile"
ResourceMiningTile ResourceType = "mining-tile"
ResourceNuclearZoneTile ResourceType = "nuclear-zone-tile"
ResourceEcologicalZoneTile ResourceType = "ecological-zone-tile"
ResourceMoholeTile ResourceType = "mohole-tile"
ResourceRestrictedTile ResourceType = "restricted-tile"
ResourceLandTile ResourceType = "land"
ResourceNonOceanTile ResourceType = "land-tile"
ResourceOceanSpace ResourceType = "ocean-space"
ResourceTemperature ResourceType = "temperature"
ResourceOxygen ResourceType = "oxygen"
ResourceOcean ResourceType = "ocean"
ResourceVenus ResourceType = "venus"
ResourceTR ResourceType = "tr"
ResourceGlobalParameter ResourceType = "global-parameter"
ResourceCreditProduction ResourceType = "credit-production"
ResourceSteelProduction ResourceType = "steel-production"
ResourceTitaniumProduction ResourceType = "titanium-production"
ResourcePlantProduction ResourceType = "plant-production"
ResourceEnergyProduction ResourceType = "energy-production"
ResourceHeatProduction ResourceType = "heat-production"
ResourceAnyProduction ResourceType = "any-production"
ResourceEffect ResourceType = "effect"
ResourceTag ResourceType = "tag"
ResourceGlobalParameterLenience ResourceType = "global-parameter-lenience"
ResourceIgnoreGlobalRequirements ResourceType = "ignore-global-requirements"
ResourceDefense ResourceType = "defense"
ResourceDiscount ResourceType = "discount"
ResourceValueModifier ResourceType = "value-modifier"
ResourcePaymentSubstitute ResourceType = "payment-substitute"
ResourceOceanAdjacencyBonus ResourceType = "ocean-adjacency-bonus"
ResourceLandClaim ResourceType = "land-claim"
ResourceStoragePaymentSubstitute ResourceType = "storage-payment-substitute"
ResourceCardResource ResourceType = "card-resource"
ResourceActionReuse ResourceType = "action-reuse"
ResourceExtraActions ResourceType = "extra-actions"
ResourceTileDestruction ResourceType = "tile-destruction"
ResourceTileReplacement ResourceType = "tile-replacement"
ResourceBonusTags ResourceType = "bonus-tags"
ResourceWorldTreeTile ResourceType = "world-tree-tile"
ResourceAwardFund ResourceType = "award-fund"
ResourceFreeTrade ResourceType = "trade"
ResourceCardCount ResourceType = "card-count"
ResourceColonyTrackStep ResourceType = "colony-track-step"
ResourceDistinctTagCount ResourceType = "distinct-tag-count"
ResourceCardsWithRequirements ResourceType = "cards-with-requirements"
ResourceMaxSingleProduction ResourceType = "max-single-production"
ResourcePlayedCardTypeCount ResourceType = "played-card-type-count"
ResourceTotalCardStorage ResourceType = "total-card-storage"
)
// IsForestTile returns true if the tile type counts as a forest (greenery or world-tree).
func IsForestTile(tileType ResourceType) bool {
return tileType == ResourceGreeneryTile || tileType == ResourceWorldTreeTile
}
// ActionType constants for action-level selectors (discounts targeting specific game actions)
const (
ActionCardBuying = "card-buying"
ActionCardPlaying = "card-playing"
ActionColonyTrade = "colony-trade"
)
package shared
// Resources represents a player's resources
type Resources struct {
Credits int
Steel int
Titanium int
Plants int
Energy int
Heat int
}
// IsZero returns true if all resource values are zero
func (r Resources) IsZero() bool {
return r.Credits == 0 && r.Steel == 0 && r.Titanium == 0 &&
r.Plants == 0 && r.Energy == 0 && r.Heat == 0
}
// DeepCopy creates a deep copy of the Resources struct
func (r Resources) DeepCopy() Resources {
return Resources{
Credits: r.Credits,
Steel: r.Steel,
Titanium: r.Titanium,
Plants: r.Plants,
Energy: r.Energy,
Heat: r.Heat,
}
}
// GetAmount returns the amount of a specific resource type.
func (r Resources) GetAmount(resourceType ResourceType) int {
switch resourceType {
case ResourceCredit:
return r.Credits
case ResourceSteel:
return r.Steel
case ResourceTitanium:
return r.Titanium
case ResourcePlant:
return r.Plants
case ResourceEnergy:
return r.Energy
case ResourceHeat:
return r.Heat
default:
return 0
}
}
package game
// MaxSpectators is the maximum number of spectators allowed per game.
const MaxSpectators = 4
// PlayerColors is the palette of 10 visually distinct colors available to players.
var PlayerColors = []string{
"#e53935", "#1e88e5", "#43a047", "#ffb300", "#8e24aa",
"#00acc1", "#f4511e", "#3949ab", "#c0ca33", "#d81b60",
}
// SpectatorColors is the palette of colors assigned to spectators.
var SpectatorColors = []string{"#9b9b9b", "#7eb8da", "#c4a6e8", "#e8c4a6"}
// Spectator represents a lightweight, ephemeral observer of a game.
type Spectator struct {
id string
name string
color string
}
// NewSpectator creates a new spectator with the given ID, name, and color.
func NewSpectator(id, name, color string) *Spectator {
return &Spectator{
id: id,
name: name,
color: color,
}
}
// ID returns the spectator's unique identifier.
func (s *Spectator) ID() string {
return s.id
}
// Name returns the spectator's display name.
func (s *Spectator) Name() string {
return s.name
}
// Color returns the spectator's assigned color.
func (s *Spectator) Color() string {
return s.color
}
package standardproject
import "terraforming-mars-backend/internal/game/shared"
// StandardProjectDefinition is the static template loaded from JSON
type StandardProjectDefinition struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Pack string `json:"pack"`
Behaviors []shared.CardBehavior `json:"behaviors"`
Style shared.Style `json:"style"`
}
// CreditCost extracts the credit input amount from behaviors (the cost to execute this project)
func (d *StandardProjectDefinition) CreditCost() int {
for _, b := range d.Behaviors {
for _, input := range b.Inputs {
if input.GetResourceType() == shared.ResourceCredit {
return input.GetAmount()
}
}
}
return 0
}
package game
import (
"time"
"terraforming-mars-backend/internal/game/shared"
)
// DiffValueString represents old/new values for string fields
type DiffValueString struct {
Old string
New string
}
// DiffValueInt represents old/new values for integer fields
type DiffValueInt struct {
Old int
New int
}
// DiffValueBool represents old/new values for boolean fields
type DiffValueBool struct {
Old bool
New bool
}
// TilePlacement records a single tile placement on the board
type TilePlacement struct {
HexID string
TileType string
OwnerID string
}
// BoardChanges contains all changes to the game board
type BoardChanges struct {
TilesPlaced []TilePlacement
}
// PlayerChanges contains all changes to a single player's state
type PlayerChanges struct {
Credits *DiffValueInt
Steel *DiffValueInt
Titanium *DiffValueInt
Plants *DiffValueInt
Energy *DiffValueInt
Heat *DiffValueInt
TerraformRating *DiffValueInt
CreditsProduction *DiffValueInt
SteelProduction *DiffValueInt
TitaniumProduction *DiffValueInt
PlantsProduction *DiffValueInt
EnergyProduction *DiffValueInt
HeatProduction *DiffValueInt
CardsAdded []string
CardsRemoved []string
CardsPlayed []string
Corporation *DiffValueString
Passed *DiffValueBool
}
// GameChanges contains all changes in a single state transition
type GameChanges struct {
Status *DiffValueString
Phase *DiffValueString
Generation *DiffValueInt
CurrentTurnPlayerID *DiffValueString
Temperature *DiffValueInt
Oxygen *DiffValueInt
Oceans *DiffValueInt
PlayerChanges map[string]*PlayerChanges
BoardChanges *BoardChanges
}
// LogDisplayData contains pre-computed display information for log entries
type LogDisplayData struct {
Behaviors []shared.CardBehavior
Tags []shared.CardTag
VPConditions []shared.VPConditionForLog
}
// StateDiff represents the difference between two consecutive game states
type StateDiff struct {
SequenceNumber int64
Timestamp time.Time
GameID string
Changes *GameChanges
Source string
SourceType shared.SourceType
PlayerID string
Description string
ChoiceIndex *int // For cards with choices, which choice was selected (0-indexed)
CalculatedOutputs []shared.CalculatedOutput // Actual values applied (for scaled outputs like "per X tags")
DisplayData *LogDisplayData // Pre-computed display information for log entries
}
// DiffLog contains the complete history of state changes for a game
type DiffLog struct {
GameID string
Diffs []StateDiff
CurrentSequence int64
}
// NewDiffLog creates a new empty diff log for a game
func NewDiffLog(gameID string) *DiffLog {
return &DiffLog{
GameID: gameID,
Diffs: []StateDiff{},
CurrentSequence: 0,
}
}
// Append adds a new diff to the log and returns the sequence number
func (dl *DiffLog) Append(changes *GameChanges, source string, sourceType shared.SourceType, playerID, description string) int64 {
return dl.AppendWithChoice(changes, source, sourceType, playerID, description, nil)
}
// AppendWithChoice adds a new diff with an optional choice index and returns the sequence number
func (dl *DiffLog) AppendWithChoice(changes *GameChanges, source string, sourceType shared.SourceType, playerID, description string, choiceIndex *int) int64 {
return dl.AppendWithChoiceAndOutputs(changes, source, sourceType, playerID, description, choiceIndex, nil)
}
// AppendWithChoiceAndOutputs adds a new diff with optional choice index and calculated outputs
func (dl *DiffLog) AppendWithChoiceAndOutputs(changes *GameChanges, source string, sourceType shared.SourceType, playerID, description string, choiceIndex *int, calculatedOutputs []shared.CalculatedOutput) int64 {
return dl.AppendFull(changes, source, sourceType, playerID, description, choiceIndex, calculatedOutputs, nil)
}
// AppendFull adds a new diff with all optional fields including display data
func (dl *DiffLog) AppendFull(changes *GameChanges, source string, sourceType shared.SourceType, playerID, description string, choiceIndex *int, calculatedOutputs []shared.CalculatedOutput, displayData *LogDisplayData) int64 {
dl.CurrentSequence++
diff := StateDiff{
SequenceNumber: dl.CurrentSequence,
Timestamp: time.Now(),
GameID: dl.GameID,
Changes: changes,
Source: source,
SourceType: sourceType,
PlayerID: playerID,
Description: description,
ChoiceIndex: choiceIndex,
CalculatedOutputs: calculatedOutputs,
DisplayData: displayData,
}
dl.Diffs = append(dl.Diffs, diff)
return dl.CurrentSequence
}
// GetAll returns all diffs in chronological order
func (dl *DiffLog) GetAll() []StateDiff {
result := make([]StateDiff, len(dl.Diffs))
copy(result, dl.Diffs)
return result
}
// diffInt compares two integers and returns a DiffValueInt if different
func diffInt(old, new int) *DiffValueInt {
if old == new {
return nil
}
return &DiffValueInt{Old: old, New: new}
}
// diffString compares two strings and returns a DiffValueString if different
func diffString(old, new string) *DiffValueString {
if old == new {
return nil
}
return &DiffValueString{Old: old, New: new}
}
// diffBool compares two booleans and returns a DiffValueBool if different
func diffBool(old, new bool) *DiffValueBool {
if old == new {
return nil
}
return &DiffValueBool{Old: old, New: new}
}
// diffStringSlice computes added and removed strings between two slices
func diffStringSlice(old, new []string) (added, removed []string) {
oldSet := make(map[string]bool)
newSet := make(map[string]bool)
for _, s := range old {
oldSet[s] = true
}
for _, s := range new {
newSet[s] = true
}
for s := range newSet {
if !oldSet[s] {
added = append(added, s)
}
}
for s := range oldSet {
if !newSet[s] {
removed = append(removed, s)
}
}
return added, removed
}
package game
import (
"context"
"fmt"
"sync"
"terraforming-mars-backend/internal/game/shared"
)
// GameStateRepository manages game state with diff tracking
type GameStateRepository interface {
Write(ctx context.Context, gameID string, game *Game, source string, sourceType shared.SourceType, playerID, description string) (*StateDiff, error)
WriteWithChoice(ctx context.Context, gameID string, game *Game, source string, sourceType shared.SourceType, playerID, description string, choiceIndex *int) (*StateDiff, error)
WriteWithChoiceAndOutputs(ctx context.Context, gameID string, game *Game, source string, sourceType shared.SourceType, playerID, description string, choiceIndex *int, calculatedOutputs []shared.CalculatedOutput) (*StateDiff, error)
WriteFull(ctx context.Context, gameID string, game *Game, source string, sourceType shared.SourceType, playerID, description string, choiceIndex *int, calculatedOutputs []shared.CalculatedOutput, displayData *LogDisplayData) (*StateDiff, error)
Get(ctx context.Context, gameID string) (*Game, error)
GetDiff(ctx context.Context, gameID string) ([]StateDiff, error)
}
// GameSnapshot represents a serializable snapshot of game state for diffing
type GameSnapshot struct {
Status string
Phase string
Generation int
CurrentTurn string
Temperature int
Oxygen int
Oceans int
Players map[string]*PlayerSnapshot
Tiles map[string]*TileSnapshot
}
// PlayerSnapshot represents a serializable snapshot of player state
type PlayerSnapshot struct {
Credits int
Steel int
Titanium int
Plants int
Energy int
Heat int
TerraformRating int
CreditsProduction int
SteelProduction int
TitaniumProduction int
PlantsProduction int
EnergyProduction int
HeatProduction int
Corporation string
Passed bool
HandCardIDs []string
PlayedCardIDs []string
}
// TileSnapshot represents a serializable snapshot of a tile
type TileSnapshot struct {
HexID string
TileType string
OwnerID string
}
// InMemoryGameStateRepository implements GameStateRepository using in-memory storage
type InMemoryGameStateRepository struct {
mu sync.RWMutex
snapshots map[string]*GameSnapshot
diffLogs map[string]*DiffLog
}
// NewInMemoryGameStateRepository creates a new in-memory game state repository
func NewInMemoryGameStateRepository() *InMemoryGameStateRepository {
return &InMemoryGameStateRepository{
snapshots: make(map[string]*GameSnapshot),
diffLogs: make(map[string]*DiffLog),
}
}
// Write stores the current game state and computes a diff from the previous state
func (r *InMemoryGameStateRepository) Write(ctx context.Context, gameID string, game *Game, source string, sourceType shared.SourceType, playerID, description string) (*StateDiff, error) {
return r.WriteWithChoice(ctx, gameID, game, source, sourceType, playerID, description, nil)
}
// WriteWithChoice stores the current game state with an optional choice index
func (r *InMemoryGameStateRepository) WriteWithChoice(ctx context.Context, gameID string, game *Game, source string, sourceType shared.SourceType, playerID, description string, choiceIndex *int) (*StateDiff, error) {
return r.WriteWithChoiceAndOutputs(ctx, gameID, game, source, sourceType, playerID, description, choiceIndex, nil)
}
// WriteWithChoiceAndOutputs stores the current game state with optional choice index and calculated outputs
func (r *InMemoryGameStateRepository) WriteWithChoiceAndOutputs(ctx context.Context, gameID string, game *Game, source string, sourceType shared.SourceType, playerID, description string, choiceIndex *int, calculatedOutputs []shared.CalculatedOutput) (*StateDiff, error) {
return r.WriteFull(ctx, gameID, game, source, sourceType, playerID, description, choiceIndex, calculatedOutputs, nil)
}
// WriteFull stores the current game state with all optional fields including display data
func (r *InMemoryGameStateRepository) WriteFull(ctx context.Context, gameID string, game *Game, source string, sourceType shared.SourceType, playerID, description string, choiceIndex *int, calculatedOutputs []shared.CalculatedOutput, displayData *LogDisplayData) (*StateDiff, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if game == nil {
return nil, fmt.Errorf("game cannot be nil")
}
newSnapshot := captureGameSnapshot(game)
r.mu.Lock()
defer r.mu.Unlock()
oldSnapshot := r.snapshots[gameID]
changes := computeSnapshotChanges(oldSnapshot, newSnapshot)
if r.diffLogs[gameID] == nil {
r.diffLogs[gameID] = NewDiffLog(gameID)
}
seqNum := r.diffLogs[gameID].AppendFull(changes, source, sourceType, playerID, description, choiceIndex, calculatedOutputs, displayData)
r.snapshots[gameID] = newSnapshot
return &StateDiff{
SequenceNumber: seqNum,
Timestamp: r.diffLogs[gameID].Diffs[len(r.diffLogs[gameID].Diffs)-1].Timestamp,
GameID: gameID,
Changes: changes,
Source: source,
SourceType: sourceType,
PlayerID: playerID,
Description: description,
ChoiceIndex: choiceIndex,
CalculatedOutputs: calculatedOutputs,
DisplayData: displayData,
}, nil
}
// Get retrieves the current game from the main GameRepository
// Note: This repository only tracks state history; use GameRepository for current game access
func (r *InMemoryGameStateRepository) Get(ctx context.Context, gameID string) (*Game, error) {
return nil, fmt.Errorf("GameStateRepository.Get not implemented - use GameRepository.Get instead")
}
// GetDiff retrieves all diffs for the specified game in chronological order
func (r *InMemoryGameStateRepository) GetDiff(ctx context.Context, gameID string) ([]StateDiff, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
r.mu.RLock()
defer r.mu.RUnlock()
diffLog, exists := r.diffLogs[gameID]
if !exists {
return nil, fmt.Errorf("game %s not found", gameID)
}
return diffLog.GetAll(), nil
}
// captureGameSnapshot creates a snapshot of the current game state
func captureGameSnapshot(game *Game) *GameSnapshot {
snapshot := &GameSnapshot{
Status: string(game.Status()),
Phase: string(game.CurrentPhase()),
Generation: game.Generation(),
Temperature: game.GlobalParameters().Temperature(),
Oxygen: game.GlobalParameters().Oxygen(),
Oceans: game.GlobalParameters().Oceans(),
Players: make(map[string]*PlayerSnapshot),
Tiles: make(map[string]*TileSnapshot),
}
if turn := game.CurrentTurn(); turn != nil {
snapshot.CurrentTurn = turn.PlayerID()
}
for _, p := range game.GetAllPlayers() {
resources := p.Resources().Get()
production := p.Resources().Production()
playerSnapshot := &PlayerSnapshot{
Credits: resources.Credits,
Steel: resources.Steel,
Titanium: resources.Titanium,
Plants: resources.Plants,
Energy: resources.Energy,
Heat: resources.Heat,
TerraformRating: p.Resources().TerraformRating(),
CreditsProduction: production.Credits,
SteelProduction: production.Steel,
TitaniumProduction: production.Titanium,
PlantsProduction: production.Plants,
EnergyProduction: production.Energy,
HeatProduction: production.Heat,
Passed: p.HasPassed(),
HandCardIDs: make([]string, 0),
PlayedCardIDs: make([]string, 0),
Corporation: p.CorporationID(),
}
for _, cardID := range p.Hand().Cards() {
playerSnapshot.HandCardIDs = append(playerSnapshot.HandCardIDs, cardID)
}
for _, cardID := range p.PlayedCards().Cards() {
playerSnapshot.PlayedCardIDs = append(playerSnapshot.PlayedCardIDs, cardID)
}
snapshot.Players[p.ID()] = playerSnapshot
}
for _, tile := range game.Board().Tiles() {
if tile.OccupiedBy != nil {
hexID := tile.Coordinates.String()
tileSnapshot := &TileSnapshot{
HexID: hexID,
TileType: string(tile.OccupiedBy.Type),
}
if tile.OwnerID != nil {
tileSnapshot.OwnerID = *tile.OwnerID
}
snapshot.Tiles[hexID] = tileSnapshot
}
}
return snapshot
}
// computeSnapshotChanges computes the diff between two game snapshots
func computeSnapshotChanges(old, new *GameSnapshot) *GameChanges {
changes := &GameChanges{}
if old == nil {
changes.Status = &DiffValueString{Old: "", New: new.Status}
changes.Phase = &DiffValueString{Old: "", New: new.Phase}
changes.Generation = &DiffValueInt{Old: 0, New: new.Generation}
changes.Temperature = &DiffValueInt{Old: 0, New: new.Temperature}
changes.Oxygen = &DiffValueInt{Old: 0, New: new.Oxygen}
changes.Oceans = &DiffValueInt{Old: 0, New: new.Oceans}
if new.CurrentTurn != "" {
changes.CurrentTurnPlayerID = &DiffValueString{Old: "", New: new.CurrentTurn}
}
changes.PlayerChanges = computeInitialSnapshotPlayerChanges(new)
changes.BoardChanges = computeInitialSnapshotBoardChanges(new)
return changes
}
changes.Status = diffString(old.Status, new.Status)
changes.Phase = diffString(old.Phase, new.Phase)
changes.Generation = diffInt(old.Generation, new.Generation)
changes.Temperature = diffInt(old.Temperature, new.Temperature)
changes.Oxygen = diffInt(old.Oxygen, new.Oxygen)
changes.Oceans = diffInt(old.Oceans, new.Oceans)
changes.CurrentTurnPlayerID = diffString(old.CurrentTurn, new.CurrentTurn)
changes.PlayerChanges = computeSnapshotPlayerChanges(old, new)
changes.BoardChanges = computeSnapshotBoardChanges(old, new)
return changes
}
// computeInitialSnapshotPlayerChanges computes player changes for initial state
func computeInitialSnapshotPlayerChanges(snapshot *GameSnapshot) map[string]*PlayerChanges {
playerChanges := make(map[string]*PlayerChanges)
for playerID, player := range snapshot.Players {
pc := computePlayerSnapshotChange(nil, player)
if pc != nil {
playerChanges[playerID] = pc
}
}
if len(playerChanges) == 0 {
return nil
}
return playerChanges
}
// computeSnapshotPlayerChanges computes changes to all players
func computeSnapshotPlayerChanges(old, new *GameSnapshot) map[string]*PlayerChanges {
playerChanges := make(map[string]*PlayerChanges)
for playerID, newPlayer := range new.Players {
oldPlayer := old.Players[playerID]
pc := computePlayerSnapshotChange(oldPlayer, newPlayer)
if pc != nil {
playerChanges[playerID] = pc
}
}
if len(playerChanges) == 0 {
return nil
}
return playerChanges
}
// computePlayerSnapshotChange computes changes for a single player
func computePlayerSnapshotChange(old, new *PlayerSnapshot) *PlayerChanges {
pc := &PlayerChanges{}
hasChanges := false
var oldCredits, oldSteel, oldTitanium, oldPlants, oldEnergy, oldHeat int
var oldTR int
var oldCredProd, oldSteelProd, oldTitaniumProd, oldPlantsProd, oldEnergyProd, oldHeatProd int
var oldPassed bool
var oldCorp string
var oldHand, oldPlayed []string
if old != nil {
oldCredits = old.Credits
oldSteel = old.Steel
oldTitanium = old.Titanium
oldPlants = old.Plants
oldEnergy = old.Energy
oldHeat = old.Heat
oldTR = old.TerraformRating
oldCredProd = old.CreditsProduction
oldSteelProd = old.SteelProduction
oldTitaniumProd = old.TitaniumProduction
oldPlantsProd = old.PlantsProduction
oldEnergyProd = old.EnergyProduction
oldHeatProd = old.HeatProduction
oldPassed = old.Passed
oldCorp = old.Corporation
oldHand = old.HandCardIDs
oldPlayed = old.PlayedCardIDs
}
if d := diffInt(oldCredits, new.Credits); d != nil {
pc.Credits = d
hasChanges = true
}
if d := diffInt(oldSteel, new.Steel); d != nil {
pc.Steel = d
hasChanges = true
}
if d := diffInt(oldTitanium, new.Titanium); d != nil {
pc.Titanium = d
hasChanges = true
}
if d := diffInt(oldPlants, new.Plants); d != nil {
pc.Plants = d
hasChanges = true
}
if d := diffInt(oldEnergy, new.Energy); d != nil {
pc.Energy = d
hasChanges = true
}
if d := diffInt(oldHeat, new.Heat); d != nil {
pc.Heat = d
hasChanges = true
}
if d := diffInt(oldCredProd, new.CreditsProduction); d != nil {
pc.CreditsProduction = d
hasChanges = true
}
if d := diffInt(oldSteelProd, new.SteelProduction); d != nil {
pc.SteelProduction = d
hasChanges = true
}
if d := diffInt(oldTitaniumProd, new.TitaniumProduction); d != nil {
pc.TitaniumProduction = d
hasChanges = true
}
if d := diffInt(oldPlantsProd, new.PlantsProduction); d != nil {
pc.PlantsProduction = d
hasChanges = true
}
if d := diffInt(oldEnergyProd, new.EnergyProduction); d != nil {
pc.EnergyProduction = d
hasChanges = true
}
if d := diffInt(oldHeatProd, new.HeatProduction); d != nil {
pc.HeatProduction = d
hasChanges = true
}
if d := diffInt(oldTR, new.TerraformRating); d != nil {
pc.TerraformRating = d
hasChanges = true
}
if d := diffBool(oldPassed, new.Passed); d != nil {
pc.Passed = d
hasChanges = true
}
if d := diffString(oldCorp, new.Corporation); d != nil {
pc.Corporation = d
hasChanges = true
}
added, removed := diffStringSlice(oldHand, new.HandCardIDs)
if len(added) > 0 {
pc.CardsAdded = added
hasChanges = true
}
if len(removed) > 0 {
pc.CardsRemoved = removed
hasChanges = true
}
playedAdded, _ := diffStringSlice(oldPlayed, new.PlayedCardIDs)
if len(playedAdded) > 0 {
pc.CardsPlayed = playedAdded
hasChanges = true
}
if !hasChanges {
return nil
}
return pc
}
// computeInitialSnapshotBoardChanges computes board changes for initial state
func computeInitialSnapshotBoardChanges(snapshot *GameSnapshot) *BoardChanges {
if len(snapshot.Tiles) == 0 {
return nil
}
placements := make([]TilePlacement, 0, len(snapshot.Tiles))
for _, tile := range snapshot.Tiles {
placements = append(placements, TilePlacement{
HexID: tile.HexID,
TileType: tile.TileType,
OwnerID: tile.OwnerID,
})
}
return &BoardChanges{TilesPlaced: placements}
}
// computeSnapshotBoardChanges computes changes to the board
func computeSnapshotBoardChanges(old, new *GameSnapshot) *BoardChanges {
var placements []TilePlacement
for hexID, newTile := range new.Tiles {
if _, exists := old.Tiles[hexID]; !exists {
placements = append(placements, TilePlacement{
HexID: newTile.HexID,
TileType: newTile.TileType,
OwnerID: newTile.OwnerID,
})
}
}
if len(placements) == 0 {
return nil
}
return &BoardChanges{TilesPlaced: placements}
}
package game
import (
"go.uber.org/zap"
"terraforming-mars-backend/internal/game/datastore"
"terraforming-mars-backend/internal/logger"
)
type Turn struct {
ds *datastore.DataStore
gameID string
}
func NewTurn(ds *datastore.DataStore, gameID string) *Turn {
return &Turn{ds: ds, gameID: gameID}
}
func (t *Turn) update(fn func(s *datastore.GameState)) {
if err := t.ds.UpdateGame(t.gameID, fn); err != nil {
logger.Get().Warn("Failed to update game state", zap.String("game_id", t.gameID), zap.Error(err))
}
}
func (t *Turn) read(fn func(s *datastore.GameState)) {
if err := t.ds.ReadGame(t.gameID, fn); err != nil {
logger.Get().Warn("Failed to read game state", zap.String("game_id", t.gameID), zap.Error(err))
}
}
func (t *Turn) PlayerID() string {
var v string
t.read(func(s *datastore.GameState) { v = s.CurrentTurnPlayerID })
return v
}
func (t *Turn) ActionsRemaining() int {
var v int
t.read(func(s *datastore.GameState) { v = s.CurrentTurnActions })
return v
}
func (t *Turn) TotalActions() int {
var v int
t.read(func(s *datastore.GameState) { v = s.CurrentTurnTotalActions })
return v
}
func (t *Turn) SetPlayerID(playerID string) {
t.update(func(s *datastore.GameState) { s.CurrentTurnPlayerID = playerID })
}
func (t *Turn) SetActionsRemaining(actions int) {
t.update(func(s *datastore.GameState) { s.CurrentTurnActions = actions })
}
func (t *Turn) SetTotalActions(totalActions int) {
t.update(func(s *datastore.GameState) { s.CurrentTurnTotalActions = totalActions })
}
func (t *Turn) AddExtraActions(amount int) {
t.update(func(s *datastore.GameState) {
if s.CurrentTurnActions >= 0 {
s.CurrentTurnActions += amount
s.CurrentTurnTotalActions += amount
}
})
}
func (t *Turn) GlobalActionCounter() int {
var v int
t.read(func(s *datastore.GameState) { v = s.GlobalActionCounter })
return v
}
func (t *Turn) IncrementGlobalActionCounter() {
t.update(func(s *datastore.GameState) {
s.GlobalActionCounter++
})
}
func (t *Turn) ConsumeAction() bool {
var consumed bool
t.update(func(s *datastore.GameState) {
if s.CurrentTurnActions > 0 {
s.CurrentTurnActions--
consumed = true
}
})
return consumed
}
package logger
import (
"os"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var globalLogger *zap.Logger
// Init initializes the global logger
func Init(logLevel *string) error {
var err error
var appliedLogLevel string
if logLevel != nil {
appliedLogLevel = *logLevel
} else {
appliedLogLevel = "info"
}
var level zapcore.Level
switch appliedLogLevel {
case "debug":
level = zap.DebugLevel
case "info":
level = zap.InfoLevel
case "warn":
level = zap.WarnLevel
case "error":
level = zap.ErrorLevel
default:
level = zap.InfoLevel
}
env := os.Getenv("GO_ENV")
if env == "production" {
config := zap.NewProductionConfig()
config.Level = zap.NewAtomicLevelAt(level)
globalLogger, err = config.Build(zap.AddStacktrace(zap.ErrorLevel))
if err != nil {
return err
}
} else {
output, _, err := zap.Open("stderr")
if err != nil {
return err
}
core := newPrettyCore(level, output)
globalLogger = zap.New(core, zap.AddCaller(), zap.AddStacktrace(zap.ErrorLevel))
}
return nil
}
// Get returns the global logger
func Get() *zap.Logger {
if globalLogger == nil {
// Fallback to development logger if not initialized
globalLogger, _ = zap.NewDevelopment()
}
return globalLogger
}
// Sync flushes the logger
func Sync() error {
if globalLogger != nil {
return globalLogger.Sync()
}
return nil
}
// Shutdown properly closes the logger
func Shutdown() error {
return Sync()
}
// WithContext returns a logger with additional context fields
func WithContext(fields ...zap.Field) *zap.Logger {
return Get().With(fields...)
}
// WithGameContext returns a logger with game-related context
func WithGameContext(gameID, playerID string) *zap.Logger {
fields := make([]zap.Field, 0, 2)
if gameID != "" {
fields = append(fields, zap.String("game_id", gameID))
}
if playerID != "" {
fields = append(fields, zap.String("player_id", playerID))
}
return Get().With(fields...)
}
// WithClientContext returns a logger with client-related context
func WithClientContext(clientID, playerID, gameID string) *zap.Logger {
fields := make([]zap.Field, 0, 3)
if clientID != "" {
fields = append(fields, zap.String("client_id", clientID))
}
if playerID != "" {
fields = append(fields, zap.String("player_id", playerID))
}
if gameID != "" {
fields = append(fields, zap.String("game_id", gameID))
}
return Get().With(fields...)
}
package logger
import (
"fmt"
"strings"
"go.uber.org/zap/zapcore"
)
// ANSI color codes
const (
ansiReset = "\033[0m"
ansiDim = "\033[2m"
ansiGrey = "\033[90m"
ansiLightGrey = "\033[37m"
ansiCyan = "\033[36m"
ansiBlue = "\033[34m"
ansiYellow = "\033[33m"
ansiRed = "\033[31m"
)
// prettyCore is a custom zapcore.Core that formats log output with colors
// and places structured fields on a separate line aligned with the caller.
type prettyCore struct {
zapcore.LevelEnabler
output zapcore.WriteSyncer
fields []zapcore.Field
}
func newPrettyCore(level zapcore.LevelEnabler, output zapcore.WriteSyncer) *prettyCore {
return &prettyCore{
LevelEnabler: level,
output: output,
}
}
func (c *prettyCore) With(fields []zapcore.Field) zapcore.Core {
clone := &prettyCore{
LevelEnabler: c.LevelEnabler,
output: c.output,
fields: make([]zapcore.Field, len(c.fields)+len(fields)),
}
copy(clone.fields, c.fields)
copy(clone.fields[len(c.fields):], fields)
return clone
}
func (c *prettyCore) Check(entry zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry {
if c.Enabled(entry.Level) {
return ce.AddCore(entry, c)
}
return ce
}
func (c *prettyCore) Write(entry zapcore.Entry, fields []zapcore.Field) error {
allFields := append(c.fields[:len(c.fields):len(c.fields)], fields...)
var sb strings.Builder
// Timestamp (dim)
ts := entry.Time.UTC().Format("2006-01-02T15:04:05.000Z")
sb.WriteString(ansiDim)
sb.WriteString(ts)
sb.WriteString(ansiReset)
sb.WriteString(" ")
// Level (colored, padded to 5 chars)
levelStr := entry.Level.CapitalString()
for len(levelStr) < 5 {
levelStr += " "
}
sb.WriteString(levelColor(entry.Level))
sb.WriteString(levelStr)
sb.WriteString(ansiReset)
sb.WriteString(" ")
// Caller (dim)
callerStr := ""
if entry.Caller.Defined {
callerStr = entry.Caller.TrimmedPath()
sb.WriteString(ansiDim)
sb.WriteString(callerStr)
sb.WriteString(ansiReset)
sb.WriteString(" ")
}
// Message (normal)
sb.WriteString(entry.Message)
// Structured fields as key=value pairs on same line, in grey
if len(allFields) > 0 {
kvStr := fieldsToKV(allFields)
if kvStr != "" {
sb.WriteString(" ")
sb.WriteString(kvStr)
sb.WriteString(ansiReset)
}
}
// Stack trace
if entry.Stack != "" {
sb.WriteString("\n")
sb.WriteString(ansiDim)
sb.WriteString(entry.Stack)
sb.WriteString(ansiReset)
}
sb.WriteString("\n")
_, err := c.output.Write([]byte(sb.String()))
return err
}
func (c *prettyCore) Sync() error {
return c.output.Sync()
}
func levelColor(level zapcore.Level) string {
switch level {
case zapcore.DebugLevel:
return ansiCyan
case zapcore.InfoLevel:
return ansiBlue
case zapcore.WarnLevel:
return ansiYellow
case zapcore.ErrorLevel, zapcore.DPanicLevel, zapcore.PanicLevel, zapcore.FatalLevel:
return ansiRed
default:
return ansiReset
}
}
func fieldsToKV(fields []zapcore.Field) string {
enc := zapcore.NewMapObjectEncoder()
for _, f := range fields {
f.AddTo(enc)
}
if len(enc.Fields) == 0 {
return ""
}
parts := make([]string, 0, len(enc.Fields))
for k, v := range enc.Fields {
parts = append(parts, fmt.Sprintf("%s%s=%s%v", ansiGrey, k, ansiLightGrey, v))
}
return strings.Join(parts, " ")
}
package milestones
import (
"encoding/json"
"fmt"
"os"
"terraforming-mars-backend/internal/game/milestone"
)
// LoadMilestonesFromJSON loads milestone definitions from a JSON file
func LoadMilestonesFromJSON(filepath string) ([]milestone.MilestoneDefinition, error) {
data, err := os.ReadFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to read milestones file: %w", err)
}
var defs []milestone.MilestoneDefinition
if err := json.Unmarshal(data, &defs); err != nil {
return nil, fmt.Errorf("failed to parse milestones JSON: %w", err)
}
if len(defs) == 0 {
return nil, fmt.Errorf("no milestones found in file: %s", filepath)
}
return defs, nil
}
package milestones
import (
"fmt"
"terraforming-mars-backend/internal/game/milestone"
)
// MilestoneRegistry provides lookup functionality for milestone definitions
type MilestoneRegistry interface {
GetByID(milestoneID string) (*milestone.MilestoneDefinition, error)
GetAll() []milestone.MilestoneDefinition
}
// InMemoryMilestoneRegistry implements MilestoneRegistry with an in-memory map
type InMemoryMilestoneRegistry struct {
milestones map[string]milestone.MilestoneDefinition
order []string
}
// NewInMemoryMilestoneRegistry creates a new registry from a slice of definitions
func NewInMemoryMilestoneRegistry(defs []milestone.MilestoneDefinition) *InMemoryMilestoneRegistry {
m := make(map[string]milestone.MilestoneDefinition, len(defs))
order := make([]string, 0, len(defs))
for _, d := range defs {
m[d.ID] = d
order = append(order, d.ID)
}
return &InMemoryMilestoneRegistry{milestones: m, order: order}
}
// GetByID retrieves a milestone definition by ID
func (r *InMemoryMilestoneRegistry) GetByID(milestoneID string) (*milestone.MilestoneDefinition, error) {
d, exists := r.milestones[milestoneID]
if !exists {
return nil, fmt.Errorf("milestone not found: %s", milestoneID)
}
return &d, nil
}
// GetAll returns all milestone definitions in their original JSON order
func (r *InMemoryMilestoneRegistry) GetAll() []milestone.MilestoneDefinition {
result := make([]milestone.MilestoneDefinition, 0, len(r.order))
for _, id := range r.order {
result = append(result, r.milestones[id])
}
return result
}
package projectfunding
import (
"encoding/json"
"fmt"
"os"
"terraforming-mars-backend/internal/game/projectfunding"
)
// LoadProjectsFromJSON loads project funding definitions from a JSON file
func LoadProjectsFromJSON(filepath string) ([]projectfunding.ProjectDefinition, error) {
data, err := os.ReadFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to read project funding file: %w", err)
}
var projects []projectfunding.ProjectDefinition
if err := json.Unmarshal(data, &projects); err != nil {
return nil, fmt.Errorf("failed to parse project funding JSON: %w", err)
}
if len(projects) == 0 {
return nil, fmt.Errorf("no projects found in file: %s", filepath)
}
for _, p := range projects {
if len(p.Seats) == 0 {
return nil, fmt.Errorf("project %q has no seats defined", p.ID)
}
}
return projects, nil
}
package projectfunding
import (
"fmt"
"terraforming-mars-backend/internal/game/projectfunding"
)
// ProjectFundingRegistry provides lookup functionality for project funding definitions
type ProjectFundingRegistry interface {
GetByID(projectID string) (*projectfunding.ProjectDefinition, error)
GetAll() []projectfunding.ProjectDefinition
}
// InMemoryProjectFundingRegistry implements ProjectFundingRegistry with an in-memory map
type InMemoryProjectFundingRegistry struct {
projects map[string]projectfunding.ProjectDefinition
}
// NewInMemoryProjectFundingRegistry creates a new project funding registry from a slice of definitions
func NewInMemoryProjectFundingRegistry(projectList []projectfunding.ProjectDefinition) *InMemoryProjectFundingRegistry {
projectMap := make(map[string]projectfunding.ProjectDefinition, len(projectList))
for _, p := range projectList {
projectMap[p.ID] = p
}
return &InMemoryProjectFundingRegistry{projects: projectMap}
}
// GetByID retrieves a project funding definition by ID
func (r *InMemoryProjectFundingRegistry) GetByID(projectID string) (*projectfunding.ProjectDefinition, error) {
p, exists := r.projects[projectID]
if !exists {
return nil, fmt.Errorf("project not found: %s", projectID)
}
return &p, nil
}
// GetAll returns all project funding definitions
func (r *InMemoryProjectFundingRegistry) GetAll() []projectfunding.ProjectDefinition {
result := make([]projectfunding.ProjectDefinition, 0, len(r.projects))
for _, p := range r.projects {
result = append(result, p)
}
return result
}
package bot
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"terraforming-mars-backend/internal/cards"
"terraforming-mars-backend/internal/delivery/dto"
"terraforming-mars-backend/internal/game"
playerPkg "terraforming-mars-backend/internal/game/player"
"terraforming-mars-backend/internal/game/shared"
"go.uber.org/zap"
)
// Broadcaster is used by the controller to broadcast game state and chat after dispatched commands.
type Broadcaster interface {
BroadcastGameState(gameID string, playerIDs []string)
BroadcastChatMessage(gameID string, chatMsg dto.ChatMessageDto)
}
// BotController manages all bot sessions and coordinates the turn-play loop.
type BotController struct {
gameRepo game.GameRepository
stateRepo game.GameStateRepository
cardRegistry cards.CardRegistry
dispatcher *CommandDispatcher
broadcaster Broadcaster
logger *zap.Logger
mu sync.Mutex
sessions map[string]map[string]*BotSession // gameID -> playerID -> session
}
// BotSession holds the state for a single bot player in a game.
type BotSession struct {
gameID string
playerID string
botName string
model string
apiKey string
difficulty string
runDir string
invoker *Invoker
stateWriter *StateWriter
commandReader *CommandReader
historyWriter *HistoryWriter
turnCh chan struct{}
cancel context.CancelFunc
done chan struct{}
}
// NewBotController creates a new bot controller.
func NewBotController(
gameRepo game.GameRepository,
stateRepo game.GameStateRepository,
cardRegistry cards.CardRegistry,
dispatcher *CommandDispatcher,
broadcaster Broadcaster,
logger *zap.Logger,
) *BotController {
return &BotController{
gameRepo: gameRepo,
stateRepo: stateRepo,
cardRegistry: cardRegistry,
dispatcher: dispatcher,
broadcaster: broadcaster,
logger: logger,
sessions: make(map[string]map[string]*BotSession),
}
}
// StartBot initializes and starts a bot session for the given player.
func (bc *BotController) StartBot(gameID, playerID, botName, difficulty, speed string, settings shared.GameSettings) error {
bc.mu.Lock()
defer bc.mu.Unlock()
if _, exists := bc.sessions[gameID]; !exists {
bc.sessions[gameID] = make(map[string]*BotSession)
}
if _, exists := bc.sessions[gameID][playerID]; exists {
return fmt.Errorf("bot session already exists for player %s in game %s", playerID, gameID)
}
var model string
switch speed {
case "fast":
model = "haiku"
case "thinker":
model = "opus"
default:
model = "sonnet"
}
runDir, err := os.MkdirTemp("", fmt.Sprintf("tm-bot-%s-", playerID[:8]))
if err != nil {
return fmt.Errorf("create bot temp dir: %w", err)
}
statePath := filepath.Join(runDir, "state.txt")
commandPath := filepath.Join(runDir, "commands.jsonl")
historyPath := filepath.Join(runDir, "history.log")
botLogger := bc.logger.With(
zap.String("game_id", gameID),
zap.String("player_id", playerID),
zap.String("bot_name", botName),
)
historyWriter, err := NewHistoryWriter(historyPath, botLogger)
if err != nil {
if removeErr := os.RemoveAll(runDir); removeErr != nil {
bc.logger.Warn("Failed to remove bot run directory", zap.String("path", runDir), zap.Error(removeErr))
}
return fmt.Errorf("create history writer: %w", err)
}
commandReader := NewCommandReader(commandPath, botLogger)
if err := commandReader.Start(); err != nil {
if closeErr := historyWriter.Close(); closeErr != nil {
bc.logger.Warn("Failed to close history writer", zap.Error(closeErr))
}
if removeErr := os.RemoveAll(runDir); removeErr != nil {
bc.logger.Warn("Failed to remove bot run directory", zap.String("path", runDir), zap.Error(removeErr))
}
return fmt.Errorf("start command reader: %w", err)
}
ctx, cancel := context.WithCancel(context.Background())
if difficulty == "" {
difficulty = "normal"
}
session := &BotSession{
gameID: gameID,
playerID: playerID,
botName: botName,
model: model,
apiKey: settings.ClaudeAPIKey,
difficulty: difficulty,
runDir: runDir,
invoker: NewInvoker(historyPath, statePath, commandPath, model, settings.ClaudeAPIKey, difficulty, botLogger),
stateWriter: NewStateWriter(statePath),
commandReader: commandReader,
historyWriter: historyWriter,
turnCh: make(chan struct{}, 1),
cancel: cancel,
done: make(chan struct{}),
}
bc.sessions[gameID][playerID] = session
go bc.runBotLoop(ctx, session)
bc.logger.Debug("Bot session started",
zap.String("game_id", gameID),
zap.String("player_id", playerID),
zap.String("bot_name", botName),
zap.String("model", model))
return nil
}
// OnGameBroadcast is called by the Broadcaster after every game state broadcast.
// It checks if any bot in the game should take a turn.
func (bc *BotController) OnGameBroadcast(gameID string) {
bc.mu.Lock()
gameSessions, exists := bc.sessions[gameID]
if !exists {
bc.mu.Unlock()
return
}
sessions := make([]*BotSession, 0, len(gameSessions))
for _, s := range gameSessions {
sessions = append(sessions, s)
}
bc.mu.Unlock()
ctx := context.Background()
g, err := bc.gameRepo.Get(ctx, gameID)
if err != nil {
return
}
for _, session := range sessions {
// Skip exited bot players
p, err := g.GetPlayer(session.playerID)
if err != nil || p.HasExited() {
continue
}
gameDto := dto.ToGameDto(g, bc.cardRegistry, session.playerID)
if IsMyTurn(&gameDto, session.playerID) {
select {
case session.turnCh <- struct{}{}:
default:
}
}
}
}
// BotStopper can stop individual bot sessions.
type BotStopper interface {
StopBot(gameID, playerID string)
}
// BotGameStopper can stop all bot sessions for a game.
type BotGameStopper interface {
StopAllBotsForGame(gameID string)
}
// StopBot stops a single bot session for a player.
func (bc *BotController) StopBot(gameID, playerID string) {
bc.mu.Lock()
gameSessions, exists := bc.sessions[gameID]
if !exists {
bc.mu.Unlock()
return
}
session, exists := gameSessions[playerID]
if !exists {
bc.mu.Unlock()
return
}
delete(gameSessions, playerID)
bc.mu.Unlock()
session.cancel()
<-session.done
bc.cleanupSession(session)
bc.logger.Debug("Bot stopped for player",
zap.String("game_id", gameID),
zap.String("player_id", playerID))
}
// StopAllBotsForGame stops all bot sessions for a game.
func (bc *BotController) StopAllBotsForGame(gameID string) {
bc.mu.Lock()
gameSessions, exists := bc.sessions[gameID]
if !exists {
bc.mu.Unlock()
return
}
delete(bc.sessions, gameID)
bc.mu.Unlock()
for _, session := range gameSessions {
session.cancel()
<-session.done
bc.cleanupSession(session)
}
bc.logger.Debug("All bots stopped for game", zap.String("game_id", gameID))
}
func (bc *BotController) runBotLoop(ctx context.Context, session *BotSession) {
defer close(session.done)
log := bc.logger.With(
zap.String("game_id", session.gameID),
zap.String("player_id", session.playerID),
)
for {
select {
case <-ctx.Done():
log.Debug("Bot loop exiting")
return
case <-session.turnCh:
bc.handleTurn(ctx, session, log)
}
}
}
func (bc *BotController) handleTurn(ctx context.Context, session *BotSession, log *zap.Logger) {
for {
if ctx.Err() != nil {
return
}
g, err := bc.gameRepo.Get(ctx, session.gameID)
if err != nil {
log.Error("Failed to get game", zap.Error(err))
return
}
gameDto := dto.ToGameDto(g, bc.cardRegistry, session.playerID)
if !IsMyTurn(&gameDto, session.playerID) {
log.Debug("Not my turn anymore, stopping")
return
}
log.Debug("Starting turn invocation")
if bot, err := g.GetPlayer(session.playerID); err == nil {
bot.SetBotStatus(playerPkg.BotStatusThinking)
bc.broadcaster.BroadcastGameState(session.gameID, nil)
}
summary := SummarizeGameState(&gameDto, session.playerID)
// Append recent action log and chat messages
if diffs, err := bc.stateRepo.GetDiff(ctx, session.gameID); err == nil {
summary += "\n\n" + formatRecentLog(diffs, 20)
}
chatMessages := g.GetChatMessages()
if len(chatMessages) > 0 {
summary += "\n\n" + formatRecentChat(chatMessages, 10)
}
if err := session.stateWriter.WriteState(summary); err != nil {
log.Error("Failed to write state", zap.Error(err))
return
}
if err := session.commandReader.Reset(); err != nil {
log.Error("Failed to reset command reader", zap.Error(err))
return
}
cmdCtx, cmdCancel := context.WithCancel(ctx)
cmdDone := make(chan struct{})
go bc.processCommands(cmdCtx, session, cmdDone, log)
invokeCtx, invokeCancel := context.WithTimeout(ctx, 5*time.Minute)
err = session.invoker.PlayTurn(invokeCtx, &gameDto, session.playerID)
invokeCancel()
if err != nil {
log.Error("Claude CLI invocation failed", zap.Error(err))
}
// Give time for remaining commands to be processed
time.Sleep(3 * time.Second)
cmdCancel()
<-cmdDone
log.Debug("Turn invocation complete")
if g, err := bc.gameRepo.Get(ctx, session.gameID); err == nil {
if bot, err := g.GetPlayer(session.playerID); err == nil {
bot.SetBotStatus(playerPkg.BotStatusReady)
bc.broadcaster.BroadcastGameState(session.gameID, nil)
}
}
}
}
func (bc *BotController) processCommands(ctx context.Context, session *BotSession, done chan struct{}, log *zap.Logger) {
defer close(done)
for {
select {
case <-ctx.Done():
return
case rawCmd, ok := <-session.commandReader.Commands():
if !ok {
return
}
bc.executeCommand(ctx, session, rawCmd, log)
}
}
}
func (bc *BotController) executeCommand(ctx context.Context, session *BotSession, rawCmd json.RawMessage, log *zap.Logger) {
session.historyWriter.WriteSent("command", rawCmd)
var envelope struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
if err := json.Unmarshal(rawCmd, &envelope); err != nil {
log.Error("Failed to parse command envelope", zap.Error(err))
return
}
// Handle chat messages directly (not through dispatcher to avoid import cycle)
if envelope.Type == "chat.send-message" {
bc.handleChatMessage(ctx, session, envelope.Payload, log)
return
}
err := bc.dispatcher.Dispatch(ctx, session.gameID, session.playerID, rawCmd)
if err != nil {
log.Error("Command dispatch failed", zap.Error(err))
errPayload, _ := json.Marshal(map[string]string{
"type": "error",
"error": err.Error(),
})
session.historyWriter.WriteReceived("error", errPayload)
return
}
successPayload, _ := json.Marshal(map[string]string{
"type": "action-success",
})
session.historyWriter.WriteReceived("action-success", successPayload)
bc.broadcaster.BroadcastGameState(session.gameID, nil)
if g, err := bc.gameRepo.Get(ctx, session.gameID); err == nil {
gameDto := dto.ToGameDto(g, bc.cardRegistry, session.playerID)
summary := SummarizeGameState(&gameDto, session.playerID)
if err := session.stateWriter.WriteState(summary); err != nil {
log.Error("Failed to update state file after command", zap.Error(err))
}
}
}
func (bc *BotController) handleChatMessage(ctx context.Context, session *BotSession, payload json.RawMessage, log *zap.Logger) {
var p struct {
Message string `json:"message"`
}
if err := json.Unmarshal(payload, &p); err != nil {
log.Error("Failed to parse chat message payload", zap.Error(err))
return
}
if len(p.Message) == 0 {
return
}
if len(p.Message) > shared.MaxChatMessageLength {
p.Message = p.Message[:shared.MaxChatMessageLength]
}
g, err := bc.gameRepo.Get(ctx, session.gameID)
if err != nil {
log.Error("Failed to get game for chat", zap.Error(err))
return
}
bot, err := g.GetPlayer(session.playerID)
if err != nil {
log.Error("Failed to get bot player for chat", zap.Error(err))
return
}
chatMsg := shared.ChatMessage{
SenderID: session.playerID,
SenderName: bot.Name(),
SenderColor: bot.Color(),
Message: p.Message,
Timestamp: time.Now(),
}
g.AddChatMessage(ctx, chatMsg)
successPayload, _ := json.Marshal(map[string]string{
"type": "action-success",
})
session.historyWriter.WriteReceived("action-success", successPayload)
bc.broadcaster.BroadcastChatMessage(session.gameID, dto.ChatMessageDto{
SenderID: chatMsg.SenderID,
SenderName: chatMsg.SenderName,
SenderColor: chatMsg.SenderColor,
Message: chatMsg.Message,
Timestamp: chatMsg.Timestamp.Format(time.RFC3339),
})
}
func (bc *BotController) cleanupSession(session *BotSession) {
session.commandReader.Stop()
if err := session.historyWriter.Close(); err != nil {
bc.logger.Warn("Failed to close history writer", zap.Error(err))
}
if err := os.RemoveAll(session.runDir); err != nil {
bc.logger.Warn("Failed to remove bot run directory", zap.String("path", session.runDir), zap.Error(err))
}
}
package bot
import (
"context"
"encoding/json"
"fmt"
"maps"
awardAction "terraforming-mars-backend/internal/action/award"
cardAction "terraforming-mars-backend/internal/action/card"
confirmAction "terraforming-mars-backend/internal/action/confirmation"
milestoneAction "terraforming-mars-backend/internal/action/milestone"
resconvAction "terraforming-mars-backend/internal/action/resource_conversion"
stdprojAction "terraforming-mars-backend/internal/action/standard_project"
tileAction "terraforming-mars-backend/internal/action/tile"
turnAction "terraforming-mars-backend/internal/action/turn_management"
gamecards "terraforming-mars-backend/internal/game/cards"
"terraforming-mars-backend/internal/game/shared"
"go.uber.org/zap"
)
// CommandDispatcher maps bot JSONL commands to direct action calls.
type CommandDispatcher struct {
playCard *cardAction.PlayCardAction
useCardAction *cardAction.UseCardActionAction
skipAction *turnAction.SkipActionAction
selectStartingChoices *turnAction.SelectStartingChoicesAction
selectTile *tileAction.SelectTileAction
confirmProductionCards *confirmAction.ConfirmProductionCardsAction
confirmCardDraw *confirmAction.ConfirmCardDrawAction
confirmCardDiscard *confirmAction.ConfirmCardDiscardAction
confirmBehaviorChoice *confirmAction.ConfirmBehaviorChoiceAction
confirmSellPatents *confirmAction.ConfirmSellPatentsAction
executeStandardProject *stdprojAction.ExecuteStandardProjectAction
convertHeat *resconvAction.ConvertHeatToTemperatureAction
convertPlants *resconvAction.ConvertPlantsToGreeneryAction
claimMilestone *milestoneAction.ClaimMilestoneAction
fundAward *awardAction.FundAwardAction
confirmInitAdvance *turnAction.ConfirmInitAdvanceAction
logger *zap.Logger
}
// NewCommandDispatcher creates a new dispatcher with all action references.
func NewCommandDispatcher(
playCard *cardAction.PlayCardAction,
useCardAction *cardAction.UseCardActionAction,
skipAction *turnAction.SkipActionAction,
selectStartingChoices *turnAction.SelectStartingChoicesAction,
selectTile *tileAction.SelectTileAction,
confirmProductionCards *confirmAction.ConfirmProductionCardsAction,
confirmCardDraw *confirmAction.ConfirmCardDrawAction,
confirmCardDiscard *confirmAction.ConfirmCardDiscardAction,
confirmBehaviorChoice *confirmAction.ConfirmBehaviorChoiceAction,
confirmSellPatents *confirmAction.ConfirmSellPatentsAction,
executeStandardProject *stdprojAction.ExecuteStandardProjectAction,
convertHeat *resconvAction.ConvertHeatToTemperatureAction,
convertPlants *resconvAction.ConvertPlantsToGreeneryAction,
claimMilestone *milestoneAction.ClaimMilestoneAction,
fundAward *awardAction.FundAwardAction,
confirmInitAdvance *turnAction.ConfirmInitAdvanceAction,
logger *zap.Logger,
) *CommandDispatcher {
return &CommandDispatcher{
playCard: playCard,
useCardAction: useCardAction,
skipAction: skipAction,
selectStartingChoices: selectStartingChoices,
selectTile: selectTile,
confirmProductionCards: confirmProductionCards,
confirmCardDraw: confirmCardDraw,
confirmCardDiscard: confirmCardDiscard,
confirmBehaviorChoice: confirmBehaviorChoice,
confirmSellPatents: confirmSellPatents,
executeStandardProject: executeStandardProject,
convertHeat: convertHeat,
convertPlants: convertPlants,
claimMilestone: claimMilestone,
fundAward: fundAward,
confirmInitAdvance: confirmInitAdvance,
logger: logger,
}
}
// Dispatch parses a raw JSON command and calls the appropriate action.
func (d *CommandDispatcher) Dispatch(ctx context.Context, gameID, playerID string, rawJSON json.RawMessage) error {
var envelope struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
if err := json.Unmarshal(rawJSON, &envelope); err != nil {
return fmt.Errorf("parse command envelope: %w", err)
}
d.logger.Debug("Dispatching bot command",
zap.String("game_id", gameID),
zap.String("player_id", playerID),
zap.String("type", envelope.Type))
switch envelope.Type {
case "action.card.play-card":
return d.dispatchPlayCard(ctx, gameID, playerID, envelope.Payload)
case "action.card.card-action":
return d.dispatchUseCardAction(ctx, gameID, playerID, envelope.Payload)
case "action.game-management.skip-action":
return d.skipAction.Execute(ctx, gameID, playerID)
case "action.card.select-starting-choices":
return d.dispatchSelectStartingChoices(ctx, gameID, playerID, envelope.Payload)
case "action.tile-selection.tile-selected":
return d.dispatchSelectTile(ctx, gameID, playerID, envelope.Payload)
case "action.card.confirm-production-cards":
return d.dispatchConfirmProductionCards(ctx, gameID, playerID, envelope.Payload)
case "action.card.card-draw-confirmed":
return d.dispatchConfirmCardDraw(ctx, gameID, playerID, envelope.Payload)
case "action.card.card-discard-confirmed":
return d.dispatchConfirmCardDiscard(ctx, gameID, playerID, envelope.Payload)
case "action.card.behavior-choice-confirmed":
return d.dispatchConfirmBehaviorChoice(ctx, gameID, playerID, envelope.Payload)
case "action.card.select-cards":
return d.dispatchConfirmSellPatents(ctx, gameID, playerID, envelope.Payload)
case "action.standard-project":
return d.dispatchStandardProject(ctx, gameID, playerID, envelope.Payload)
case "action.standard-project.sell-patents":
return d.executeStandardProject.Execute(ctx, gameID, playerID, "sell-patents")
case "action.standard-project.confirm-sell-patents":
return d.dispatchConfirmSellPatents(ctx, gameID, playerID, envelope.Payload)
case "action.standard-project.launch-asteroid":
return d.executeStandardProject.Execute(ctx, gameID, playerID, "asteroid")
case "action.standard-project.build-power-plant":
return d.executeStandardProject.Execute(ctx, gameID, playerID, "power-plant")
case "action.standard-project.build-aquifer":
return d.executeStandardProject.Execute(ctx, gameID, playerID, "aquifer")
case "action.standard-project.plant-greenery":
return d.executeStandardProject.Execute(ctx, gameID, playerID, "greenery")
case "action.standard-project.build-city":
return d.executeStandardProject.Execute(ctx, gameID, playerID, "city")
case "action.resource-conversion.convert-heat-to-temperature":
return d.convertHeat.Execute(ctx, gameID, playerID, nil)
case "action.resource-conversion.convert-plants-to-greenery":
return d.convertPlants.Execute(ctx, gameID, playerID, nil)
case "action.game-management.confirm-init-advance":
return d.confirmInitAdvance.Execute(ctx, gameID, playerID)
case "action.milestone.claim-milestone":
return d.dispatchClaimMilestone(ctx, gameID, playerID, envelope.Payload)
case "action.award.fund-award":
return d.dispatchFundAward(ctx, gameID, playerID, envelope.Payload)
default:
return fmt.Errorf("unknown command type: %s", envelope.Type)
}
}
func (d *CommandDispatcher) dispatchPlayCard(ctx context.Context, gameID, playerID string, payload json.RawMessage) error {
var p struct {
CardID string `json:"cardId"`
Payment playCardPayment `json:"payment"`
ChoiceIndex *int `json:"choiceIndex,omitempty"`
CardStorageTargets []string `json:"cardStorageTargets,omitempty"`
TargetPlayerID *string `json:"targetPlayerId,omitempty"`
SelectedAmount *int `json:"selectedAmount,omitempty"`
}
if err := json.Unmarshal(payload, &p); err != nil {
return fmt.Errorf("parse play-card payload: %w", err)
}
payment := cardAction.PaymentRequest{
Credits: p.Payment.Credits,
Steel: p.Payment.Steel,
Titanium: p.Payment.Titanium,
Substitutes: make(map[shared.ResourceType]int),
StorageSubstitutes: make(map[string]int),
}
for k, v := range p.Payment.Substitutes {
payment.Substitutes[shared.ResourceType(k)] = v
}
maps.Copy(payment.StorageSubstitutes, p.Payment.StorageSubstitutes)
return d.playCard.Execute(ctx, gameID, playerID, p.CardID, payment, p.ChoiceIndex, p.CardStorageTargets, p.TargetPlayerID, p.SelectedAmount)
}
type playCardPayment struct {
Credits int `json:"credits"`
Steel int `json:"steel"`
Titanium int `json:"titanium"`
Substitutes map[string]int `json:"substitutes,omitempty"`
StorageSubstitutes map[string]int `json:"storageSubstitutes,omitempty"`
}
func (d *CommandDispatcher) dispatchUseCardAction(ctx context.Context, gameID, playerID string, payload json.RawMessage) error {
var p struct {
CardID string `json:"cardId"`
BehaviorIndex int `json:"behaviorIndex"`
ChoiceIndex *int `json:"choiceIndex,omitempty"`
CardStorageTargets []string `json:"cardStorageTargets,omitempty"`
TargetPlayerID *string `json:"targetPlayerId,omitempty"`
SourceCardForInput *string `json:"sourceCardForInput,omitempty"`
SelectedAmount *int `json:"selectedAmount,omitempty"`
Payment *struct {
Credits int `json:"credits"`
Steel int `json:"steel"`
Titanium int `json:"titanium"`
} `json:"payment,omitempty"`
}
if err := json.Unmarshal(payload, &p); err != nil {
return fmt.Errorf("parse card-action payload: %w", err)
}
var actionPayment *gamecards.CardPayment
if p.Payment != nil {
actionPayment = &gamecards.CardPayment{
Credits: p.Payment.Credits,
Steel: p.Payment.Steel,
Titanium: p.Payment.Titanium,
}
}
return d.useCardAction.Execute(ctx, gameID, playerID, p.CardID, p.BehaviorIndex, p.ChoiceIndex, p.CardStorageTargets, p.TargetPlayerID, p.SourceCardForInput, p.SelectedAmount, actionPayment, nil)
}
func (d *CommandDispatcher) dispatchSelectStartingChoices(ctx context.Context, gameID, playerID string, payload json.RawMessage) error {
var p struct {
CorporationID string `json:"corporationId"`
PreludeIDs []string `json:"preludeIds"`
CardIDs []string `json:"cardIds"`
}
if err := json.Unmarshal(payload, &p); err != nil {
return fmt.Errorf("parse select-starting-choices payload: %w", err)
}
return d.selectStartingChoices.Execute(ctx, gameID, playerID, p.CorporationID, p.PreludeIDs, p.CardIDs)
}
func (d *CommandDispatcher) dispatchSelectTile(ctx context.Context, gameID, playerID string, payload json.RawMessage) error {
var p struct {
Hex string `json:"hex"`
}
if err := json.Unmarshal(payload, &p); err != nil {
return fmt.Errorf("parse tile-selected payload: %w", err)
}
_, err := d.selectTile.Execute(ctx, gameID, playerID, p.Hex)
return err
}
func (d *CommandDispatcher) dispatchConfirmProductionCards(ctx context.Context, gameID, playerID string, payload json.RawMessage) error {
var p struct {
CardIDs []string `json:"cardIds"`
}
if err := json.Unmarshal(payload, &p); err != nil {
return fmt.Errorf("parse confirm-production-cards payload: %w", err)
}
return d.confirmProductionCards.Execute(ctx, gameID, playerID, p.CardIDs)
}
func (d *CommandDispatcher) dispatchConfirmCardDraw(ctx context.Context, gameID, playerID string, payload json.RawMessage) error {
var p struct {
CardsToTake []string `json:"cardsToTake"`
CardsToBuy []string `json:"cardsToBuy"`
}
if err := json.Unmarshal(payload, &p); err != nil {
return fmt.Errorf("parse card-draw-confirmed payload: %w", err)
}
return d.confirmCardDraw.Execute(ctx, gameID, playerID, p.CardsToTake, p.CardsToBuy)
}
func (d *CommandDispatcher) dispatchConfirmCardDiscard(ctx context.Context, gameID, playerID string, payload json.RawMessage) error {
var p struct {
CardsToDiscard []string `json:"cardsToDiscard"`
}
if err := json.Unmarshal(payload, &p); err != nil {
return fmt.Errorf("parse card-discard-confirmed payload: %w", err)
}
return d.confirmCardDiscard.Execute(ctx, gameID, playerID, p.CardsToDiscard)
}
func (d *CommandDispatcher) dispatchConfirmBehaviorChoice(ctx context.Context, gameID, playerID string, payload json.RawMessage) error {
var p struct {
ChoiceIndex int `json:"choiceIndex"`
CardStorageTargets []string `json:"cardStorageTargets,omitempty"`
}
if err := json.Unmarshal(payload, &p); err != nil {
return fmt.Errorf("parse behavior-choice-confirmed payload: %w", err)
}
return d.confirmBehaviorChoice.Execute(ctx, gameID, playerID, p.ChoiceIndex, p.CardStorageTargets)
}
func (d *CommandDispatcher) dispatchConfirmSellPatents(ctx context.Context, gameID, playerID string, payload json.RawMessage) error {
var p struct {
SelectedCardIDs []string `json:"selectedCardIds"`
CardIDs []string `json:"cardIds"`
}
if err := json.Unmarshal(payload, &p); err != nil {
return fmt.Errorf("parse confirm-sell-patents payload: %w", err)
}
ids := p.SelectedCardIDs
if len(ids) == 0 {
ids = p.CardIDs
}
return d.confirmSellPatents.Execute(ctx, gameID, playerID, ids)
}
func (d *CommandDispatcher) dispatchClaimMilestone(ctx context.Context, gameID, playerID string, payload json.RawMessage) error {
var p struct {
MilestoneType string `json:"milestoneType"`
}
if err := json.Unmarshal(payload, &p); err != nil {
return fmt.Errorf("parse claim-milestone payload: %w", err)
}
return d.claimMilestone.Execute(ctx, gameID, playerID, p.MilestoneType)
}
func (d *CommandDispatcher) dispatchFundAward(ctx context.Context, gameID, playerID string, payload json.RawMessage) error {
var p struct {
AwardType string `json:"awardType"`
}
if err := json.Unmarshal(payload, &p); err != nil {
return fmt.Errorf("parse fund-award payload: %w", err)
}
return d.fundAward.Execute(ctx, gameID, playerID, p.AwardType)
}
func (d *CommandDispatcher) dispatchStandardProject(ctx context.Context, gameID, playerID string, payload json.RawMessage) error {
var p struct {
ProjectID string `json:"projectId"`
}
if err := json.Unmarshal(payload, &p); err != nil {
return fmt.Errorf("parse standard-project payload: %w", err)
}
return d.executeStandardProject.Execute(ctx, gameID, playerID, p.ProjectID)
}
package bot
import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"go.uber.org/zap"
)
// HistoryWriter appends messages to a log file for Claude to verify command results.
type HistoryWriter struct {
file *os.File
mu sync.Mutex
logger *zap.Logger
}
// NewHistoryWriter creates a new history writer that truncates the log file.
func NewHistoryWriter(path string, logger *zap.Logger) (*HistoryWriter, error) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return nil, fmt.Errorf("open history file: %w", err)
}
return &HistoryWriter{file: f, logger: logger}, nil
}
// WriteReceived logs an inbound message (server response).
func (h *HistoryWriter) WriteReceived(msgType string, data json.RawMessage) {
h.write("<<", msgType, data)
}
// WriteSent logs an outbound message (bot command).
func (h *HistoryWriter) WriteSent(msgType string, data json.RawMessage) {
h.write(">>", msgType, data)
}
func (h *HistoryWriter) write(direction, msgType string, data json.RawMessage) {
h.mu.Lock()
defer h.mu.Unlock()
ts := time.Now().Format("15:04:05.000")
line := fmt.Sprintf("[%s] %s %s %s\n", ts, direction, msgType, string(data))
if _, err := h.file.WriteString(line); err != nil {
h.logger.Error("Failed to write history", zap.Error(err))
}
}
// Close closes the history file.
func (h *HistoryWriter) Close() error {
return h.file.Close()
}
// StateWriter atomically writes the game state summary to a file.
type StateWriter struct {
path string
}
// NewStateWriter creates a new state writer.
func NewStateWriter(path string) *StateWriter {
return &StateWriter{path: path}
}
// WriteState atomically writes the summary to the state file.
func (w *StateWriter) WriteState(summary string) error {
tmpPath := w.path + ".tmp"
if err := os.WriteFile(tmpPath, []byte(summary), 0644); err != nil {
return fmt.Errorf("write tmp state: %w", err)
}
if err := os.Rename(tmpPath, w.path); err != nil {
return fmt.Errorf("rename state file: %w", err)
}
return nil
}
// CommandReader polls a JSONL file for new commands written by Claude.
type CommandReader struct {
path string
commands chan json.RawMessage
done chan struct{}
offset int64
logger *zap.Logger
}
// NewCommandReader creates a new command reader.
func NewCommandReader(path string, logger *zap.Logger) *CommandReader {
return &CommandReader{
path: path,
commands: make(chan json.RawMessage, 32),
done: make(chan struct{}),
logger: logger,
}
}
// Start creates the command file and begins polling.
func (r *CommandReader) Start() error {
if err := os.MkdirAll(filepath.Dir(r.path), 0755); err != nil {
return err
}
f, err := os.Create(r.path)
if err != nil {
return fmt.Errorf("create command file: %w", err)
}
if err := f.Close(); err != nil {
r.logger.Warn("Failed to close command file after creation", zap.Error(err))
}
go r.watch()
return nil
}
// Commands returns a channel of raw JSON commands.
func (r *CommandReader) Commands() <-chan json.RawMessage {
return r.commands
}
// Stop stops the command reader.
func (r *CommandReader) Stop() {
close(r.done)
}
// Reset truncates the command file and resets the offset.
func (r *CommandReader) Reset() error {
f, err := os.Create(r.path)
if err != nil {
return fmt.Errorf("reset command file: %w", err)
}
if err := f.Close(); err != nil {
r.logger.Warn("Failed to close command file after reset", zap.Error(err))
}
r.offset = 0
return nil
}
func (r *CommandReader) watch() {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-r.done:
return
case <-ticker.C:
r.readNewLines()
}
}
}
func (r *CommandReader) readNewLines() {
f, err := os.Open(r.path)
if err != nil {
return
}
defer func() {
if err := f.Close(); err != nil {
r.logger.Warn("Failed to close command file after reading", zap.Error(err))
}
}()
if _, err := f.Seek(r.offset, io.SeekStart); err != nil {
return
}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var raw json.RawMessage
if err := json.Unmarshal([]byte(line), &raw); err != nil {
r.logger.Warn("Invalid JSON line in command file", zap.String("line", line))
continue
}
select {
case r.commands <- raw:
case <-r.done:
return
}
}
newOffset, _ := f.Seek(0, io.SeekCurrent)
r.offset = newOffset
}
package bot
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
"go.uber.org/zap"
)
var greetingPersonalities = map[string]string{
"normal": "You are friendly and casual. Use simple, enthusiastic language. Say things like 'Hey!', 'Let's go!', 'This is gonna be fun!'",
"hard": "You are respectful but competitive. Confident, concise. Acknowledge the challenge ahead.",
"extreme": "You are intimidating and precise. Cold, calculated. Short, sharp statements. You exude quiet dominance.",
}
// HealthChecker verifies that a Claude API key is valid by generating a greeting.
type HealthChecker struct {
logger *zap.Logger
}
// NewHealthChecker creates a new health checker.
func NewHealthChecker(logger *zap.Logger) *HealthChecker {
return &HealthChecker{logger: logger}
}
// CheckHealth runs a Claude CLI prompt that both verifies the API key works and generates a lobby greeting.
// Returns the greeting message on success.
func (hc *HealthChecker) CheckHealth(ctx context.Context, apiKey, model, botName, difficulty string) (string, error) {
if model == "" {
model = "sonnet"
}
personality := greetingPersonalities[difficulty]
if personality == "" {
personality = greetingPersonalities["normal"]
}
prompt := fmt.Sprintf(
"You are %s, an AI bot joining a Terraforming Mars game lobby. %s Write a short greeting (1 sentence, max 15 words). Output ONLY the greeting, nothing else.",
botName, personality,
)
checkCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
cmd := exec.CommandContext(checkCtx, "claude",
"-p",
"--model", model,
"--output-format", "text",
prompt,
)
cmd.Env = append(os.Environ(), fmt.Sprintf("CLAUDE_CODE_OAUTH_TOKEN=%s", apiKey))
output, err := cmd.CombinedOutput()
if err != nil {
hc.logger.Error("Health check failed",
zap.Error(err),
zap.String("output", string(output)))
return "", fmt.Errorf("claude health check failed: %w", err)
}
greeting := strings.TrimSpace(string(output))
hc.logger.Debug("Health check passed", zap.String("greeting", greeting))
return greeting, nil
}
package bot
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"terraforming-mars-backend/internal/delivery/dto"
"go.uber.org/zap"
)
// Invoker executes the Claude CLI as a subprocess to decide turns.
type Invoker struct {
HistoryPath string
StatePath string
CommandPath string
Model string
APIKey string
Difficulty string
logger *zap.Logger
}
// Stream event types from claude --output-format stream-json
type streamEvent struct {
Type string `json:"type"`
Subtype string `json:"subtype,omitempty"`
Message *streamMessage `json:"message,omitempty"`
Result string `json:"result,omitempty"`
Cost float64 `json:"total_cost_usd,omitempty"`
NumTurns int `json:"num_turns,omitempty"`
DurationMs int `json:"duration_ms,omitempty"`
ToolUseResult *toolUseResult `json:"tool_use_result,omitempty"`
}
type streamMessage struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
}
type contentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
Content json.RawMessage `json:"content,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
}
type toolUseResult struct {
Type string `json:"type"`
File *toolUseFile `json:"file,omitempty"`
}
type toolUseFile struct {
FilePath string `json:"filePath,omitempty"`
NumLines int `json:"numLines,omitempty"`
}
// NewInvoker creates a new Claude CLI invoker.
func NewInvoker(historyPath, statePath, commandPath, model, apiKey, difficulty string, logger *zap.Logger) *Invoker {
return &Invoker{
HistoryPath: historyPath,
StatePath: statePath,
CommandPath: commandPath,
Model: model,
APIKey: apiKey,
Difficulty: difficulty,
logger: logger,
}
}
// PlayTurn invokes Claude CLI to decide and execute actions for the current turn.
func (inv *Invoker) PlayTurn(ctx context.Context, gameDto *dto.GameDto, myPlayerID string) error {
systemPrompt := buildSystemPrompt(inv.CommandPath, inv.Difficulty, inv.logger)
turnPrompt := buildTurnPrompt(gameDto, inv.StatePath, inv.CommandPath, inv.HistoryPath)
inv.logger.Debug("Invoking Claude CLI",
zap.String("model", inv.Model),
zap.String("player_id", myPlayerID))
cmd := exec.CommandContext(ctx, "claude",
"--print",
"--output-format", "stream-json",
"--verbose",
"--model", inv.Model,
"--dangerously-skip-permissions",
"--tools", "Read,Bash",
"--append-system-prompt", systemPrompt,
turnPrompt,
)
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
// Set API key in environment
if inv.APIKey != "" {
cmd.Env = append(cmd.Environ(), "CLAUDE_CODE_OAUTH_TOKEN="+inv.APIKey)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start claude CLI: %w", err)
}
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var event streamEvent
if err := json.Unmarshal(line, &event); err != nil {
continue
}
inv.displayEvent(&event)
}
err = cmd.Wait()
if err != nil {
return fmt.Errorf("claude CLI: %w", err)
}
inv.logger.Debug("Claude CLI exited")
return nil
}
func (inv *Invoker) displayEvent(event *streamEvent) {
switch event.Type {
case "assistant":
if event.Message == nil {
return
}
var blocks []contentBlock
if err := json.Unmarshal(event.Message.Content, &blocks); err != nil {
return
}
for _, block := range blocks {
switch block.Type {
case "text":
if block.Text != "" {
inv.logger.Debug("Claude", zap.String("text", block.Text))
}
case "tool_use":
inv.displayToolUse(block)
}
}
case "user":
if event.ToolUseResult != nil && event.ToolUseResult.File != nil {
f := event.ToolUseResult.File
inv.logger.Debug("Tool result", zap.String("file", f.FilePath), zap.Int("lines", f.NumLines))
}
case "result":
if event.Subtype == "success" {
inv.logger.Debug("Claude done",
zap.Int("turns", event.NumTurns),
zap.Int("duration_ms", event.DurationMs),
zap.Float64("cost_usd", event.Cost))
} else {
inv.logger.Error("Claude error", zap.String("result", event.Result))
}
}
}
func (inv *Invoker) displayToolUse(block contentBlock) {
switch block.Name {
case "Read":
var input struct {
FilePath string `json:"file_path"`
}
if err := json.Unmarshal(block.Input, &input); err != nil {
inv.logger.Warn("Failed to unmarshal Read tool input", zap.Error(err))
}
inv.logger.Debug("Read", zap.String("file", input.FilePath))
case "Bash":
var input struct {
Command string `json:"command"`
}
if err := json.Unmarshal(block.Input, &input); err != nil {
inv.logger.Warn("Failed to unmarshal Bash tool input", zap.Error(err))
}
cmd := input.Command
if len(cmd) > 200 {
cmd = cmd[:200] + "..."
}
if strings.Contains(cmd, ">> ") {
inv.logger.Debug("Command", zap.String("cmd", cmd))
} else {
inv.logger.Debug("Bash", zap.String("cmd", cmd))
}
default:
inv.logger.Debug("Tool", zap.String("name", block.Name))
}
}
func loadStrategyGuide(difficulty string, logger *zap.Logger) string {
if difficulty == "" {
difficulty = "normal"
}
wd, err := os.Getwd()
if err != nil {
logger.Warn("Failed to get working directory for strategy file", zap.Error(err))
return ""
}
strategyPath := filepath.Join(wd, "assets", "bot", difficulty+".md")
data, err := os.ReadFile(strategyPath)
if err != nil {
logger.Warn("Failed to load strategy file", zap.String("path", strategyPath), zap.Error(err))
return ""
}
return string(data)
}
func buildSystemPrompt(commandPath string, difficulty string, logger *zap.Logger) string {
strategyGuide := loadStrategyGuide(difficulty, logger)
strategySection := ""
if strategyGuide != "" {
strategySection = fmt.Sprintf("\n\n=====================\nSTRATEGY GUIDE\n=====================\n\n%s", strategyGuide)
}
return fmt.Sprintf(`You are an expert Terraforming Mars player controlling a bot via WebSocket commands.
Your job is to analyze the game state and decide what actions to take for your turn.
CRITICAL RULES:
1. Read the game state file FIRST to understand the current situation.
2. Check "Actions remaining: N" in YOUR STATUS section. This is how many main actions you can take.
3. FAST EXIT: If Actions remaining ≤ 0 AND there is NO pending action, immediately send skip-action and STOP. Do not analyze cards, do not deliberate. Just skip.
4. Send AT MOST N action commands. Do NOT send more than the remaining actions allow.
5. Pending follow-ups (tile placement, card selection, behavior choices, discard) do NOT consume actions - resolve them after the action that triggered them.
6. After EACH command, read the tail of the history file to verify it was accepted before sending the next.
7. After you have used all remaining actions (or passed), STOP. Do not send any more commands.
8. If "Actions remaining: 0", you likely only have a pending follow-up to resolve. Resolve it and STOP.
9. skip-action counts as an action and ends your participation for this generation.
HOW TO SEND COMMANDS:
Use Bash to append each command as a JSON line:
echo '{"type": "action.game-management.skip-action", "payload": {}}' >> %s
After sending a command, read the tail of the history file to verify it was accepted (look for "action-success" or error messages).
=====================
PHASE-SPECIFIC GUIDE
=====================
--- STARTING SELECTION PHASE ---
You must pick a corporation and starting cards in a single command.
The state file shows your available corporations and available starting cards.
Each starting card costs 3M€ to buy (deducted from your corporation's starting credits).
Send: {"type": "action.card.select-starting-choices", "payload": {"corporationId": "CORP_ID", "preludeIds": [], "cardIds": ["c1", "c2"]}}
- corporationId: pick one from the available corporations
- preludeIds: pick preludes if available (usually empty in base game)
- cardIds: array of card IDs you want to BUY (can be empty [] to buy none)
How to choose:
- Read each corporation's description carefully. Note starting credits, production, and special effects.
- Calculate budget: corporation starting credits minus (3 x number of cards bought).
- Pick cards that synergize with your corporation. E.g., Tharsis Republic (city bonuses) pairs well with city-related cards.
- Prefer cards with good economy (credit production), terraform rating bumps, or strong VP potential.
- Don't buy cards you can't afford to play for many generations. Avoid expensive cards (20+ cost) early unless they have amazing value.
- It's OK to buy 0-3 cards. Don't overbuy and start broke.
--- FORCED FIRST ACTION ---
Some corporations require a specific first action (e.g., Tharsis Republic must place a city).
The state file shows "FORCED FIRST ACTION" with the required action type.
For city placement: a pending tile selection will appear with available hexes.
Send: {"type": "action.tile-selection.tile-selected", "payload": {"hex": "q,r,s"}}
- Pick a hex from the available hexes list in the state file.
- For cities: prefer hexes with placement bonuses (steel, credits) and room for adjacent greeneries later.
- Avoid edges of the board if possible - more adjacency options in the center.
--- ACTION PHASE (main gameplay) ---
You get 2 actions per turn. Each action can be one of:
1. Play a card from hand (if PLAYABLE - check the state file for availability)
2. Use a card action from a played card (if AVAILABLE)
3. Standard project (sell patents, build power plant, asteroid, aquifer, greenery, city)
4. Resource conversion (8 plants -> greenery, 8 heat -> temperature)
5. Claim a milestone (if CLAIMABLE)
6. Fund an award (if FUNDABLE)
7. Skip/pass (ends your participation this generation)
Priority order for each action:
1. Resolve any pending actions first (tile placement, card selection, etc.)
2. Claim a milestone if you qualify and can afford it (8M€ first, 8M€ second, 8M€ third - only 3 total)
3. Play strong cards that boost production or give TR
4. Use available card actions
5. Convert resources if beneficial (plants -> greenery for TR + VP, heat -> temperature for TR)
6. Standard projects as fallback
7. Pass if nothing good to do (saves money for next generation)
When passing: you skip the rest of this generation. All players must pass to end the generation.
--- PRODUCTION PHASE ---
At the end of each generation, you draw 4 cards and choose which to buy at 3M€ each.
The state file shows "PRODUCTION PHASE - Select cards to buy" with available cards.
Send: {"type": "action.card.confirm-production-cards", "payload": {"cardIds": ["id1"]}}
- cardIds: array of card IDs you want to buy (can be empty [])
- Only buy cards you'll realistically play. Don't waste credits on cards you can't use.
--- TILE PLACEMENT ---
When a pending tile selection appears, you must place a tile.
Send: {"type": "action.tile-selection.tile-selected", "payload": {"hex": "q,r,s"}}
- Use the exact coordinates from the "Available hexes" list (format: "q,r,s")
- Cities: must NOT be adjacent to other cities. Place near future greenery spots.
- Greeneries: must be adjacent to one of your tiles if possible. Each greenery = 1 VP.
- Oceans: placed on ocean spaces only. Give 2 TR when placed.
=====================
COMMAND REFERENCE
=====================
== PLAY A CARD FROM HAND ==
{"type": "action.card.play-card", "payload": {"cardId": "CARD_ID", "payment": {"credits": N, "steel": N, "titanium": N}}}
Steel pays for building tags (1 steel = 2M€). Titanium pays for space tags (1 titanium = 3M€).
Optional payload fields: choiceIndex (int), targetPlayerId (string), selectedAmount (int)
== USE A CARD ACTION (from played cards) ==
{"type": "action.card.card-action", "payload": {"cardId": "CARD_ID", "behaviorIndex": N}}
Optional: choiceIndex, targetPlayerId, sourceCardForInput, selectedAmount
== STANDARD PROJECTS ==
{"type": "action.standard-project.sell-patents", "payload": {}}
(After sending, you must confirm with: {"type": "action.standard-project.confirm-sell-patents", "payload": {"selectedCardIds": ["id1", "id2"]}})
{"type": "action.standard-project.launch-asteroid", "payload": {}}
{"type": "action.standard-project.build-power-plant", "payload": {}}
{"type": "action.standard-project.build-aquifer", "payload": {}}
{"type": "action.standard-project.plant-greenery", "payload": {}}
{"type": "action.standard-project.build-city", "payload": {}}
== RESOURCE CONVERSIONS ==
{"type": "action.resource-conversion.convert-plants-to-greenery", "payload": {}}
{"type": "action.resource-conversion.convert-heat-to-temperature", "payload": {}}
== SKIP/PASS ==
{"type": "action.game-management.skip-action", "payload": {}}
== TILE PLACEMENT ==
{"type": "action.tile-selection.tile-selected", "payload": {"hex": "q,r,s"}}
== STARTING SELECTION ==
{"type": "action.card.select-starting-choices", "payload": {"corporationId": "CORP_ID", "preludeIds": [], "cardIds": ["c1", "c2"]}}
== PRODUCTION PHASE CARD SELECTION ==
{"type": "action.card.confirm-production-cards", "payload": {"cardIds": ["id1"]}}
== CARD DRAW CONFIRMATION ==
{"type": "action.card.card-draw-confirmed", "payload": {"cardsToTake": ["id1"], "cardsToBuy": ["id2"]}}
== CARD DISCARD ==
{"type": "action.card.card-discard-confirmed", "payload": {"cardsToDiscard": ["id1"]}}
== BEHAVIOR CHOICE ==
{"type": "action.card.behavior-choice-confirmed", "payload": {"choiceIndex": 0}}
== CARD SELECTION ==
{"type": "action.card.select-cards", "payload": {"cardIds": ["id1"]}}
== MILESTONES ==
{"type": "action.milestone.claim-milestone", "payload": {"milestoneType": "TYPE"}}
== AWARDS ==
{"type": "action.award.fund-award", "payload": {"awardType": "TYPE"}}
== CHAT MESSAGE ==
{"type": "chat.send-message", "payload": {"message": "Your message here"}}
Chat is a big part of the game experience. Use it to engage with other players:
- React to opponent moves that affect you (stealing resources, blocking tiles, claiming milestones you wanted)
- Respond to chat messages from other players — don't leave people hanging
- Trash talk, banter, celebrate your own big plays
- Comment on the game state when something dramatic happens
Keep messages to 1 sentence, max 2 chat messages per turn. Do NOT narrate your actions — react and engage instead.%s`, commandPath, strategySection)
}
func buildTurnPrompt(game *dto.GameDto, statePath, commandPath, historyPath string) string {
pendingAction := GetPendingActionType(game)
pendingNote := ""
if pendingAction != "" {
pendingNote = fmt.Sprintf("\nIMPORTANT: You have a PENDING ACTION (%s) that MUST be resolved first! This does NOT consume an action.", pendingAction)
}
phaseNote := ""
actionsNote := ""
if game != nil {
switch game.CurrentPhase {
case dto.GamePhaseStartingSelection:
phaseNote = "\nYou are in the STARTING SELECTION phase. Pick your corporation, preludes (if any), and starting cards. Send exactly 1 command."
case dto.GamePhaseProductionAndCardDraw:
phaseNote = "\nYou are in the PRODUCTION PHASE. Select which drawn cards to buy. Send exactly 1 command."
case dto.GamePhaseAction:
remaining := game.CurrentPlayer.AvailableActions
actionsNote = fmt.Sprintf("\nYou have %d action(s) remaining this turn. Send at most %d action command(s), then STOP.", remaining, remaining)
case dto.GamePhaseFinalPhase:
phaseNote = "\nYou are in the FINAL PHASE. You may only convert plants to greenery tiles, or pass. Send exactly 1 command."
}
}
return fmt.Sprintf(`It's your turn to play Terraforming Mars!
GAME STATE FILE: %s
Read this file to see the current game state, your hand, resources, and available actions.
COMMAND FILE: %s
Append your commands here, one JSON object per line, using: echo '...' >> %s
HISTORY FILE: %s
After each command, read the tail of this file to check if it succeeded before sending the next.
%s%s%s
Begin by reading the game state file, then decide on your action(s).
CHAT: After reading the game state, check the RECENT GAME LOG and RECENT CHAT sections.
- If there are unanswered chat messages from other players, ALWAYS reply. Don't ignore people.
- If an opponent did something that affected you (stole resources, blocked your tile spot, claimed a milestone you were going for), react to it.
- If something big happened in the game log, comment on it.
- Send chat BEFORE your game actions. Stay in character per your personality style.`,
statePath, commandPath, commandPath, historyPath, pendingNote, phaseNote, actionsNote)
}
package bot
import (
"fmt"
"strings"
"terraforming-mars-backend/internal/delivery/dto"
"terraforming-mars-backend/internal/game"
"terraforming-mars-backend/internal/game/shared"
)
// SummarizeGameState produces a human-readable text summary of the game state.
func SummarizeGameState(game *dto.GameDto, myPlayerID string) string {
if game == nil {
return "No game state available."
}
var lines []string
lines = append(lines, formatGameInfo(game, myPlayerID))
lines = append(lines, formatGlobalParams(&game.GlobalParameters))
p := &game.CurrentPlayer
lines = append(lines, formatPendingActions(p))
lines = append(lines, formatPlayerStatus(p))
lines = append(lines, formatHand(p.Cards))
lines = append(lines, formatCardActions(p.Actions))
lines = append(lines, formatStandardProjects(p.StandardProjects))
lines = append(lines, formatMilestones(p.Milestones))
lines = append(lines, formatAwards(p.Awards))
lines = append(lines, formatOpponents(game.OtherPlayers))
lines = append(lines, formatBoard(game.Board.Tiles))
if len(game.FinalScores) > 0 {
lines = append(lines, formatFinalScores(game))
}
var filtered []string
for _, l := range lines {
if l != "" {
filtered = append(filtered, l)
}
}
return strings.Join(filtered, "\n\n")
}
func formatGameInfo(game *dto.GameDto, myPlayerID string) string {
turnInfo := "N/A"
if game.CurrentTurn != nil {
if *game.CurrentTurn == myPlayerID {
turnInfo = "YOUR TURN"
} else {
turnInfo = fmt.Sprintf("Waiting for %s", findPlayerName(game, *game.CurrentTurn))
}
}
return strings.Join([]string{
"=== GAME INFO ===",
fmt.Sprintf("Game ID: %s", game.ID),
fmt.Sprintf("Phase: %s", string(game.CurrentPhase)),
fmt.Sprintf("Status: %s", string(game.Status)),
fmt.Sprintf("Generation: %d", game.Generation),
fmt.Sprintf("Turn: %s", turnInfo),
fmt.Sprintf("Players: %d", len(game.TurnOrder)),
fmt.Sprintf("Turn order: %s", formatTurnOrder(game)),
}, "\n")
}
func formatTurnOrder(game *dto.GameDto) string {
var names []string
for _, id := range game.TurnOrder {
names = append(names, findPlayerName(game, id))
}
return strings.Join(names, " -> ")
}
func formatGlobalParams(gp *dto.GlobalParametersDto) string {
return strings.Join([]string{
"=== GLOBAL PARAMETERS ===",
fmt.Sprintf("Temperature: %d°C (target: 8°C)", gp.Temperature),
fmt.Sprintf("Oxygen: %d%% (target: 14%%)", gp.Oxygen),
fmt.Sprintf("Oceans: %d/%d", gp.Oceans, gp.MaxOceans),
fmt.Sprintf("Venus: %d%% (target: 30%%)", gp.Venus),
}, "\n")
}
func formatPendingActions(player *dto.PlayerDto) string {
var parts []string
if player.PendingTileSelection != nil {
parts = append(parts, formatPendingTile(player.PendingTileSelection))
}
if player.PendingCardSelection != nil {
parts = append(parts, formatPendingCardSelection(player.PendingCardSelection))
}
if player.PendingCardDrawSelection != nil {
parts = append(parts, formatPendingCardDraw(player.PendingCardDrawSelection))
}
if player.PendingCardDiscardSelection != nil {
parts = append(parts, formatPendingCardDiscard(player.PendingCardDiscardSelection))
}
if player.PendingBehaviorChoiceSelection != nil {
parts = append(parts, formatPendingBehaviorChoice(player.PendingBehaviorChoiceSelection))
}
if player.ForcedFirstAction != nil && !player.ForcedFirstAction.Completed {
parts = append(parts, formatForcedAction(player.ForcedFirstAction))
}
if player.SelectCorporationPhase != nil || player.SelectStartingCardsPhase != nil || player.SelectPreludeCardsPhase != nil {
parts = append(parts, formatStartingSelection(player))
}
if player.ProductionPhase != nil && !player.ProductionPhase.SelectionComplete {
parts = append(parts, formatProductionPhase(player))
}
if len(parts) == 0 {
return ""
}
return ">>> PENDING ACTIONS (must resolve) <<<\n" + strings.Join(parts, "\n\n")
}
func formatPendingTile(sel *dto.PendingTileSelectionDto) string {
return strings.Join([]string{
fmt.Sprintf("TILE PLACEMENT REQUIRED: Place a %s tile", sel.TileType),
fmt.Sprintf("Source: %s", sel.Source),
fmt.Sprintf("Available hexes: %s", strings.Join(sel.AvailableHexes, ", ")),
"Send a tile-selected command with q, r, s coordinates.",
}, "\n")
}
func formatPendingCardSelection(sel *dto.PendingCardSelectionDto) string {
var cardLines []string
for _, c := range sel.AvailableCards {
cost := sel.CardCosts[c.ID]
reward := sel.CardRewards[c.ID]
info := ""
if cost > 0 {
info = fmt.Sprintf(" (cost: %dM€)", cost)
} else if reward > 0 {
info = fmt.Sprintf(" (reward: %dM€)", reward)
}
cardLines = append(cardLines, fmt.Sprintf(" - %s [%s]%s", c.Name, c.ID, info))
}
return strings.Join([]string{
fmt.Sprintf("CARD SELECTION REQUIRED (source: %s)", sel.Source),
fmt.Sprintf("Select %d-%d cards:", sel.MinCards, sel.MaxCards),
strings.Join(cardLines, "\n"),
`Send a select-cards command with cardIds.`,
}, "\n")
}
func formatPendingCardDraw(sel *dto.PendingCardDrawSelectionDto) string {
var cardLines []string
for _, c := range sel.AvailableCards {
cardLines = append(cardLines, fmt.Sprintf(" - %s [%s]: %s", c.Name, c.ID, c.Description))
}
buyCostStr := ""
if sel.CardBuyCost > 0 {
buyCostStr = fmt.Sprintf(" (%dM€ each)", sel.CardBuyCost)
}
return strings.Join([]string{
fmt.Sprintf("CARD DRAW SELECTION (source: %s)", sel.Source),
fmt.Sprintf("Free takes: %d, Max buy: %d%s", sel.FreeTakeCount, sel.MaxBuyCount, buyCostStr),
strings.Join(cardLines, "\n"),
`Send a card-draw-confirmed command with cardsToTake and cardsToBuy.`,
}, "\n")
}
func formatPendingCardDiscard(sel *dto.PendingCardDiscardSelectionDto) string {
return strings.Join([]string{
fmt.Sprintf("CARD DISCARD REQUIRED (source: %s)", sel.Source),
fmt.Sprintf("Discard %d-%d cards from hand.", sel.MinCards, sel.MaxCards),
`Send a card-discard-confirmed command with cardsToDiscard.`,
}, "\n")
}
func formatPendingBehaviorChoice(sel *dto.PendingBehaviorChoiceSelectionDto) string {
var choiceLines []string
for i, c := range sel.Choices {
desc := formatBehaviorBrief(c.Inputs, c.Outputs)
avail := ""
if !c.Available {
avail = " [UNAVAILABLE]"
}
choiceLines = append(choiceLines, fmt.Sprintf(" %d: %s%s", i, desc, avail))
}
return strings.Join([]string{
fmt.Sprintf("BEHAVIOR CHOICE REQUIRED (source: %s)", sel.Source),
strings.Join(choiceLines, "\n"),
`Send a behavior-choice-confirmed command with choiceIndex.`,
}, "\n")
}
func formatForcedAction(fa *dto.ForcedFirstActionDto) string {
return strings.Join([]string{
fmt.Sprintf("FORCED FIRST ACTION: %s", fa.Description),
fmt.Sprintf("Action type: %s", fa.ActionType),
fmt.Sprintf("Corporation: %s", fa.CorporationID),
}, "\n")
}
func formatStartingSelection(player *dto.PlayerDto) string {
parts := []string{"STARTING SELECTION REQUIRED"}
if player.SelectCorporationPhase != nil {
var corps []string
for _, c := range player.SelectCorporationPhase.AvailableCorporations {
corps = append(corps, fmt.Sprintf(" - %s [%s]: %s", c.Name, c.ID, c.Description))
}
parts = append(parts, "Corporations:\n"+strings.Join(corps, "\n"))
}
if player.SelectPreludeCardsPhase != nil {
var preludes []string
for _, c := range player.SelectPreludeCardsPhase.AvailablePreludes {
preludes = append(preludes, fmt.Sprintf(" - %s [%s]: %s", c.Name, c.ID, c.Description))
}
parts = append(parts, fmt.Sprintf("Preludes (pick %d):\n%s",
player.SelectPreludeCardsPhase.MaxSelectable,
strings.Join(preludes, "\n")))
}
if player.SelectStartingCardsPhase != nil {
var cards []string
for _, c := range player.SelectStartingCardsPhase.AvailableCards {
tags := ""
if len(c.Tags) > 0 {
tagStrs := make([]string, len(c.Tags))
for i, t := range c.Tags {
tagStrs[i] = string(t)
}
tags = " " + strings.Join(tagStrs, ", ")
}
cards = append(cards, fmt.Sprintf(" - %s [%s] (%dM€) [%s]%s: %s",
c.Name, c.ID, c.Cost, string(c.Type), tags, c.Description))
}
parts = append(parts, "Starting cards (pick any to buy at 3M€ each):\n"+strings.Join(cards, "\n"))
}
parts = append(parts, "Send a select-starting-choices command with corporationId, preludeIds, and cardIds.")
return strings.Join(parts, "\n")
}
func formatProductionPhase(player *dto.PlayerDto) string {
pp := player.ProductionPhase
var cards []string
for _, c := range pp.AvailableCards {
cards = append(cards, fmt.Sprintf(" - %s [%s]", c.Name, c.ID))
}
cardList := " (no cards available)"
if len(cards) > 0 {
cardList = strings.Join(cards, "\n")
}
return strings.Join([]string{
"PRODUCTION PHASE - Select cards to buy:",
cardList,
`Send a confirm-production-cards command with cardIds.`,
}, "\n")
}
func formatPlayerStatus(player *dto.PlayerDto) string {
r := &player.Resources
p := &player.Production
corpName := "None"
if player.Corporation != nil {
corpName = player.Corporation.Name
}
lines := []string{
"=== YOUR STATUS ===",
fmt.Sprintf("Name: %s | Corporation: %s | TR: %d", player.Name, corpName, player.TerraformRating),
fmt.Sprintf("Status: %s | Actions remaining: %d | Passed: %v", string(player.Status), player.AvailableActions, player.Passed),
"",
"Resources (amount / production):",
fmt.Sprintf(" Credits: %d / %s", r.Credits, formatProd(p.Credits)),
fmt.Sprintf(" Steel: %d / %s", r.Steel, formatProd(p.Steel)),
fmt.Sprintf(" Titanium: %d / %s", r.Titanium, formatProd(p.Titanium)),
fmt.Sprintf(" Plants: %d / %s", r.Plants, formatProd(p.Plants)),
fmt.Sprintf(" Energy: %d / %s", r.Energy, formatProd(p.Energy)),
fmt.Sprintf(" Heat: %d / %s", r.Heat, formatProd(p.Heat)),
}
if len(player.PlayedCards) > 0 {
var names []string
for _, c := range player.PlayedCards {
names = append(names, c.Name)
}
lines = append(lines, "", fmt.Sprintf("Played cards (%d): %s", len(player.PlayedCards), strings.Join(names, ", ")))
}
if len(player.ResourceStorage) > 0 {
var storage []string
for k, v := range player.ResourceStorage {
if v > 0 {
storage = append(storage, fmt.Sprintf("%s: %d", k, v))
}
}
if len(storage) > 0 {
lines = append(lines, fmt.Sprintf("Resource storage: %s", strings.Join(storage, ", ")))
}
}
if len(player.PaymentSubstitutes) > 0 {
var subs []string
for _, s := range player.PaymentSubstitutes {
subs = append(subs, fmt.Sprintf("%s (%d:1)", string(s.ResourceType), s.ConversionRate))
}
lines = append(lines, fmt.Sprintf("Payment substitutes: %s", strings.Join(subs, ", ")))
}
if len(player.Effects) > 0 {
var effects []string
for _, e := range player.Effects {
effects = append(effects, e.CardName)
}
lines = append(lines, fmt.Sprintf("Active effects: %s", strings.Join(effects, ", ")))
}
return strings.Join(lines, "\n")
}
func formatHand(cards []dto.PlayerCardDto) string {
if len(cards) == 0 {
return "=== HAND (0 cards) ===\n(empty)"
}
header := fmt.Sprintf("=== HAND (%d cards) ===", len(cards))
var cardLines []string
for _, c := range cards {
avail := "PLAYABLE"
if !c.Available {
avail = "BLOCKED"
}
errInfo := ""
if !c.Available && len(c.Errors) > 0 {
var msgs []string
for _, e := range c.Errors {
msgs = append(msgs, e.Message)
}
errInfo = fmt.Sprintf(" (%s)", strings.Join(msgs, "; "))
}
tags := ""
if len(c.Tags) > 0 {
tagStrs := make([]string, len(c.Tags))
for i, t := range c.Tags {
tagStrs[i] = string(t)
}
tags = fmt.Sprintf(" [%s]", strings.Join(tagStrs, ", "))
}
discount := ""
if c.EffectiveCost < c.Cost {
discount = fmt.Sprintf(" (discounted from %d)", c.Cost)
}
line := fmt.Sprintf(" - %s [%s] | %dM€%s | %s%s | %s%s",
c.Name, c.ID, c.EffectiveCost, discount, string(c.Type), tags, avail, errInfo)
cardLines = append(cardLines, line)
}
return header + "\n" + strings.Join(cardLines, "\n")
}
func formatCardActions(actions []dto.PlayerActionDto) string {
if len(actions) == 0 {
return ""
}
header := "=== CARD ACTIONS ==="
var actionLines []string
for _, a := range actions {
avail := "AVAILABLE"
if !a.Available {
avail = "BLOCKED"
}
errInfo := ""
if !a.Available && len(a.Errors) > 0 {
var msgs []string
for _, e := range a.Errors {
msgs = append(msgs, e.Message)
}
errInfo = fmt.Sprintf(" (%s)", strings.Join(msgs, "; "))
}
usedInfo := ""
if a.TimesUsedThisTurn > 0 {
usedInfo = fmt.Sprintf(" [used %dx this turn]", a.TimesUsedThisTurn)
}
desc := formatBehaviorBrief(a.Behavior.Inputs, a.Behavior.Outputs)
line := fmt.Sprintf(" - %s [%s] behavior#%d | %s%s%s",
a.CardName, a.CardID, a.BehaviorIndex, avail, usedInfo, errInfo)
if desc != "" {
line += fmt.Sprintf("\n %s", desc)
}
actionLines = append(actionLines, line)
}
return header + "\n" + strings.Join(actionLines, "\n")
}
func formatStandardProjects(projects []dto.PlayerStandardProjectDto) string {
if len(projects) == 0 {
return ""
}
header := "=== STANDARD PROJECTS ==="
var lines []string
for _, p := range projects {
avail := "AVAILABLE"
if !p.Available {
avail = "BLOCKED"
}
errInfo := ""
if !p.Available && len(p.Errors) > 0 {
errInfo = fmt.Sprintf(" (%s)", p.Errors[0].Message)
}
var costParts []string
for k, v := range p.EffectiveCost {
costParts = append(costParts, fmt.Sprintf("%d %s", v, k))
}
costStr := strings.Join(costParts, ", ")
lines = append(lines, fmt.Sprintf(" - %s | %s | %s%s", p.ProjectType, costStr, avail, errInfo))
}
return header + "\n" + strings.Join(lines, "\n")
}
func formatMilestones(milestones []dto.PlayerMilestoneDto) string {
if len(milestones) == 0 {
return ""
}
header := "=== MILESTONES ==="
var lines []string
for _, m := range milestones {
claimed := "NOT YET"
if m.IsClaimed {
by := ""
if m.ClaimedBy != nil {
by = *m.ClaimedBy
}
claimed = fmt.Sprintf("CLAIMED by %s", by)
} else if m.Available {
claimed = fmt.Sprintf("CLAIMABLE (%dM€)", m.ClaimCost)
}
lines = append(lines, fmt.Sprintf(" - %s: %s | Progress: %d/%d | %s",
m.Name, m.Description, m.Progress, m.Required, claimed))
}
return header + "\n" + strings.Join(lines, "\n")
}
func formatAwards(awards []dto.PlayerAwardDto) string {
if len(awards) == 0 {
return ""
}
header := "=== AWARDS ==="
var lines []string
for _, a := range awards {
funded := "NOT AVAILABLE"
if a.IsFunded {
by := ""
if a.FundedBy != nil {
by = *a.FundedBy
}
funded = fmt.Sprintf("FUNDED by %s", by)
} else if a.Available {
funded = fmt.Sprintf("FUNDABLE (%dM€)", a.FundingCost)
}
lines = append(lines, fmt.Sprintf(" - %s: %s | %s", a.Name, a.Description, funded))
}
return header + "\n" + strings.Join(lines, "\n")
}
func formatOpponents(others []dto.OtherPlayerDto) string {
if len(others) == 0 {
return ""
}
header := "=== OPPONENTS ==="
var lines []string
for _, o := range others {
r := &o.Resources
corpName := "None"
if o.Corporation != nil {
corpName = o.Corporation.Name
}
lines = append(lines, strings.Join([]string{
fmt.Sprintf(" %s (%s) | TR: %d | Status: %s | Passed: %v",
o.Name, corpName, o.TerraformRating, string(o.Status), o.Passed),
fmt.Sprintf(" Resources: %dM€, %d steel, %d ti, %d plants, %d energy, %d heat",
r.Credits, r.Steel, r.Titanium, r.Plants, r.Energy, r.Heat),
fmt.Sprintf(" Cards in hand: %d | Played: %d cards",
o.HandCardCount, len(o.PlayedCards)),
}, "\n"))
}
return header + "\n" + strings.Join(lines, "\n")
}
func formatBoard(tiles []dto.TileDto) string {
var occupied []dto.TileDto
for _, t := range tiles {
if t.OccupiedBy != nil {
occupied = append(occupied, t)
}
}
if len(occupied) == 0 {
return ""
}
header := "=== BOARD ==="
var lines []string
for _, t := range occupied {
coord := fmt.Sprintf("(%d,%d,%d)", t.Coordinates.Q, t.Coordinates.R, t.Coordinates.S)
occ := ""
if t.OccupiedBy != nil {
ownerStr := ""
if t.OwnerID != nil {
ownerStr = fmt.Sprintf(" (owner: %s)", *t.OwnerID)
}
occ = fmt.Sprintf(" | %s%s", t.OccupiedBy.Type, ownerStr)
}
name := ""
if t.DisplayName != nil {
name = " " + *t.DisplayName
}
bonuses := ""
if len(t.Bonuses) > 0 {
var bonusParts []string
for _, b := range t.Bonuses {
bonusParts = append(bonusParts, fmt.Sprintf("%dx %s", b.Amount, b.Type))
}
bonuses = " | bonuses: " + strings.Join(bonusParts, ", ")
}
lines = append(lines, fmt.Sprintf(" %s%s%s%s", coord, name, occ, bonuses))
}
return header + "\n" + strings.Join(lines, "\n")
}
func formatFinalScores(game *dto.GameDto) string {
if len(game.FinalScores) == 0 {
return ""
}
header := "=== FINAL SCORES ==="
var lines []string
for _, s := range game.FinalScores {
vp := s.VPBreakdown
winner := ""
if s.IsWinner {
winner = " (WINNER)"
}
lines = append(lines, strings.Join([]string{
fmt.Sprintf(" #%d %s%s: %d VP", s.Placement, s.PlayerName, winner, vp.TotalVP),
fmt.Sprintf(" TR: %d, Cards: %d, Greenery: %d, City: %d, Milestones: %d, Awards: %d",
vp.TerraformRating, vp.CardVP, vp.GreeneryVP, vp.CityVP, vp.MilestoneVP, vp.AwardVP),
}, "\n"))
}
return header + "\n" + strings.Join(lines, "\n")
}
func formatProd(val int) string {
if val >= 0 {
return fmt.Sprintf("+%d", val)
}
return fmt.Sprintf("%d", val)
}
type conditionSummary interface {
GetConditionType() string
GetConditionAmount() int
}
func extractTypeAmount(item any) (string, int) {
if c, ok := item.(conditionSummary); ok {
return c.GetConditionType(), c.GetConditionAmount()
}
return "unknown", 0
}
func formatBehaviorBrief(inputs, outputs []any) string {
formatItems := func(items []any) string {
var parts []string
for _, item := range items {
t, a := extractTypeAmount(item)
parts = append(parts, fmt.Sprintf("%d %s", a, t))
}
return strings.Join(parts, ", ")
}
var result []string
if len(inputs) > 0 {
result = append(result, "Costs: "+formatItems(inputs))
}
if len(outputs) > 0 {
result = append(result, "Gives: "+formatItems(outputs))
}
return strings.Join(result, " -> ")
}
func findPlayerName(game *dto.GameDto, playerID string) string {
if game.CurrentPlayer.ID == playerID {
return game.CurrentPlayer.Name
}
for _, o := range game.OtherPlayers {
if o.ID == playerID {
return o.Name
}
}
return playerID
}
func formatRecentLog(diffs []game.StateDiff, maxEntries int) string {
if len(diffs) == 0 {
return ""
}
start := 0
if len(diffs) > maxEntries {
start = len(diffs) - maxEntries
}
recent := diffs[start:]
var lines []string
for _, d := range recent {
if d.Description == "" {
continue
}
lines = append(lines, fmt.Sprintf(" - %s", d.Description))
}
if len(lines) == 0 {
return ""
}
return "=== RECENT GAME LOG ===\n" + strings.Join(lines, "\n")
}
func formatRecentChat(messages []shared.ChatMessage, maxEntries int) string {
if len(messages) == 0 {
return ""
}
start := 0
if len(messages) > maxEntries {
start = len(messages) - maxEntries
}
recent := messages[start:]
var lines []string
for _, m := range recent {
lines = append(lines, fmt.Sprintf(" %s: %s", m.SenderName, m.Message))
}
return "=== RECENT CHAT ===\n" + strings.Join(lines, "\n")
}
package bot
import (
"terraforming-mars-backend/internal/delivery/dto"
)
// IsMyTurn checks if it's currently the given player's turn to act.
func IsMyTurn(game *dto.GameDto, myPlayerID string) bool {
if game == nil || myPlayerID == "" {
return false
}
p := &game.CurrentPlayer
// Action phase (or final phase) and it's our turn
if (game.CurrentPhase == dto.GamePhaseAction || game.CurrentPhase == dto.GamePhaseFinalPhase) && game.CurrentTurn != nil && *game.CurrentTurn == myPlayerID {
return true
}
// Player status checks
if p.Status == dto.PlayerStatusActive {
return true
}
if p.Status == dto.PlayerStatusSelectingStartingCards {
return true
}
if p.Status == dto.PlayerStatusSelectingProductionCards {
return true
}
// Pending selections
if p.PendingTileSelection != nil {
return true
}
if p.PendingCardSelection != nil {
return true
}
if p.PendingCardDrawSelection != nil {
return true
}
if p.PendingCardDiscardSelection != nil {
return true
}
if p.PendingBehaviorChoiceSelection != nil {
return true
}
// Forced first action
if p.ForcedFirstAction != nil && !p.ForcedFirstAction.Completed {
return true
}
// Starting selection phase — only if player still has choices to make
if game.CurrentPhase == dto.GamePhaseStartingSelection {
if p.SelectCorporationPhase != nil || p.SelectStartingCardsPhase != nil || p.SelectPreludeCardsPhase != nil {
return true
}
}
// Production phase with incomplete selection
if game.CurrentPhase == dto.GamePhaseProductionAndCardDraw &&
p.ProductionPhase != nil &&
!p.ProductionPhase.SelectionComplete {
return true
}
return false
}
// GetPendingActionType returns the type of pending action requiring resolution.
func GetPendingActionType(game *dto.GameDto) string {
if game == nil {
return ""
}
p := &game.CurrentPlayer
if p.PendingTileSelection != nil {
return "tile-selection"
}
if p.PendingCardSelection != nil {
return "card-selection"
}
if p.PendingCardDrawSelection != nil {
return "card-draw-selection"
}
if p.PendingCardDiscardSelection != nil {
return "card-discard-selection"
}
if p.PendingBehaviorChoiceSelection != nil {
return "behavior-choice-selection"
}
if p.ForcedFirstAction != nil && !p.ForcedFirstAction.Completed {
return "forced-first-action"
}
return ""
}
package standardprojects
import (
"encoding/json"
"fmt"
"os"
"terraforming-mars-backend/internal/game/standardproject"
)
// LoadStandardProjectsFromJSON loads standard project definitions from a JSON file
func LoadStandardProjectsFromJSON(filepath string) ([]standardproject.StandardProjectDefinition, error) {
data, err := os.ReadFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to read standard projects file: %w", err)
}
var projects []standardproject.StandardProjectDefinition
if err := json.Unmarshal(data, &projects); err != nil {
return nil, fmt.Errorf("failed to parse standard projects JSON: %w", err)
}
if len(projects) == 0 {
return nil, fmt.Errorf("no standard projects found in file: %s", filepath)
}
return projects, nil
}
package standardprojects
import (
"fmt"
"terraforming-mars-backend/internal/game/standardproject"
)
// StandardProjectRegistry provides lookup functionality for standard project definitions
type StandardProjectRegistry interface {
GetByID(projectID string) (*standardproject.StandardProjectDefinition, error)
GetAll() []standardproject.StandardProjectDefinition
}
// InMemoryStandardProjectRegistry implements StandardProjectRegistry with an in-memory map
type InMemoryStandardProjectRegistry struct {
projects map[string]standardproject.StandardProjectDefinition
order []string
}
// NewInMemoryStandardProjectRegistry creates a new registry from a slice of definitions
func NewInMemoryStandardProjectRegistry(projectList []standardproject.StandardProjectDefinition) *InMemoryStandardProjectRegistry {
projectMap := make(map[string]standardproject.StandardProjectDefinition, len(projectList))
order := make([]string, 0, len(projectList))
for _, p := range projectList {
projectMap[p.ID] = p
order = append(order, p.ID)
}
return &InMemoryStandardProjectRegistry{projects: projectMap, order: order}
}
// GetByID retrieves a standard project definition by ID
func (r *InMemoryStandardProjectRegistry) GetByID(projectID string) (*standardproject.StandardProjectDefinition, error) {
p, exists := r.projects[projectID]
if !exists {
return nil, fmt.Errorf("standard project not found: %s", projectID)
}
return &p, nil
}
// GetAll returns all standard project definitions in their original JSON order
func (r *InMemoryStandardProjectRegistry) GetAll() []standardproject.StandardProjectDefinition {
result := make([]standardproject.StandardProjectDefinition, 0, len(r.order))
for _, id := range r.order {
result = append(result, r.projects[id])
}
return result
}