Skip to content

dshah3/smallworld

Repository files navigation

smallworld

A deterministic Small World board game engine in Python. Implements the complete base game rules with 14 races, 20 special powers, procedural map generation, and an AI-friendly observation API. All randomness is seeded for perfect replay and reproducibility.

Version: 0.1.0 License: MIT Python: >= 3.12 Dependencies: Standard library only (no third-party runtime dependencies)


Table of Contents


Quick Start

Installation

uv pip install -e .

Note: This project uses uv for fast, reliable package management. Install it with:

pip install uv

Running a Game

from smallworld import SmallWorldGame, GameConfig

# Create a 3-player game with seed 42
config = GameConfig(num_players=3, seed=42)
game = SmallWorldGame(config)

# Game loop
while not game.is_game_over():
    player_id = game.get_current_player()
    observation = game.get_observation(player_id)
    legal_actions = game.get_legal_actions()

    # Pick an action (your agent logic here)
    action = legal_actions[0]

    result = game.submit_action(player_id, action)

# Get final results
results = game.get_results()
print(f"Winner: Player {results['winner']}")
print(f"Scores: {results['scores']}")

Deterministic Replay

# Replay a game from its config and action log
replayed_game = SmallWorldGame.replay(config, game._state.action_log)

Running Tests

uv run pytest

Or if you prefer to activate the environment:

uv pip install pytest
pytest

Project Structure

smallworld/
├── pyproject.toml                        # Build config (setuptools, Python >=3.12)
├── README.md                             # This file
├── assets/
│   ├── races.json                        # 14 race configurations (tokens, decline rules)
│   └── powers.json                       # 20 power configurations (tokens, decline rules)
├── rules/
│   ├── 00_rules_overview.md              # Game overview
│   ├── 01_setup.md                       # Setup rules
│   ├── 02_turn_structure.md              # Turn structure rules
│   ├── 03_conquest.md                    # Conquest rules
│   ├── 04_decline.md                     # Decline rules
│   ├── 05_scoring_endgame.md             # Scoring and endgame rules
│   └── 06_glossary.md                    # Game terminology glossary
├── smallworld/
│   ├── __init__.py                       # Public API: SmallWorldGame, GameConfig
│   ├── engine.py                         # Game orchestrator (~1070 lines)
│   ├── state.py                          # Dataclasses for all game state (~158 lines)
│   ├── enums.py                          # Terrain, Feature, Marker, Phase, ActionType, etc.
│   ├── actions.py                        # Action validation & legal move enumeration (~1138 lines)
│   ├── conquest.py                       # Conquest cost, execution, displacement (~342 lines)
│   ├── scoring.py                        # Per-turn scoring logic (~98 lines)
│   ├── phase_machine.py                  # Turn phase state machine (~318 lines)
│   ├── game_map.py                       # Immutable map graph structure (~122 lines)
│   ├── map_generator.py                  # Procedural hex-grid map generation (~340 lines)
│   ├── combo.py                          # Race+Power combo selection row (~83 lines)
│   ├── observation.py                    # Player observation API for agents (~146 lines)
│   ├── data_loader.py                    # JSON asset loader with caching (~109 lines)
│   ├── rng.py                            # Seeded RNG wrapper (~51 lines)
│   ├── errors.py                         # Custom exception hierarchy (~19 lines)
│   └── abilities/
│       ├── __init__.py                   # AbilityHook base class & registry (~140 lines)
│       ├── races.py                      # 14 race ability implementations (~225 lines)
│       └── powers.py                     # 20 power ability implementations (~310 lines)
└── tests/
    ├── __init__.py
    ├── conftest.py                       # Fixtures (game_config, game, game_state) & helpers
    ├── test_engine.py                    # Engine integration tests
    ├── test_actions.py                   # Action validation tests
    ├── test_conquest.py                  # Conquest mechanic tests
    ├── test_combo.py                     # Combo row tests
    ├── test_scoring.py                   # Scoring tests
    ├── test_map_generator.py             # Map generation tests
    ├── test_determinism.py               # Determinism / replay verification
    └── test_full_simulation.py           # Full game simulation tests

Architecture Overview

┌─────────────────────────────────────────────────────────────────┐
│                        SmallWorldGame                           │
│  (engine.py:68)                                                 │
│                                                                 │
│  Public API:                                                    │
│    submit_action() ─── validate_action() ─── _execute()         │
│    get_observation()   get_legal_actions()    _auto_advance()    │
│    get_current_player()   get_phase()         replay()          │
│    is_game_over()         get_results()                         │
└──────────────────────────┬──────────────────────────────────────┘
                           │
          ┌────────────────┼────────────────┐
          │                │                │
          ▼                ▼                ▼
   ┌────────────┐  ┌─────────────┐  ┌────────────────┐
   │  actions.py │  │ conquest.py │  │ phase_machine  │
   │  Validation │  │  Execution  │  │  .py           │
   │  & Legal    │  │  & Displace │  │  Phase FSM     │
   │  Move Enum  │  │  -ment      │  │                │
   └──────┬─────┘  └──────┬──────┘  └───────┬────────┘
          │               │                  │
          └───────┬───────┘                  │
                  ▼                          │
          ┌─────────────┐                    │
          │  GameState   │◄───────────────────┘
          │  (state.py)  │
          │              │
          │  Single      │
          │  source of   │
          │  truth       │
          └──────┬───────┘
                 │
    ┌────────────┼─────────────┐
    │            │             │
    ▼            ▼             ▼
┌────────┐ ┌──────────┐ ┌───────────┐
│MapGraph│ │ ComboRow │ │PlayerState│
│(frozen)│ │          │ │RegionState│
└────────┘ └──────────┘ └───────────┘

Cross-cutting: abilities/ (hook-based system for race/power behavior)
               scoring.py (end-of-turn coin awards)
               observation.py (agent-facing API)
               rng.py (deterministic randomness)

Data flow for a single action

  1. Agent calls game.submit_action(player_id, action_dict)
  2. Engine normalizes the action type (engine.py:48)
  3. Action is validated by actions.py:validate_action()
  4. Action is dispatched to the appropriate handler via _ACTION_HANDLERS dispatch table
  5. Handler mutates GameState in place
  6. Action is logged to GameState.action_log
  7. _auto_advance() runs automatic phases (SCORE, TURN_END)
  8. Result dict is returned with new phase, current player, and game-over flag

Core Modules

Engine

File: smallworld/engine.py

The top-level game orchestrator. Contains the SmallWorldGame class and all action handler functions.

SmallWorldGame (line 68)

Method Line Description
__init__(config) 75 Initialize game: create map, regions, players, combo row
get_observation(player_id) 117 Build JSON-serializable player observation
get_legal_actions() 121 Enumerate all legal actions for current player
get_current_player() 125 Return current player ID
get_phase() 129 Return current game phase
is_game_over() 133 Check if game has ended
get_results() 137 Final scores, rankings, and winner
submit_action(player_id, action) 155 Validate and execute an action, returns result dict
replay(config, action_log) 268 Static method: replay a game deterministically

Action Handlers

All handlers take (gs: GameState, action: dict) and return a result dict:

Handler Line Triggered By
_handle_pick_combo 280 PICK_COMBO
_handle_expand ~320 EXPAND
_handle_decline ~340 DECLINE
_handle_conquer ~400 CONQUER
_handle_skip_conquest ~500 SKIP_CONQUEST
_handle_final_conquest ~520 FINAL_CONQUEST
_handle_redeploy ~580 REDEPLOY
_handle_use_dragon ~640 USE_DRAGON
_handle_use_sorcerer ~700 USE_SORCERER
_handle_place_bivouacs ~760 PLACE_BIVOUACS
_handle_place_fortress ~790 PLACE_FORTRESS
_handle_place_heroes ~810 PLACE_HEROES
_handle_choose_diplomat_ally ~830 CHOOSE_DIPLOMAT_ALLY
_handle_stout_decline ~845 STOUT_DECLINE
_handle_opponent_redeploy ~855 OPPONENT_REDEPLOY

Auto-Advance System (line 220)

The engine automatically processes phases that require no player input:

  • SCORE: Awards victory coins via scoring.py
  • TURN_END: Fires turn-end hooks, advances to the next player
  • Empty post-redeploy phases: Skipped when player has no valid actions
  • OPPONENT_REDEPLOY: Auto-resolves when displaced players have no regions

Game State

File: smallworld/state.py

All mutable game state lives in dataclasses. GameState is the single source of truth.

RegionState (line 15)

Mutable occupancy state of a single map region.

region_id: int
owner: int | None          # Player who controls this region
tokens: int                # Number of race tokens
is_decline: bool           # Whether tokens belong to a decline race
decline_race: RaceName | None
decline_power: PowerName | None
markers: list[Marker]      # MOUNTAIN, TROLL_LAIR, FORTRESS, ENCAMPMENT, etc.
has_lost_tribe: bool       # Whether a lost tribe occupies this region

ActiveRaceState (line 29)

Tracks a player's currently active race and per-turn bookkeeping.

race_name: RaceName
power_name: PowerName
tokens_in_hand: int                    # Tokens available for conquest/redeployment
total_tokens: int                      # Total tokens for this race
first_turn: bool                       # True on the turn the combo was picked
regions_conquered_this_turn: list[int] # Region IDs conquered this turn
non_empty_conquests_this_turn: int     # Count of conquests against occupied regions
opponents_attacked_this_turn: set[int] # Player IDs attacked (for Diplomat)
sorcerer_targets_this_turn: set[int]   # Regions converted by Sorcerer
dragon_used_this_turn: bool            # Whether dragon was used
berserk_die_result: int | None         # Last Berserk die result

PlayerState (line 56)

player_id: int
coins: int = 5                             # Victory coins (starts at 5)
active: ActiveRaceState | None             # Current active race
decline: DeclineRaceState | None           # Primary decline race
decline_spirit: DeclineRaceState | None    # Second decline (Spirit power only)
displaced_tokens: int                      # Tokens awaiting redeployment

GameConfig (line 69)

Immutable configuration. Validates player count (3, 4, or 5) and derives max_turns.

num_players: int          # 3, 4, or 5
seed: int                 # RNG seed for game events
map_seed: int | None      # Separate seed for map generation (defaults to seed)

GameState (line 96)

Complete mutable game state. Holds the map, all region states, all player states, combo row, phase, turn counter, RNG, action log, and special state for displacements, diplomat immunity, and ghoul conquests.

Key query methods:

  • player_regions(player_id, decline) (line 116) -- all regions owned by a player
  • player_active_regions(player_id) (line 135) -- active (non-decline) regions
  • player_decline_regions(player_id) (line 139) -- decline regions
  • tokens_on_map(player_id, decline) (line 151) -- count tokens on the map

Phase Machine

File: smallworld/phase_machine.py

A pure-function state machine that determines phase transitions. Does not mutate state -- only reads it.

Function Line Description
get_legal_action_types(game_state) 20 Legal action types for current phase
get_post_redeploy_phases(game_state, player_id) 104 Extra phases between REDEPLOY and SCORE
advance_phase(game_state, action_type) 126 Compute next phase given current phase + action
advance_to_next_player(game_state) 240 Move to next player, increment turn, check game-over

Actions

File: smallworld/actions.py

Handles both validation (is this action legal?) and enumeration (what are all legal actions?).

Validation entry point

  • validate_action(game_state, player_id, action) -- dispatches to per-type validators

Legal action enumeration

  • get_legal_actions(game_state) -- returns a list of all legal action dicts for the current player/phase
  • get_conquerable_regions(game_state, player_id) -- returns all regions the player can conquer

Each action type has a _validate_* function and an _enum_* function. Redeploy enumeration has both a compact mode (for large state spaces) and an exhaustive mode for smaller cases.


Conquest

File: smallworld/conquest.py

Function Line Description
compute_base_conquest_cost(region_id, game_state) 48 Raw cost: 2 + defenders + markers + lost tribe
compute_conquest_cost(region_id, game_state, player_id) 74 After race/power discounts (min 1)
can_conquer_region(region_id, game_state, player_id) 106 Full legality check (ownership, immunity, water, diplomat, tokens)
execute_conquest(region_id, game_state, player_id, tokens_used) 188 Perform conquest: displace, clear markers, install attacker, fire hooks
handle_displacement(game_state, defender_id, tokens, is_decline) 314 Queue active tokens for redeployment; decline tokens are lost

Scoring

File: smallworld/scoring.py

Function Line Description
compute_player_score(game_state, player_id) 18 Compute total coins earned this turn
apply_scoring(game_state, player_id) 87 Compute score and add to player's coins

Observation API

File: smallworld/observation.py

Builds a JSON-serializable dict for AI agents. Designed for information asymmetry: opponent coins are "HIDDEN", RNG state is never exposed.

  • build_observation(game_state, player_id) (line 96) -- returns a dict with sections:
    • game -- turn number, max turns, num players, current player, phase
    • map -- all regions with terrain, features, owner, tokens, markers, adjacency
    • combo_row -- visible combos with race, power, total tokens, coins on combo
    • self -- player's coins, active race (full detail), decline races
    • opponents -- other players' public info (coins hidden)
    • legal_actions -- all legal actions for the requesting player

Map & Map Generator

Files: smallworld/game_map.py, smallworld/map_generator.py

Region (game_map.py:9)

Frozen dataclass representing a map node. Properties: is_border, is_coastal, has_mine, has_magic_source, has_cavern, is_water, is_land.

MapGraph (game_map.py:45)

Immutable undirected graph. Never modified during gameplay.

Method Line Description
get_region(id) 67 Lookup region by ID
neighbors(id) 71 Adjacent region IDs
border_regions() 75 All border region IDs (cached)
coastal_regions() 83 All coastal region IDs (cached)
cavern_regions() 91 All cavern region IDs (cached)
land_regions() 99 All non-water region IDs
water_regions() 103 All sea/lake region IDs
regions_by_terrain(terrain) 107 Filter by terrain type
regions_with_feature(feature) 111 Filter by feature

Map Generator (map_generator.py)

Procedural hex-grid generation with retry. Entry point: generate_map(num_players, rng) (line 313).

The generator:

  1. Builds a hex grid sized for the player count
  2. Removes 2-4 random edge cells for irregular shape
  3. Places sea on edges, lakes in interior
  4. Assigns weighted terrain types to land cells
  5. Assigns features (mines, magic sources, caverns)
  6. Computes border and coastal regions
  7. Validates: land connectivity (BFS), minimum border regions, minimum feature counts
  8. Places lost tribes on ~40% of land regions
  9. Retries up to 100 times on failure

Combo Row

File: smallworld/combo.py

Combo (line 11)

A race+power pairing. total_tokens = race.base_tokens + power.bonus_tokens.

ComboRow (line 24)

The 6-visible combo selection mechanism.

  • Constructor (line 27): Shuffles race and power decks, draws 6 combos
  • visible (line 44): Currently visible combos (index 0 is top/free)
  • pick(index, player_coins) (line 49): Select combo at index N. Costs N coins, which are placed one each on combos 0..N-1. Returns (chosen_combo, net_coin_change).

Data Loader

File: smallworld/data_loader.py

Loads race/power configurations from JSON files in assets/. Uses lazy caching -- data is loaded from disk on first access and cached globally.

Function Line Description
load_races(path) 39 Parse races.json into dict[RaceName, RaceConfig]
load_powers(path) 57 Parse powers.json into dict[PowerName, PowerConfig]
get_race_config(name) 79 Cached single race lookup
get_power_config(name) 87 Cached single power lookup
get_all_races() 95 Cached full race dict
get_all_powers() 103 Cached full power dict

RaceConfig (line 14)

name: RaceName
base_tokens: int
persists_in_decline: bool | str

PowerConfig (line 27)

name: PowerName
bonus_tokens: int
persists_in_decline: bool | str

Seeded RNG

File: smallworld/rng.py

All game randomness flows through SeededRNG (line 9), a wrapper around random.Random. This guarantees deterministic replay when given the same seed.

Method Line Description
roll_reinforcement_die() 20 Returns 0, 0, 0, 1, 2, or 3 (weighted: 50% chance of 0)
shuffle(items) 24 In-place list shuffle
random_int(lo, hi) 28 Random integer in [lo, hi]
random_float() 32 Random float in [0.0, 1.0)
choice(items) 36 Random element from list
sample(items, k) 40 k unique random elements
get_state() / set_state() 44, 48 Serialize/restore RNG state

The reinforcement die faces are defined at module level: REINFORCEMENT_DIE_FACES = [0, 0, 0, 1, 2, 3] (line 6).


Enums

File: smallworld/enums.py

Enum Line Values
Terrain 4 FARMLAND, HILL, FOREST, SWAMP, MOUNTAIN, SEA, LAKE
Feature 15 MINE, MAGIC_SOURCE, CAVERN, BORDER, COASTAL
Marker 24 MOUNTAIN, LOST_TRIBE, TROLL_LAIR, FORTRESS, ENCAMPMENT, HOLE_IN_THE_GROUND, DRAGON, HERO
Phase 44 15 phases (see Turn Flow)
ActionType 63 15 action types
RaceName 82 14 races (string values, e.g. "Amazons")
PowerName 100 20 powers (string values, e.g. "Dragon Master")

DEFENSIVE_MARKERS (line 36): frozenset({MOUNTAIN, TROLL_LAIR, FORTRESS, ENCAMPMENT}) -- markers that add +1 to conquest cost.


Errors

File: smallworld/errors.py

SmallWorldError (base)
├── IllegalAction    -- Action violates game rules
├── InvalidPhase     -- Wrong action for current phase
├── GameOver         -- Game already finished
└── InvalidConfig    -- Bad GameConfig parameters

Ability System

Files: smallworld/abilities/__init__.py, smallworld/abilities/races.py, smallworld/abilities/powers.py

Base Class & Registry

AbilityHook (line 17 in __init__.py) is the base class for all race and power abilities. Every method has a default no-op implementation, so subclasses only override the hooks they need.

Hook Methods

Hook Default Purpose
modify_conquest_cost() 0 Return discount amount for a conquest
get_extra_adjacency() set() Additional regions considered adjacent
ignore_adjacency() False Override all adjacency checks
is_valid_first_target() None Override first-conquest border restriction
can_conquer_sea() False Allow sea/lake conquest
can_conquer_target() True General conquest permission
on_region_conquered() no-op Fires after a conquest
modify_tokens_lost_on_defeat() base_loss Modify token loss on defense
on_ready_troops() 0 Extra tokens during ready-troops step
on_post_redeploy() no-op Fires after redeployment
get_post_redeploy_phases() [] Inject phases after REDEPLOY
compute_score_bonus() 0 Extra coins during scoring
on_decline() no-op Fires when going into decline
tokens_kept_in_decline() 1 Tokens kept per region in decline
decline_counts_toward_limit() True Whether decline counts toward one-decline limit
can_conquer_in_decline() False Allow conquests while in decline
can_stout_decline() False Allow end-of-turn decline
use_reinforcement_die_per_conquest() False Roll die on every conquest
is_region_immune() False Region immunity check
on_turn_end() no-op Fires at end of turn
on_combo_picked() no-op Fires when combo is selected

Registry Pattern

Abilities are registered via decorators (__init__.py:118-129):

@register_race(RaceName.AMAZONS)
class AmazonsAbility(AbilityHook):
    ...

@register_power(PowerName.FLYING)
class FlyingPower(AbilityHook):
    ...

Lookup functions: get_race_ability(name) (line 132), get_power_ability(name) (line 137). Unregistered names return NullAbility, a no-op subclass.

The registries are populated by importing smallworld.abilities.races and smallworld.abilities.powers in smallworld/__init__.py (lines 6-7).


Race Abilities

File: smallworld/abilities/races.py

Race Line Ability
Amazons 16 on_ready_troops: +4 temporary conquest tokens (returned before redeployment)
Dwarves 25 compute_score_bonus: +1 coin per mine region (works in active and decline)
Elves 47 modify_tokens_lost_on_defeat: Lose 0 tokens when conquered
Ghouls 58 can_conquer_in_decline + tokens_kept_in_decline: All tokens persist, can conquer while in decline
Giants 70 modify_conquest_cost: -1 cost when target is adjacent to own mountain-terrain region
Halflings 88 is_valid_first_target + on_region_conquered + is_region_immune: First 2 conquests ignore adjacency, place Hole-in-the-Ground markers (immune to conquest)
Humans 124 compute_score_bonus: +1 coin per farmland (active only)
Orcs 142 compute_score_bonus: +1 coin per non-empty conquest this turn (active only)
Ratmen 158 No special ability (compensated by higher base token count)
Skeletons 165 on_ready_troops: +1 bonus token per 2 non-empty conquests this turn
Sorcerers 177 Ability handled by engine via USE_SORCERER action type (converts adjacent single-token enemy)
Tritons 184 modify_conquest_cost: -1 cost for coastal regions
Trolls 198 on_region_conquered: Places Troll Lair marker (+1 defense, persists in decline)
Wizards 211 compute_score_bonus: +1 coin per magic source (active only)

Power Abilities

File: smallworld/abilities/powers.py

Power Line Ability
Alchemist 16 compute_score_bonus: +2 coins per turn (active only)
Berserk 29 use_reinforcement_die_per_conquest: Roll die on every conquest (not just final)
Bivouacking 39 get_post_redeploy_phases: PLACE_BIVOUACS phase; up to 5 encampments (+1 defense each)
Commando 55 modify_conquest_cost: -1 cost for all regions
Diplomat 66 get_post_redeploy_phases: CHOOSE_DIPLOMAT_ALLY; chosen opponent has mutual immunity
Dragon Master 75 Engine-handled: USE_DRAGON action conquers with 1 token, places Dragon marker (immune)
Flying 82 ignore_adjacency: Can conquer any region regardless of adjacency
Forest 91 compute_score_bonus: +1 coin per forest region (active only)
Fortified 110 get_post_redeploy_phases: PLACE_FORTRESS; 1 per turn, 6 max total; +1 defense, +1 coin, persists in decline
Heroic 132 get_post_redeploy_phases: PLACE_HEROES; 2 hero markers; regions become immune to conquest
Hill 147 compute_score_bonus: +1 coin per hill region (active only)
Merchant 165 compute_score_bonus: +1 coin per region occupied (active only) -- effectively doubles base scoring
Mounted 178 modify_conquest_cost: -1 cost for hills and farmland
Pillaging 192 compute_score_bonus: +1 coin per non-empty conquest (active only)
Seafaring 208 can_conquer_sea + cost discount: Can conquer sea/lake regions
Spirit 226 decline_counts_toward_limit: Decline stored in decline_spirit, allowing 2 decline races simultaneously
Stout 235 can_stout_decline: May decline at end of turn (after SCORE) instead of next turn
Swamp 244 compute_score_bonus: +1 coin per swamp region (active only)
Underworld 263 get_extra_adjacency + modify_conquest_cost: All caverns are adjacent to each other; -1 cost for caverns
Wealthy 287 on_combo_picked + compute_score_bonus: +7 coins on first turn only

Turn Flow & Phase Transitions

Player's Turn Start
    │
    ├─ No active race ──► PICK_COMBO ──► [GHOUL_CONQUEST] ──► CONQUEST
    │
    └─ Has active race ──► CHOOSE_EXPAND_OR_DECLINE
                               │
                               ├─ EXPAND ──► CONQUEST
                               │
                               └─ DECLINE ──► SCORE
                                                │
CONQUEST                                        │
    │                                           │
    ├─ CONQUER (stay in CONQUEST)                │
    ├─ USE_DRAGON (stay in CONQUEST)             │
    ├─ USE_SORCERER (stay in CONQUEST)           │
    ├─ SKIP_CONQUEST ──► FINAL_CONQUEST          │
    │                     │                      │
    │                     └──────────────────┐   │
    └─ FINAL_CONQUEST ──────────────────────►│   │
                                             ▼   │
                                          REDEPLOY
                                             │
                                             ▼
                                    Post-Redeploy Phases
                                    (injected by abilities)
                                    ├─ PLACE_BIVOUACS
                                    ├─ PLACE_FORTRESS
                                    ├─ PLACE_HEROES
                                    └─ CHOOSE_DIPLOMAT_ALLY
                                             │
                                             ▼
                                           SCORE ◄─────────────┘
                                             │
                                             ├─ Stout power ──► STOUT_DECLINE
                                             │
                                             ▼
                                      OPPONENT_REDEPLOY
                                      (if pending displacements)
                                             │
                                             ▼
                                          TURN_END
                                             │
                                             ├─ Last player of round ──► increment turn
                                             │
                                             ├─ turn > max_turns ──► GAME_OVER
                                             │
                                             └─ Next player's turn start

Phase Enum Values

Phase Requires Player Input Description
PICK_COMBO Yes Select a race+power combo
CHOOSE_EXPAND_OR_DECLINE Yes Expand with current race or go into decline
CONQUEST Yes Conquer regions (multiple actions)
GHOUL_CONQUEST Yes Special conquest phase for decline Ghouls
FINAL_CONQUEST Yes Last conquest attempt with reinforcement die
REDEPLOY Yes Distribute tokens among owned regions
PLACE_BIVOUACS Yes Place up to 5 encampment markers
PLACE_FORTRESS Yes Place 1 fortress marker
PLACE_HEROES Yes Place 2 hero markers
CHOOSE_DIPLOMAT_ALLY Yes Choose one opponent for mutual immunity
STOUT_DECLINE Yes Optional end-of-turn decline (Stout power)
OPPONENT_REDEPLOY Yes Displaced player repositions tokens
SCORE No (automatic) Award victory coins
TURN_END No (automatic) Fire turn-end hooks, advance player
GAME_OVER No (terminal) Game has ended

Conquest Algorithm

Defined in smallworld/conquest.py.

Cost Calculation

Base cost (compute_base_conquest_cost, line 48):

cost = 2
     + number of defender tokens
     + number of defensive markers (Mountain, Troll Lair, Fortress, Encampment)
     + 1 if lost tribe present

Effective cost (compute_conquest_cost, line 74):

effective = max(1, base - race_discount - power_discount)

Legality Check (can_conquer_region, line 106)

  1. Region is not already owned by attacker's active race
  2. Region is not immune (Hero markers, Dragon markers, Hole-in-the-Ground)
  3. Water regions require sea-conquest ability (Seafaring power)
  4. Diplomat immunity is respected (mutual: neither player can attack the other)
  5. Player has enough tokens (or at least 1 for final-conquest die roll)

Execution (execute_conquest, line 188)

  1. Snapshot defender state
  2. Displace defender tokens (lose 1, redeploy the rest; Elves lose 0)
  3. Remove lost tribe
  4. Remove non-persistent markers (keep Mountain terrain marker only)
  5. Install attacker tokens
  6. Fire race and power on_region_conquered hooks
  7. Update bookkeeping (conquered regions, non-empty count, opponents attacked)

Scoring Breakdown

Defined in smallworld/scoring.py.

Per turn, a player earns:

Source Coins Condition
Active regions +1 each Always
Decline regions +1 each Always
Active race bonus Varies Race-specific (e.g., Humans: +1 per farmland)
Active power bonus Varies Power-specific (e.g., Alchemist: +2 flat)
Decline race bonus Varies Only if persists_in_decline is true
Decline power bonus Varies Only if persists_in_decline is true

Scoring happens automatically at the SCORE phase. The engine calls apply_scoring() which adds the computed coins to the player's total.


Map Generation

Defined in smallworld/map_generator.py.

Region Counts by Player Count

Players Land Regions Sea Lakes Total
3 26 3-4 1 ~30
4 34 4-5 1 ~39
5 40 5-6 1 ~46

Terrain Distribution (Land)

Terrain Weight
Farmland 22%
Hill 22%
Forest 22%
Swamp 17%
Mountain 17%

Feature Placement

Each of mine, magic source, and cavern targets 15% of land regions, constrained to 2-5 instances.

Lost Tribes

40% of land regions start with a lost tribe (+1 defense, removed on conquest).

Validation Criteria

A generated map must satisfy:

  • All land regions form a single connected component (BFS check)
  • At least 4 border regions exist
  • At least 2 each of mines, magic sources, and caverns

Failed maps are discarded and regenerated (up to 100 attempts).


Design Decisions

Deterministic by Default

All randomness flows through SeededRNG, making every game perfectly reproducible. Given the same GameConfig, the same sequence of actions always produces identical outcomes. This enables:

  • Deterministic replay via SmallWorldGame.replay()
  • Reproducible test cases
  • AI training with controlled random seeds

Single Source of Truth

GameState holds all mutable state in one place. No component maintains its own shadow state. This simplifies reasoning about game logic and makes serialization straightforward.

Immutable Map Topology

MapGraph is immutable after construction. Region connections never change. Only RegionState (owner, tokens, markers) is mutable. This separates static map structure from dynamic game state.

Hook-Based Ability System

The AbilityHook base class defines 20+ hook methods with no-op defaults. Concrete race/power abilities override only the hooks they need. This avoids conditional chains (if race == AMAZONS ... elif race == DWARVES ...) and makes adding new abilities straightforward.

Decorator Registry

Race and power abilities register themselves via @register_race and @register_power decorators. The registry is populated by importing the modules in __init__.py. Unregistered abilities fall back to NullAbility.

Strict Phase State Machine

The phase_machine.py module is a pure-function FSM. It determines the next phase based on the current phase, the action taken, and the game state. It never mutates state directly. Phase transitions are deterministic and testable in isolation.

Action-Validation Separation

actions.py contains both validators and enumerators, but they are separate functions. Validators raise exceptions on illegal actions. Enumerators produce all legal actions for a given state. The engine calls validators during submit_action and enumerators for get_legal_actions.

Automatic Phase Advancement

The engine's _auto_advance() loop processes phases that require no player input (SCORE, TURN_END) immediately after each action. This means the caller never needs to manually trigger scoring or turn advancement.

Information Asymmetry in Observations

The observation.py module builds per-player observations that hide opponent coins (replaced with "HIDDEN") and never expose RNG state. This makes the observation API suitable for training AI agents with imperfect information.

Separate Map Seed

GameConfig supports a map_seed separate from the game seed. This allows varying game randomness while holding the map constant, or vice versa. If map_seed is not provided, it defaults to seed.

No Third-Party Runtime Dependencies

The engine uses only Python standard library modules (random, json, pathlib, dataclasses, enum, collections). This minimizes installation friction and ensures portability.


Data Files

assets/races.json

Contains all 14 race configurations. Each entry has:

  • name: Race name (matches RaceName enum values)
  • base_tokens: Starting token count for the race
  • persists_in_decline: Whether the race ability continues to function in decline
  • rules: Markdown text describing the race's rules
  • tags: Categorization tags

assets/powers.json

Contains all 20 power configurations. Each entry has:

  • name: Power name (matches PowerName enum values)
  • bonus_tokens: Token bonus from the power badge
  • persists_in_decline: Whether the power continues to function in decline
  • rules: Markdown text describing the power's rules
  • tags: Categorization tags

Both files are loaded by data_loader.py and cached after first access.


Testing

The test suite is in tests/ and uses pytest.

Fixtures (tests/conftest.py)

Fixture Description
game_config GameConfig(num_players=3, seed=42)
game Fresh SmallWorldGame from game_config
game_state Internal GameState extracted from game._state

Helper: play_full_game(seed, num_players)

Plays a complete game using a greedy agent that prefers CONQUER > EXPAND > first legal action. Used by integration and determinism tests. Defined in tests/conftest.py:39.

Test Files

File Focus
test_engine.py Game initialization, combo picking, action dispatch
test_actions.py Per-action-type validation and legal action enumeration
test_conquest.py Conquest cost calculation, execution, displacement
test_combo.py Combo row selection, coin placement, refill
test_scoring.py Scoring calculation and application
test_map_generator.py Map generation, connectivity, feature placement
test_determinism.py Same seed produces identical game outcomes
test_full_simulation.py Full games run to completion without errors

Running Tests

# Run all tests
uv run pytest

# Run a specific test file
uv run pytest tests/test_engine.py

# Run with verbose output
uv run pytest -v

Rules Reference

Detailed game rules are in the rules/ directory:

File Content
00_rules_overview.md High-level game overview
01_setup.md Game setup procedure
02_turn_structure.md Turn phases and flow
03_conquest.md Conquest mechanics
04_decline.md Decline mechanics
05_scoring_endgame.md Scoring and end-game
06_glossary.md Game terminology

Key Constants & Configuration

Game Duration

Players Turns
3 10
4 9
5 8

Defined in GameConfig.TURN_COUNTS.

Starting Conditions

  • Each player starts with 5 victory coins
  • 6 combos visible in the combo row
  • Combo selection costs index N coins (index 0 is free)

Reinforcement Die

Faces: [0, 0, 0, 1, 2, 3] -- 50% chance of rolling 0, then uniform across 1/2/3.

Defined in rng.py:6.

Defensive Markers

MOUNTAIN, TROLL_LAIR, FORTRESS, ENCAMPMENT -- each adds +1 to conquest cost.

Defined in enums.py:36.

Map Generation Parameters

Parameter Value Location
Lost tribe fraction 40% map_generator.py:34
Max generation attempts 100 map_generator.py:35
Min border regions 4 map_generator.py:36
Feature targets 15% of land, 2-5 each map_generator.py:27

About

smallworld game, smallworld agent harness, game benchmark

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors