A Scala library for managing Settlers of Catan games with immutable state. ImmutableSOC provides a type-safe, functional implementation of Catan game rules, supporting both perfect information (server-side) and public information (player perspective) game modes.
Add ImmutableSOC to your project:
sbt:
libraryDependencies += "io.github.soc-training-tool" %% "immutablesoc" % "0.0.7-SNAPSHOT"Maven:
<dependency>
<groupId>io.github.soc-training-tool</groupId>
<artifactId>immutablesoc_2.13</artifactId>
<version>0.0.7-SNAPSHOT</version>
</dependency>import soc.base.BaseGame._
import soc.base._
import soc.core._
import game._
import shapeless._
// Create a game instance
val game = PerfectInfoGame.game
// Initialize game state
val board = BaseBoard(hexes, ports)
val initState = ImmutableGame.initialize[PerfectInfoState]
.replaceAll(board :: Bank(bank) :: DevelopmentCardDeck(devDeck) :: robberLocation :: HNil)
// Apply a move
val newState = game.applyMove(
Coproduct[PerfectInfoMoves](RollDiceMoveResult(0, 5)),
initState
)
// Query state
val points = newState.select[PlayerPoints]ImmutableSOC provides two game modes:
Complete information variant where all game state is fully visible. Use this for:
- Server-side game logic
- AI training with complete information
- Game analysis and debugging
- Single-player simulations
All player resources and development cards are tracked precisely.
Partial information variant where only public information is visible. Use this for:
- Player-facing game state
- Multiplayer games with hidden information
- Realistic game simulations
- Converting server state to player perspectives
Only resource/card counts are visible, not the specific cards each player holds.
Game state is represented as an HList (heterogeneous list) containing all game components:
// Perfect Info State includes:
RobberLocation :: PrivateInventories :: PrivateDevelopmentCards ::
DevelopmentCardDeck :: Bank :: Turn :: PlayerPoints :: ... :: HNilState elements can be accessed using .select[Type]:
val currentTurn = state.select[Turn]
val playerPoints = state.select[PlayerPoints]All moves are wrapped in Coproducts (type-safe unions):
Coproduct[PerfectInfoMoves](BuildRoadMove(playerId, edge))
Coproduct[PublicInfoMoves](TradeMove(player, partner, give, get))Moves transform state immutably:
val newState = game.applyMove(move, currentState)Create a board with hexes and ports:
import soc.base._
import soc.core._
import game._
val board = BaseBoard(
List[Hex[Resource]](
ResourceHex(WHEAT, 6),
ResourceHex(ORE, 2),
ResourceHex(SHEEP, 5),
ResourceHex(BRICK, 4),
Desert,
ResourceHex(WOOD, 10),
// ... 13 more hexes for standard board
),
ports = List(MISC, ORE, MISC, WHEAT, MISC, BRICK, WOOD, SHEEP, MISC)
)
val bank = InventorySet(Map(
WOOD -> 19,
BRICK -> 19,
SHEEP -> 19,
WHEAT -> 19,
ORE -> 19
))
val devDeck = List.fill(14)(KNIGHT) ++
List.fill(5)(Point) ++
List.fill(2)(Monopoly) ++
List.fill(2)(RoadBuilder) ++
List.fill(2)(YearOfPlenty)
val robberLocation = RobberLocation(7) // Start on desert
// Initialize Perfect Info Game
val initState = ImmutableGame.initialize[PerfectInfoState]
.replaceAll(
board :: Bank(bank) :: DevelopmentCardDeck(devDeck) ::
robberLocation :: HNil
)
// Or initialize Public Info Game
val publicState = ImmutableGame.initialize[PublicInfoState]
.replaceAll(
board :: Bank(bank) :: DevelopmentCardDeckSize(devDeck.size) ::
robberLocation :: HNil
)import shapeless._
// First settlement and road for player 0
val state1 = game.applyMove(
Coproduct[PerfectInfoMoves](
InitialPlacementMove(Vertex(33), Edge(4, 33), isFirstPlacement = true, 0)
),
initState
)
// Second settlement and road for player 0
val state2 = game.applyMove(
Coproduct[PerfectInfoMoves](
InitialPlacementMove(Vertex(15), Edge(15, 38), isFirstPlacement = false, 0)
),
state1
)// Roll dice
val afterRoll = game.applyMove(
Coproduct[PerfectInfoMoves](RollDiceMoveResult(0, 8)),
currentState
)
// End turn
val afterTurn = game.applyMove(
Coproduct[PerfectInfoMoves](EndTurnMove(0)),
afterRoll
)// Build a road
val withRoad = game.applyMove(
Coproduct[PerfectInfoMoves](BuildRoadMove(0, Edge(48, 49))),
currentState
)
// Build a settlement
val withSettlement = game.applyMove(
Coproduct[PerfectInfoMoves](BuildSettlementMove(0, Vertex(48))),
withRoad
)
// Build a city (upgrade settlement)
val withCity = game.applyMove(
Coproduct[PerfectInfoMoves](BuildCityMove(0, Vertex(33))),
withSettlement
)// Port trade (4:1 generic port - 4 wheat for 1 wood)
val afterPortTrade = game.applyMove(
Coproduct[PerfectInfoMoves](
PortTradeMove(0, ResourceSet(wh = 4), ResourceSet(wo = 1))
),
currentState
)
// Player-to-player trade (player 1 trades with player 3)
val afterPlayerTrade = game.applyMove(
Coproduct[PublicInfoMoves](
TradeMove(1, 3, ResourceSet(WOOD), ResourceSet(WHEAT, SHEEP))
),
currentState
)// Buy a development card (perfect info shows which card)
val afterBuy = game.applyMove(
Coproduct[PerfectInfoMoves](
PerfectInfoBuyDevelopmentCardMoveResult(0, KNIGHT)
),
currentState
)
// Buy a development card (public info hides the card)
val afterPublicBuy = game.applyMove(
Coproduct[PublicInfoMoves](
BuyDevelopmentCardMoveResult[DevelopmentCard](0, None)
),
publicState
)
// Play Knight card
val afterKnight = game.applyMove(
Coproduct[PerfectInfoMoves](
PerfectInfoPlayKnightResult(
PerfectInfoRobberMoveResult(0, 13, Some(PlayerSteal(1, BRICK)))
)
),
currentState
)
// Play Monopoly
val afterMonopoly = game.applyMove(
Coproduct[PublicInfoMoves](
PlayMonopolyMoveResult(0, SHEEP, Map(1 -> 3, 2 -> 2, 3 -> 1))
),
publicState
)
// Play Year of Plenty
val afterYOP = game.applyMove(
Coproduct[PublicInfoMoves](
PlayYearOfPlentyMove(0, WHEAT, WHEAT)
),
publicState
)
// Play Road Builder
val afterRoadBuilder = game.applyMove(
Coproduct[PublicInfoMoves](
PlayRoadBuilderMove(0, Edge(10, 33), Edge(33, 56))
),
publicState
)
// Play Victory Point card
val afterPoint = game.applyMove(
Coproduct[PublicInfoMoves](PlayPointMove(0)),
publicState
)// Move robber and steal (perfect info shows exact card stolen)
val afterRobber = game.applyMove(
Coproduct[PerfectInfoMoves](
PerfectInfoRobberMoveResult(0, 9, Some(PlayerSteal(1, BRICK)))
),
currentState
)
// Move robber and steal (public info shows count only)
val afterPublicRobber = game.applyMove(
Coproduct[PublicInfoMoves](
RobberMoveResult[Resource](0, 9, Some(PlayerSteal(1, Some(WHEAT))))
),
publicState
)// Discard cards when rolling 7 with >7 cards
val afterDiscard = game.applyMove(
Coproduct[PerfectInfoMoves](
DiscardMove(1, ResourceSet(or = 3, br = 1))
),
currentState
)Resources can be specified in two ways:
// Named parameters (recommended)
ResourceSet(wo = 2, br = 1) // 2 wood, 1 brick
ResourceSet(wh = 4) // 4 wheat
ResourceSet(sh = 1, wh = 1, or = 1) // 1 sheep, 1 wheat, 1 ore
// Explicit listing
ResourceSet(WOOD, WOOD, BRICK) // 2 wood, 1 brick
ResourceSet(WHEAT, WHEAT, WHEAT, WHEAT) // 4 wheat
// Parameter key mapping:
// wo = WOOD
// br = BRICK
// sh = SHEEP
// wh = WHEAT
// or = OREExtract information from state using .select[Type]:
// Player points
val points: PlayerPoints = state.select[PlayerPoints]
val player0Points = points.points.getOrElse(0, 0)
// Current turn
val turn: Turn = state.select[Turn]
val turnNumber = turn.t
// Bank inventory
val bank: Bank[Resource] = state.select[Bank[Resource]]
val wheatInBank = bank.b.getAmount(WHEAT)
// Player inventories (Perfect Info)
val inventories: PrivateInventories[Resource] =
state.select[PrivateInventories[Resource]]
val player0Wood = inventories.players(0).getAmount(WOOD)
// Player inventories (Public Info)
val publicInv: PublicInventories[Resource] =
publicState.select[PublicInventories[Resource]]
val player1CardCount = publicInv.numCards(publicState, 1)
// Building placements
val settlements: VertexBuildingState[BaseVertexBuilding] =
state.select[VertexBuildingState[BaseVertexBuilding]]
val roads: EdgeBuildingState[BaseEdgeBuilding] =
state.select[EdgeBuildingState[BaseEdgeBuilding]]
// Robber location
val robber: RobberLocation = state.select[RobberLocation]
val robberHex = robber.hex
// Longest road
val longestRoad: SOCLongestRoadPlayer =
state.select[SOCLongestRoadPlayer]
// Largest army
val largestArmy: LargestArmyPlayer =
state.select[LargestArmyPlayer]import soc.base.BaseGame._
import soc.base._
import soc.core._
import game._
import shapeless._
// 1. Setup
val game = PerfectInfoGame.game
val board = BaseBoard(standardHexes, standardPorts)
val bank = InventorySet(Map(WOOD -> 19, BRICK -> 19, SHEEP -> 19, WHEAT -> 19, ORE -> 19))
val devDeck = standardDevelopmentCardDeck
val robberLocation = RobberLocation(7)
var state = ImmutableGame.initialize[PerfectInfoState]
.replaceAll(board :: Bank(bank) :: DevelopmentCardDeck(devDeck) :: robberLocation :: HNil)
// 2. Initial placement (4 players, 2 settlements each)
state = game.applyMove(
Coproduct[PerfectInfoMoves](InitialPlacementMove(Vertex(33), Edge(4, 33), true, 0)),
state
)
// ... repeat for all players
// 3. Start normal play
state = game.applyMove(
Coproduct[PerfectInfoMoves](RollDiceMoveResult(0, 8)),
state
)
// 4. Player actions
state = game.applyMove(
Coproduct[PerfectInfoMoves](BuildRoadMove(0, Edge(48, 49))),
state
)
state = game.applyMove(
Coproduct[PerfectInfoMoves](
PortTradeMove(0, ResourceSet(WOOD, WOOD, WOOD, WOOD), ResourceSet(BRICK))
),
state
)
state = game.applyMove(
Coproduct[PerfectInfoMoves](BuildSettlementMove(0, Vertex(48))),
state
)
// 5. End turn
state = game.applyMove(
Coproduct[PerfectInfoMoves](EndTurnMove(0)),
state
)
// 6. Check game state
val currentPoints = state.select[PlayerPoints]
val currentTurn = state.select[Turn]
println(s"Player 0 points: ${currentPoints.points.getOrElse(0, 0)}")
println(s"Turn: ${currentTurn.t}")This project uses GitHub Actions for continuous integration and publishing.
The CI workflow automatically runs tests and generates coverage reports on every push and pull request.
Releases are automatically published to Maven Central using conventional commits for semantic versioning.
When you merge to master, the release workflow:
- Analyzes commit messages since the last tag
- Determines version bump based on conventional commits:
fix:commits → patch version bump (0.0.7 → 0.0.8)feat:commits → minor version bump (0.0.7 → 0.1.0)BREAKING CHANGE:orfeat!:→ major version bump (0.0.7 → 1.0.0)
- Updates
build.sbtwith the new version - Creates and pushes a git tag (e.g.,
v0.0.8) - The publish workflow then:
- Runs tests
- Signs artifacts with your GPG key
- Publishes to Maven Central via Sonatype
- Creates a GitHub release
Use conventional commit messages in your PRs:
# Patch release (bug fixes)
git commit -m "fix: correct settlement placement validation"
# Minor release (new features)
git commit -m "feat: add support for 5-6 player expansion"
# Major release (breaking changes)
git commit -m "feat!: redesign game state API"
# or
git commit -m "feat: new API
BREAKING CHANGE: GameState structure has changed"To prevent a release when merging to master, include [skip ci] or [ci skip] in your commit message:
git commit -m "docs: update README [skip ci]"You can also manually create a release by pushing a tag:
git tag v0.0.7
git push origin v0.0.7The following secrets must be configured in your repository settings:
SONATYPE_USERNAME- Your Sonatype JIRA usernameSONATYPE_PASSWORD- Your Sonatype JIRA passwordPGP_SECRET- Your GPG private key (base64-encoded)PGP_PASSPHRASE- Your GPG key passphrasePGP_KEY_ID- Your GPG key ID in hex format
To encode your GPG key:
# Export your private key
gpg --armor --export-secret-keys YOUR_KEY_ID > private-key.asc
# Base64 encode it
cat private-key.asc | base64 > private-key.base64
# Copy the contents of private-key.base64 to the PGP_SECRET secretTo get your key ID:
gpg --list-secret-keys --keyid-format LONG
# Use the long hex ID after 'sec rsa4096/'To enable Codecov for visual coverage tracking and PR comments:
- Go to codecov.io and sign in with GitHub
- Add the
SOC-Training-Tool/ImmutableSOCrepository - No token needed for public repositories - the existing workflow will automatically start uploading coverage
- (Optional) Add a coverage badge to this README from the Codecov dashboard