44 KiB
Naval Roguelite — Architecture Design
0. Architecture Philosophy
This system separates pure computation from effectful operations. The core engine is a pure function: given the same state, action, and seed, it always produces the same output. IO, UI, and randomness live at the boundaries.
Core Principle: GameState + Action + Seed → NewGameState + EventLog
All game logic is deterministic, testable, and engine-agnostic. Frontends are thin viewers that translate events into visuals and user input into actions.
1. Module Structure
1.1 Directory Layout
fleet_builder/
├── core/ # Pure functional core (no IO, no side effects)
│ ├── __init__.py
│ ├── state.py # GameState and all state models
│ ├── ship.py # Ship construction and queries
│ ├── fleet.py # Fleet composition and chemistry
│ ├── tags.py # Tag derivation and queries
│ ├── combat/ # Combat resolution subsystem
│ │ ├── __init__.py
│ │ ├── resolver.py # Main combat resolver
│ │ ├── phases.py # Phase definitions and execution
│ │ ├── targeting.py # Target selection logic
│ │ ├── damage.py # Damage calculation
│ │ └── events.py # Event emission
│ ├── actions.py # All possible actions
│ ├── rules.py # Core rules and constraints
│ └── rng.py # Deterministic RNG wrapper
│
├── data/ # Data definitions (JSON/YAML)
│ ├── hulls/
│ │ ├── submarine.json
│ │ ├── destroyer.json
│ │ ├── cruiser.json
│ │ └── battleship.json
│ ├── mods/
│ │ ├── aircraft_hangar.json
│ │ ├── flight_deck.json # maybe redundant in poc with hangar but here as example anyways
│ │ └── reinforced_armor.json
│ ├── equipment/
│ │ ├── main_guns.json
│ │ ├── torpedoes.json
│ │ ├── attack_planes.json
│ │ └── radar.json
│ ├── tags/
│ │ └── derivation_rules.json
│ ├── encounters/
│ │ └── enemy_fleets.json
│ └── schema/ # JSON schemas for validation
│ ├── hull_schema.json
│ ├── mod_schema.json
│ └── equipment_schema.json
│
├── engine/ # Game loop and state transitions
│ ├── __init__.py
│ ├── game.py # Main game loop (pure)
│ ├── map.py # Map generation and navigation
│ ├── nodes.py # Node type handlers
│ └── progression.py # Run and meta-progression
│
├── loaders/ # IO boundary: data loading
│ ├── __init__.py
│ ├── loader.py # Main data loader
│ ├── validator.py # Schema validation
│ └── registry.py # Runtime lookup tables
│
├── adapters/ # IO boundary: UI integration
│ ├── __init__.py
│ ├── cli.py # CLI interface
│ ├── events_to_text.py # Event log → human text
│ └── serialization.py # GameState ↔ JSON
│
├── tests/ # Test suite
│ ├── unit/ # Unit tests per module
│ ├── integration/ # Combat + state integration
│ ├── golden/ # Golden tests with fixed seeds
│ └── fixtures/ # Test data and builders
│
└── scripts/ # Development utilities
├── run_cli.py # Entry point for CLI
├── validate_data.py # Validate all JSON files
└── seed_explorer.py # Tool for testing seeds
1.2 Dependency Flow
adapters/ ──→ engine/ ──→ core/
loaders/ ──→ data/ ──→ (runtime registry)
scripts/ ──→ adapters/ + loaders/
All modules depend on core/
No module in core/ imports from engine/, loaders/, or adapters/
Rationale: The core must be pure and isolated. Engine orchestrates core functions. Loaders translate files into core data structures. Adapters translate between core and the outside world.
2. Core Data Models
2.1 GameState
@dataclass(frozen=True)
class GameState:
"""
Immutable snapshot of the entire game.
All state transitions produce a new GameState.
"""
run_id: str
player_fleet: Fleet
map_state: MapState
current_node: NodeId
meta_unlocks: frozenset[str]
rng_seed: int
turn_number: int
event_history: tuple[Event, ...] # Append-only log
def with_fleet(self, new_fleet: Fleet) -> 'GameState':
"""Return new state with updated fleet."""
return replace(self, player_fleet=new_fleet)
Rationale: Immutability enables time travel, undo, replay, and deterministic testing. All mutations return new instances.
2.2 Ship
@dataclass(frozen=True)
class Ship:
"""
A single ship instance.
Identity = Hull + Mods + Equipment
"""
id: str
hull: HullTemplate
mods: tuple[Mod, ...] # Semi-permanent, ordered
equipment: tuple[Equipment, ...] # Swappable, ordered
current_hp: int
status_effects: frozenset[str] # TODO: Define StatusEffect after gameplay design
@property
def tags(self) -> frozenset[Tag]:
"""Derive tags from current state. No caching in PoC."""
return derive_tags(self)
@property
def stats(self) -> Stats:
"""Aggregate stats from hull + mods + equipment. No caching in PoC."""
return compute_stats(self)
def with_equipment(self, new_equipment: tuple[Equipment, ...]) -> 'Ship':
"""Return new ship with equipment replaced."""
return replace(self, equipment=new_equipment)
Rationale: Ship is the core entity. Tags and stats are derived on every access. Caching can be added later if profiling shows it's needed.
2.3 Fleet
@dataclass(frozen=True)
class Fleet:
"""
Collection of ships with derived chemistry.
"""
ships: tuple[Ship, ...]
formation: dict | None # TODO: Define Formation after gameplay design
@property
def active_ships(self) -> tuple[Ship, ...]:
"""Ships that can still act (HP > 0, not disabled)."""
return tuple(s for s in self.ships if s.current_hp > 0)
@property
def fleet_tags(self) -> frozenset[Tag]:
"""Union of all ship tags."""
return frozenset().union(*(s.tags for s in self.ships))
def get_chemistry_effects(self) -> tuple[dict, ...]:
"""
Compute fleet-level synergies based on tag composition.
Examples: SCREEN + CAPITAL, multiple AVIATOR, etc.
TODO: Define ChemistryEffect structure after gameplay design.
Placeholder returns dicts with 'type' and 'effect' keys.
"""
return derive_chemistry(self)
Rationale: Fleet is a container but also has emergent properties. Chemistry effects are computed, not stored, to maintain derivation principle.
2.4 Tags
@dataclass(frozen=True)
class Tag:
"""
A derived label that affects multiple systems.
Tags are never manually assigned.
"""
name: str
source: str # Derivation reason (e.g., "flight_deck_mod + carrier_hull")
# Tag rules are loaded from JSON data files, not hardcoded in Python.
# Example JSON format for tag derivation rules:
#
# {
# "tag": "AVIATOR",
# "conditions": [
# {
# "type": "equipment_has_property",
# "property": "grants_flight",
# "value": true,
# "quantifier": "any"
# },
# {
# "type": "stat_comparison",
# "stat": "deck_capacity",
# "operator": ">=",
# "value": 20
# }
# ],
# "logic": "AND"
# }
#
# This allows modders to add new tags via JSON without writing Python code.
@dataclass(frozen=True)
class TagDerivationRule:
"""
Rule for deriving a tag from ship state.
Loaded from JSON and compiled into a predicate function.
"""
tag_name: str
conditions: tuple[dict, ...] # Condition definitions from JSON
logic: str # "AND" or "OR"
def evaluate(self, ship: Ship) -> bool:
"""Evaluate all conditions against ship state."""
results = [evaluate_condition(ship, cond) for cond in self.conditions]
return all(results) if self.logic == "AND" else any(results)
def evaluate_condition(ship: Ship, condition: dict) -> bool:
"""
Evaluate a single condition from JSON rule definition.
Interpreter for the tag rule mini-language.
"""
cond_type = condition["type"]
if cond_type == "stat_comparison":
stat_value = getattr(ship.stats, condition["stat"])
return compare(stat_value, condition["operator"], condition["value"])
elif cond_type == "equipment_has_property":
prop = condition["property"]
value = condition["value"]
quantifier = condition.get("quantifier", "any")
matches = [getattr(eq, prop) == value for eq in ship.equipment]
return any(matches) if quantifier == "any" else all(matches)
elif cond_type == "hull_type":
return ship.hull.type == condition["value"]
# ... more condition types as needed
return False
def derive_tags(ship: Ship) -> frozenset[Tag]:
"""Apply all derivation rules to a ship."""
# TAG_RULES loaded from data/tags/derivation_rules.json at startup
return frozenset(
Tag(name=rule.tag_name, source=f"rule:{rule.tag_name}")
for rule in TAG_RULES
if rule.evaluate(ship)
)
Rationale: Tags are computed from observable state. Rules are data-driven and testable. Adding a tag requires adding a rule, ensuring intentional design.
2.5 Equipment and Mods
@dataclass(frozen=True)
class Equipment:
"""
Swappable tactical loadout.
"""
id: str
name: str
slot_type: str # "WEAPON", "SENSOR", "DEFENSE"
stat_modifiers: Stats
grants_flight: bool
triggers: tuple[Trigger, ...] # Combat phase triggers
@dataclass(frozen=True)
class Mod:
"""
Semi-permanent structural alteration.
"""
id: str
name: str
stat_modifiers: Stats
adds_capacity: int
restrictions: tuple[str, ...] # Hull types this can attach to
@dataclass(frozen=True)
class Stats:
"""
Aggregated numeric attributes.
"""
hp: int = 0
speed: int = 0
armor: int = 0
firepower: int = 0
accuracy: int = 0
evasion: int = 0
deck_capacity: int = 0
torpedo_tubes: int = 0
def __add__(self, other: 'Stats') -> 'Stats':
"""Sum all stats for aggregation."""
return Stats(
hp=self.hp + other.hp,
speed=self.speed + other.speed,
armor=self.armor + other.armor,
firepower=self.firepower + other.firepower,
accuracy=self.accuracy + other.accuracy,
evasion=self.evasion + other.evasion,
deck_capacity=self.deck_capacity + other.deck_capacity,
torpedo_tubes=self.torpedo_tubes + other.torpedo_tubes,
)
Rationale: Stats are additive. Equipment and Mods are immutable templates loaded from data. Ship stats = Hull stats + sum(Mod stats) + sum(Equipment stats).
2.6 HullTemplate
@dataclass(frozen=True)
class HullTemplate:
"""
Immutable base ship blueprint.
"""
id: str
name: str
type: str # "DESTROYER", "CRUISER", "BATTLESHIP", "CARRIER"
base_stats: Stats
mod_slots: int
equipment_slots: int
3. Combat Resolution Architecture
3.1 Combat Entry Point
def resolve_combat(
player_fleet: Fleet,
enemy_fleet: Fleet,
seed: int
) -> tuple[CombatResult, tuple[Event, ...]]:
"""
Pure function: fleets + seed → outcome + event log.
Returns:
CombatResult: Final state (victory, defeat, or retreat)
Event log: Structured history of what happened
"""
rng = SeededRNG(seed)
combat_state = CombatState(
player_fleet=player_fleet,
enemy_fleet=enemy_fleet,
turn=0,
events=()
)
while not is_combat_over(combat_state):
combat_state = execute_combat_turn(combat_state, rng)
return determine_result(combat_state), combat_state.events
Rationale: Combat is a pure function. All randomness is seeded. State transitions are explicit. Events are emitted as the log, not side effects.
3.2 Combat Phases
@dataclass(frozen=True)
class CombatState:
"""Snapshot of combat at a specific turn."""
player_fleet: Fleet
enemy_fleet: Fleet
turn: int
events: tuple[Event, ...]
def emit(self, event: Event) -> 'CombatState':
"""Append event to log and return new state."""
return replace(self, events=self.events + (event,))
# Phase execution order
PHASE_ORDER = (
"DETECTION",
"AIR_OPERATIONS",
"SURFACE_GUNNERY",
"TORPEDO_ATTACKS",
"DAMAGE_CONTROL",
)
def execute_combat_turn(state: CombatState, rng: RNG) -> CombatState:
"""Execute all phases in order."""
state = state.emit(Event(type="TURN_START", turn=state.turn))
for phase_name in PHASE_ORDER:
state = state.emit(Event(type="PHASE_START", phase=phase_name))
state = execute_phase(phase_name, state, rng)
state = state.emit(Event(type="PHASE_END", phase=phase_name))
state = state.emit(Event(type="TURN_END", turn=state.turn))
return replace(state, turn=state.turn + 1)
Rationale: Phases are executed sequentially. Each phase queries tags, triggers equipment, and updates state. All actions emit events for UI rendering.
3.3 Phase Handlers
# Example: Surface Gunnery Phase
def execute_phase(phase_name: str, state: CombatState, rng: RNG) -> CombatState:
"""Dispatch to phase-specific handler."""
handlers = {
"DETECTION": execute_detection_phase,
"AIR_OPERATIONS": execute_air_phase,
"SURFACE_GUNNERY": execute_gunnery_phase,
"TORPEDO_ATTACKS": execute_torpedo_phase,
"DAMAGE_CONTROL": execute_damage_control_phase,
}
return handlers[phase_name](state, rng)
def execute_gunnery_phase(state: CombatState, rng: RNG) -> CombatState:
"""
Each ship with ARTILLERY or firepower > threshold attacks.
"""
# Query attackers
attackers = [
s for s in state.player_fleet.active_ships
if can_attack_in_phase(s, "SURFACE_GUNNERY")
]
for attacker in attackers:
# Select target
target = select_target(attacker, state.enemy_fleet, rng)
if target is None:
continue
# Resolve attack
hit = resolve_hit(attacker, target, rng)
damage = calculate_damage(attacker, target, hit, "GUNNERY")
# Emit event
state = state.emit(Event(
type="ATTACK",
attacker_id=attacker.id,
target_id=target.id,
weapon_type="GUNNERY",
hit=hit,
damage=damage,
))
# Apply damage
if damage > 0:
state = apply_damage(state, target.id, damage)
return state
def can_attack_in_phase(ship: Ship, phase: str) -> bool:
"""Check if ship can act in this phase."""
if phase == "SURFACE_GUNNERY":
return ship.stats.firepower > 0 or Tag("ARTILLERY", "") in ship.tags
elif phase == "TORPEDO_ATTACKS":
return ship.stats.torpedo_tubes > 0 or Tag("TORPEDO_SPECIALIST", "") in ship.tags
elif phase == "AIR_OPERATIONS":
return Tag("AVIATOR", "") in ship.tags
return False
Rationale: Each phase is a pure function. Queries use tags and stats. Randomness is explicit via RNG. State updates are immutable.
3.4 Event System
STATUS: PROVISIONAL - Awaiting gameplay design finalization
Current implementation uses a single Event dataclass with string discriminator and optional fields. This will be replaced with a proper typed event hierarchy (tagged union) once combat events are finalized.
@dataclass(frozen=True)
class Event:
"""
Structured log entry for combat or game events.
UI renders these; core never prints.
TODO: Replace with typed event hierarchy after gameplay lock.
Planned approach: Union of specific event types (AttackEvent, PhaseStartEvent, etc.)
"""
type: str # "ATTACK", "SHIP_DISABLED", "TAG_GAINED", etc.
turn: int | None = None
phase: str | None = None
attacker_id: str | None = None
target_id: str | None = None
damage: int | None = None
hit: bool | None = None
weapon_type: str | None = None
# ... extensible fields
# Event types (example subset)
EVENT_TYPES = frozenset([
"TURN_START", "TURN_END",
"PHASE_START", "PHASE_END",
"ATTACK", "DAMAGE", "MISS",
"SHIP_DISABLED", "SHIP_DESTROYED",
"TAG_GAINED", "TAG_LOST",
"CHEMISTRY_TRIGGERED",
])
Rationale: Events are data, not side effects. They form a complete audit trail. UI translates events into visuals. Testing validates event sequences. Current stringly-typed approach is acceptable for PoC but should be replaced with proper types for production.
3.5 Targeting
def select_target(
attacker: Ship,
enemy_fleet: Fleet,
rng: RNG
) -> Ship | None:
"""
Select a valid target based on tags, chemistry, and randomness.
"""
candidates = enemy_fleet.active_ships
if not candidates:
return None
# Check for forced targeting (e.g., screen ships absorb torpedo hits)
chemistry = enemy_fleet.get_chemistry_effects()
forced_target = apply_forced_targeting(attacker, chemistry, candidates)
if forced_target:
return forced_target
# Weight by ship role and current HP
weights = [compute_target_weight(attacker, target) for target in candidates]
return rng.weighted_choice(candidates, weights)
def compute_target_weight(attacker: Ship, target: Ship) -> float:
"""
Compute targeting preference.
Example: ARTILLERY prefers CAPITAL ships.
"""
weight = 1.0
if Tag("ARTILLERY", "") in attacker.tags and Tag("CAPITAL", "") in target.tags:
weight *= 2.0
if target.current_hp < target.stats.hp * 0.3:
weight *= 1.5 # Prefer wounded targets
return weight
Rationale: Targeting is deterministic given RNG seed. Chemistry effects can force targets (e.g., screens protect capitals). Weights are derived from tags.
3.6 Damage Calculation
def calculate_damage(
attacker: Ship,
target: Ship,
hit: bool,
weapon_type: str
) -> int:
"""
Compute damage dealt.
"""
if not hit:
return 0
base_damage = get_weapon_damage(attacker, weapon_type)
armor_reduction = target.stats.armor * 0.5
final_damage = max(1, int(base_damage - armor_reduction))
# Critical hit check (could use tags here)
# if Tag("CRITICAL_SPECIALIST", "") in attacker.tags:
# final_damage *= 2
return final_damage
def resolve_hit(attacker: Ship, target: Ship, rng: RNG) -> bool:
"""
Determine if attack hits.
"""
hit_chance = base_hit_chance(attacker.stats.accuracy, target.stats.evasion)
return rng.roll(hit_chance)
def base_hit_chance(accuracy: int, evasion: int) -> float:
"""
Accuracy vs evasion formula.
Example: 70% base, +2% per accuracy, -2% per evasion.
"""
return max(0.1, min(0.95, 0.7 + (accuracy - evasion) * 0.02))
Rationale: Damage is computed from stats and armor. Hit chance is a formula. RNG is explicit. No hidden rolls.
4. Data Loading and Validation
4.1 Data File Structure
// data/hulls/destroyer.json
{
"id": "hull_destroyer_basic",
"name": "Fletcher-class Destroyer",
"type": "DESTROYER",
"base_stats": {
"hp": 100,
"speed": 35,
"armor": 10,
"firepower": 40,
"accuracy": 60,
"evasion": 70,
"deck_capacity": 0,
"torpedo_tubes": 10
},
"mod_slots": 1,
"equipment_slots": 3
}
// data/equipment/main_guns.json
{
"id": "eq_5inch_guns",
"name": "5-inch/38 Dual Purpose Guns",
"slot_type": "WEAPON",
"stat_modifiers": {
"firepower": 20,
"accuracy": 10
},
"grants_flight": false,
"triggers": [
{
"phase": "SURFACE_GUNNERY",
"effect": "standard_attack"
}
]
}
4.2 Loader Architecture
# loaders/loader.py
class DataLoader:
"""
IO boundary: loads files and returns pure data structures.
"""
def __init__(self, data_dir: Path):
self.data_dir = data_dir
self.validator = DataValidator()
def load_all(self) -> GameData:
"""Load and validate all game data."""
return GameData(
hulls=self._load_hulls(),
mods=self._load_mods(),
equipment=self._load_equipment(),
tag_rules=self._load_tag_rules(),
encounters=self._load_encounters(),
)
def _load_hulls(self) -> dict[str, HullTemplate]:
"""Load all hull definitions."""
hulls = {}
for file_path in (self.data_dir / "hulls").glob("*.json"):
data = json.loads(file_path.read_text())
self.validator.validate(data, "hull_schema.json")
hulls[data["id"]] = parse_hull(data)
return hulls
@dataclass(frozen=True)
class GameData:
"""
Immutable registry of all loaded game content.
Created once at startup, never modified.
"""
hulls: dict[str, HullTemplate]
mods: dict[str, Mod]
equipment: dict[str, Equipment]
tag_rules: tuple[TagDerivationRule, ...]
encounters: dict[str, EncounterTemplate]
Rationale: Loader is the IO boundary. It validates data at load time, then returns immutable structures. Core never touches files.
4.3 Validation
# loaders/validator.py
import jsonschema
class DataValidator:
"""Validates data files against JSON schemas."""
def __init__(self, schema_dir: Path):
self.schemas = self._load_schemas(schema_dir)
def validate(self, data: dict, schema_name: str) -> None:
"""Raise exception if data doesn't match schema."""
schema = self.schemas[schema_name]
jsonschema.validate(instance=data, schema=schema)
Rationale: Validation happens at load time. Invalid data fails fast. Schemas are versioned with data.
5. Integration Points for UI/Frontends
5.1 Adapter Pattern
# adapters/cli.py
class CLIAdapter:
"""
Translates between CLI I/O and core game logic.
"""
def __init__(self, game_data: GameData):
self.game_data = game_data
self.state: GameState | None = None
def start_new_run(self) -> None:
"""Initialize a new game state."""
# Present fleet selection
fleet = self._prompt_fleet_selection()
# Create initial state
self.state = create_initial_state(fleet, game_data=self.game_data)
# Start game loop
self.run_game_loop()
def run_game_loop(self) -> None:
"""Main CLI loop."""
while not is_run_over(self.state):
self._render_state()
action = self._prompt_action()
self.state, events = apply_action(self.state, action, self.game_data)
self._render_events(events)
def _render_state(self) -> None:
"""Print current game state."""
print(format_game_state(self.state))
def _render_events(self, events: tuple[Event, ...]) -> None:
"""Print event log as human-readable text."""
for event in events:
print(format_event(event))
Rationale: Adapter owns IO. Core is unaware of CLI. Different adapters (web UI, game engine) follow same pattern.
5.2 Event-to-Text Rendering
# adapters/events_to_text.py
def format_event(event: Event) -> str:
"""Convert event to human-readable text."""
formatters = {
"ATTACK": format_attack_event,
"SHIP_DISABLED": format_ship_disabled_event,
"PHASE_START": format_phase_start_event,
}
formatter = formatters.get(event.type, format_generic_event)
return formatter(event)
def format_attack_event(event: Event) -> str:
"""Format: 'USS Fletcher fires at enemy cruiser (HIT, 45 damage)'"""
hit_str = "HIT" if event.hit else "MISS"
damage_str = f"{event.damage} damage" if event.hit else ""
return f"{event.attacker_id} fires at {event.target_id} ({hit_str}, {damage_str})"
Rationale: Rendering is separate from logic. Events are structured data. Different UIs can render differently.
5.3 Serialization
# adapters/serialization.py
def serialize_game_state(state: GameState) -> str:
"""Convert GameState to JSON for saving."""
return json.dumps(dataclasses.asdict(state), indent=2)
def deserialize_game_state(json_str: str, game_data: GameData) -> GameState:
"""Reconstruct GameState from JSON."""
data = json.loads(json_str)
return parse_game_state(data, game_data)
Rationale: State is serializable. Enables save/load, replay, and debugging. Core doesn't know about serialization.
6. Testing Architecture
6.1 Test Structure
# tests/unit/test_tags.py
def test_aviator_tag_derived_from_flight_deck():
"""AVIATOR tag should be present when flight equipment + capacity."""
hull = HullTemplate(id="test", type="CARRIER", base_stats=Stats(deck_capacity=30), ...)
mod = Mod(id="flight_deck", adds_capacity=10, ...)
equipment = Equipment(id="aircraft", grants_flight=True, ...)
ship = Ship(id="test_ship", hull=hull, mods=(mod,), equipment=(equipment,))
assert Tag("AVIATOR", ...) in ship.tags
# tests/integration/test_combat.py
def test_screen_absorbs_torpedo():
"""Screen ships should absorb first torpedo hit to capital."""
player_fleet = Fleet(ships=(
create_destroyer(tag=Tag("SCREEN", "")),
create_battleship(tag=Tag("CAPITAL", "")),
))
enemy_fleet = Fleet(ships=(create_submarine(),))
result, events = resolve_combat(player_fleet, enemy_fleet, seed=12345)
# Find torpedo attack event
torpedo_events = [e for e in events if e.type == "ATTACK" and e.weapon_type == "TORPEDO"]
assert torpedo_events[0].target_id == "destroyer_id" # Screen took hit
# tests/golden/test_determinism.py
def test_same_seed_same_outcome():
"""Same fleets and seed must produce identical results."""
player_fleet = create_test_fleet()
enemy_fleet = create_test_enemy_fleet()
seed = 42
result1, events1 = resolve_combat(player_fleet, enemy_fleet, seed)
result2, events2 = resolve_combat(player_fleet, enemy_fleet, seed)
assert result1 == result2
assert events1 == events2
6.2 Test Fixtures
# tests/fixtures/builders.py
def create_test_destroyer() -> Ship:
"""Builder for test destroyer with sensible defaults."""
hull = HullTemplate(id="test_dd", type="DESTROYER", base_stats=Stats(hp=100, ...))
return Ship(id="dd_test", hull=hull, mods=(), equipment=())
def create_test_fleet(ship_count: int = 3) -> Fleet:
"""Builder for test fleet."""
ships = tuple(create_test_destroyer() for _ in range(ship_count))
return Fleet(ships=ships, formation=None)
Rationale: Fixtures reduce boilerplate. Builders enable easy test data creation. Golden tests catch regressions.
6.3 Golden Tests
# tests/golden/test_fleet_vs_fleet.py
def test_standard_fleet_composition():
"""
Golden test: 2 destroyers + 1 cruiser vs 3 destroyers.
Expected outcome recorded and validated.
"""
player = Fleet(ships=(
load_ship("destroyer_basic"),
load_ship("destroyer_basic"),
load_ship("cruiser_basic"),
))
enemy = Fleet(ships=(
load_ship("destroyer_basic"),
load_ship("destroyer_basic"),
load_ship("destroyer_basic"),
))
result, events = resolve_combat(player, enemy, seed=99999)
# Compare against recorded golden output
expected_events = load_golden_events("standard_fleet_composition.json")
assert events == expected_events
assert result == CombatResult.VICTORY
Rationale: Golden tests validate entire combat flows. Any change to combat logic requires updating goldens. Catches unintended changes.
7. RNG Architecture
7.1 Deterministic RNG Wrapper
# core/rng.py
import random
class SeededRNG:
"""
Deterministic random number generator.
Same seed always produces same sequence.
"""
def __init__(self, seed: int):
self._rng = random.Random(seed)
def roll(self, probability: float) -> bool:
"""Return True with given probability (0.0 to 1.0)."""
return self._rng.random() < probability
def randint(self, a: int, b: int) -> int:
"""Return random integer in [a, b]."""
return self._rng.randint(a, b)
def choice(self, seq: tuple) -> Any:
"""Select random element from sequence."""
return self._rng.choice(seq)
def weighted_choice(self, seq: tuple, weights: list[float]) -> Any:
"""Select element with weighted probability."""
return self._rng.choices(seq, weights=weights, k=1)[0]
def shuffle(self, seq: tuple) -> tuple:
"""Return shuffled copy of sequence."""
lst = list(seq)
self._rng.shuffle(lst)
return tuple(lst)
Rationale: All randomness goes through this wrapper. Seed is explicit. Testing uses fixed seeds. Replay is trivial.
7.2 RNG Threading
def apply_action(state: GameState, action: Action, game_data: GameData) -> tuple[GameState, tuple[Event, ...]]:
"""
Process an action and return new state + events.
RNG seed is derived from current state.
"""
# Derive new seed from state (ensures determinism)
# Action may contain unhashable fields (dicts), so serialize it first
import json
action_hash = hash(json.dumps(dataclasses.asdict(action), sort_keys=True))
new_seed = hash((state.rng_seed, state.turn_number, action_hash))
rng = SeededRNG(new_seed)
# Dispatch action
if action.type == "START_COMBAT":
result, events = resolve_combat(state.player_fleet, action.enemy_fleet, new_seed)
new_state = state.with_combat_result(result)
elif action.type == "EQUIP_ITEM":
new_state = equip_item(state, action.ship_id, action.equipment_id)
events = (Event(type="ITEM_EQUIPPED", ship_id=action.ship_id),)
# ... more actions
return replace(new_state, rng_seed=new_seed), events
Rationale: Each action derives a new seed from current state + action. Ensures different branches have different outcomes but same path is deterministic. Action is serialized to JSON for stable hashing since it may contain unhashable dict fields.
8. Map and Progression
8.1 Map Structure
@dataclass(frozen=True)
class MapState:
"""
Represents the current run's map structure and progress.
"""
nodes: dict[NodeId, Node]
edges: tuple[Edge, ...]
current_node_id: NodeId
visited_nodes: frozenset[NodeId]
@dataclass(frozen=True)
class Node:
id: NodeId
type: str # "COMBAT", "ELITE", "BOSS", "REFIT", "EVENT", "SALVAGE"
data: dict # Node-specific data (e.g., enemy fleet for combat)
@dataclass(frozen=True)
class Edge:
from_node: NodeId
to_node: NodeId
8.2 Node Handlers
# engine/nodes.py
def handle_node(state: GameState, game_data: GameData) -> tuple[GameState, tuple[Event, ...]]:
"""
Execute current node and return updated state.
"""
node = state.map_state.nodes[state.current_node]
handlers = {
"COMBAT": handle_combat_node,
"REFIT": handle_refit_node,
"EVENT": handle_event_node,
"BOSS": handle_boss_node,
}
return handlers[node.type](state, node, game_data)
def handle_combat_node(state: GameState, node: Node, game_data: GameData) -> tuple[GameState, tuple[Event, ...]]:
"""Combat node: resolve battle, update fleet."""
enemy_fleet = load_encounter(node.data["encounter_id"], game_data)
result, events = resolve_combat(state.player_fleet, enemy_fleet, state.rng_seed)
if result == CombatResult.DEFEAT:
return state.with_run_ended(), events
# Apply rewards
new_state = apply_combat_rewards(state, result)
return new_state, events
def handle_refit_node(state: GameState, node: Node, game_data: GameData) -> tuple[GameState, tuple[Event, ...]]:
"""Refit node: player chooses equipment swaps or mods."""
# This is where player makes choices (via Action)
# Node handler prepares options, waits for action
available_equipment = node.data["equipment_pool"]
events = (Event(type="REFIT_AVAILABLE", options=available_equipment),)
return state, events
Rationale: Each node type has a handler. Handlers are pure functions. Player choices come via actions.
9. Action System
9.1 Action Definitions
@dataclass(frozen=True)
class Action:
"""
Represents a player decision or game event trigger.
"""
type: str
# Action-specific fields
ship_id: str | None = None
equipment_id: str | None = None
target_node_id: NodeId | None = None
choices: dict | None = None
# Example actions:
# Action(type="MOVE_TO_NODE", target_node_id="node_5")
# Action(type="EQUIP_ITEM", ship_id="ship_1", equipment_id="eq_radar")
# Action(type="START_COMBAT")
# Action(type="SELECT_EVENT_CHOICE", choices={"option": "A"})
9.2 Action Validation
def validate_action(state: GameState, action: Action, game_data: GameData) -> bool:
"""
Check if action is legal in current state.
"""
validators = {
"MOVE_TO_NODE": validate_move_action,
"EQUIP_ITEM": validate_equip_action,
}
validator = validators.get(action.type)
if validator is None:
return False
return validator(state, action, game_data)
def validate_equip_action(state: GameState, action: Action, game_data: GameData) -> bool:
"""Check if equipment swap is legal."""
ship = find_ship(state.player_fleet, action.ship_id)
if ship is None:
return False
equipment = game_data.equipment.get(action.equipment_id)
if equipment is None:
return False
# Check if ship has capacity for this equipment
return len(ship.equipment) < ship.hull.equipment_slots
Rationale: Actions are data. Validation is a pure function. Invalid actions are rejected before state transition.
9.3 Error Handling with Result Pattern
All state transitions use the Result pattern for explicit error handling:
from typing import TypeVar, Generic
from dataclasses import dataclass
T = TypeVar('T')
E = TypeVar('E')
@dataclass(frozen=True)
class Ok(Generic[T]):
"""Successful result containing a value."""
value: T
def is_ok(self) -> bool:
return True
def is_err(self) -> bool:
return False
@dataclass(frozen=True)
class Err(Generic[E]):
"""Error result containing an error value."""
error: E
def is_ok(self) -> bool:
return False
def is_err(self) -> bool:
return True
Result = Ok[T] | Err[E]
Usage in action processing:
def apply_action(
state: GameState,
action: Action,
game_data: GameData
) -> Result[tuple[GameState, tuple[Event, ...]], str]:
"""
Process an action and return new state + events, or an error.
"""
# Validate action
if not validate_action(state, action, game_data):
return Err(f"Invalid action: {action.type}")
# Derive new seed and process
try:
action_hash = hash(json.dumps(dataclasses.asdict(action), sort_keys=True))
new_seed = hash((state.rng_seed, state.turn_number, action_hash))
# Dispatch action
if action.type == "START_COMBAT":
result, events = resolve_combat(state.player_fleet, action.enemy_fleet, new_seed)
new_state = state.with_combat_result(result)
elif action.type == "EQUIP_ITEM":
new_state = equip_item(state, action.ship_id, action.equipment_id)
events = (Event(type="ITEM_EQUIPPED", ship_id=action.ship_id),)
else:
return Err(f"Unknown action type: {action.type}")
return Ok((replace(new_state, rng_seed=new_seed), events))
except Exception as e:
return Err(f"Action processing failed: {str(e)}")
Benefits:
- Errors are values, not exceptions
- Callers must explicitly handle both success and error cases
- Type system ensures error handling is not forgotten
- No hidden control flow
Alternative: Use existing library like returns or result for more features (map, flatmap, etc.).
10. Summary of Architectural Boundaries
10.1 The Pure Core
Location: core/, engine/
Rules:
- No
import os,import sys,print(),input(),open() - All functions are deterministic (same input → same output)
- No globals, no mutable class variables
- All data structures are immutable (frozen dataclasses, tuples, frozensets)
Purpose: Game logic is testable, replayable, and portable.
10.2 The IO Boundary
Location: loaders/, adapters/
Rules:
- These modules may perform IO (read files, print to console)
- They translate between external formats and core data structures
- They call core functions but core never calls them
Purpose: Isolate side effects. Core remains pure.
10.3 Dependency Invariants
adapters → engine → core
loaders → core
tests → all
core → NOTHING (except stdlib)
Enforcement: CI checks imports. Core modules that import from adapters or loaders fail the build.
11. Key Design Decisions
11.1 Why Immutability?
Immutable state enables:
- Time travel debugging (any past state is preserved)
- Trivial undo/redo
- Deterministic replay (just replay actions from initial state)
- Fearless parallelization (no shared mutable state)
- Golden tests (serialize state, compare)
11.2 Why Event Logs Instead of Callbacks?
Callbacks couple core to UI. Event logs:
- Are data, not code
- Can be serialized, filtered, replayed
- Enable multiple simultaneous UI views (CLI, GUI, replay viewer)
- Support analytics and debugging
11.3 Why Tags Are Derived?
Manual tagging leads to:
- Desync bugs (forgot to add tag when equipping item)
- Inconsistency (same state, different tags)
- Testing difficulty (must mock tag state)
Derived tags:
- Always reflect current state
- Are testable (just check derivation rules)
- Eliminate entire class of bugs
11.4 Why Pure Functions?
Side effects make testing hard. Pure functions:
- Are deterministic (same input → same output)
- Are testable (no mocking, no setup/teardown)
- Are composable (output of f is input to g)
- Enable parallelization and caching
11.5 Why CLI First?
CLI forces architectural discipline:
- Can't hide complexity in fancy UI
- Must expose clean APIs
- Ensures data-driven design
- Easy to test and script
Game engines come later as thin viewers. If we start with Unity/Godot, rules leak into the engine and become untestable.
12. Open Questions and Future Extensions
12.1 Save System
Save files are just serialized GameState + GameData version. Load validates version compatibility.
12.2 Replay System
Replay = initial state + sequence of actions + seeds. Replay viewer is just another adapter that steps through actions.
12.3 Modding
Mods are additional data files in data/mods/. Loader merges them with base data. No code mods (data-driven only).
12.4 Multiplayer (PvP)
Future extension: two players' fleets as input. Combat resolver is already symmetric. Add action synchronization layer.
12.5 Performance
Profiling will reveal bottlenecks. Likely optimizations:
- Aggressive caching of derived tags/stats
- Compile tag rules to bytecode
- Use numpy for batch damage calculations
Current architecture doesn't prevent optimization. Pure functions are easier to optimize (no hidden state).
13. Migration Plan from Scratch
Phase 1: Core Data Models (Week 1)
- Implement
core/state.py(GameState, Ship, Fleet) - Implement
core/tags.py(Tag, TagDerivationRule, derive_tags) - Write unit tests for tag derivation
- Implement
core/rng.py(SeededRNG)
Phase 2: Data Loading (Week 1-2)
- Define JSON schemas for hulls, equipment, mods
- Implement
loaders/loader.pyandloaders/validator.py - Create sample data files (3 hulls, 8 equipment, 5 tags)
- Write tests that load and validate data
Phase 3: Combat Core (Week 2-3)
- Implement
core/combat/resolver.py(resolve_combat entry point) - Implement
core/combat/phases.py(phase execution) - Implement
core/combat/events.py(event emission) - Write golden tests for simple 1v1 combat
Phase 4: CLI Adapter (Week 3)
- Implement
adapters/cli.py(game loop) - Implement
adapters/events_to_text.py(event rendering) - Manual playtesting with CLI
Phase 5: Map and Progression (Week 4)
- Implement
engine/map.py(map generation) - Implement
engine/nodes.py(node handlers) - Implement
core/actions.py(action system) - End-to-end run through CLI
Phase 6: Polish and Testing (Week 5)
- Add remaining equipment and enemies
- Balance testing
- Golden test suite for full runs
- Documentation and examples
14. Success Criteria
Architecture is successful if:
- Determinism: Same inputs always produce same outputs
- Testability: >80% test coverage, golden tests pass
- Portability: Core has zero external dependencies
- Modularity: Can swap CLI for GUI without touching core
- Clarity: New developer can understand combat flow in <1 hour
- Data-Driven: Adding new hull/equipment requires zero code changes
15. Architecture Revisions
This section documents architectural corrections made to ensure implementation viability.
15.1 Caching Removed from Ship (Critical Fix)
Original Issue: Ship dataclass declared frozen=True with mutable cache fields that would never work.
Resolution: Removed caching entirely for PoC. Tags and stats are computed on every access. Caching can be added later if profiling shows performance issues.
15.2 Tag Rules Made Data-Driven (Critical Fix)
Original Issue: Tag derivation rules used Python lambdas, making them impossible to load from JSON and breaking the "data-driven" architecture promise.
Resolution: Tag rules now use JSON-based condition definitions with a rule interpreter. Modders can add new tags via JSON files without writing Python code.
Example JSON format:
{
"tag": "AVIATOR",
"conditions": [
{"type": "equipment_has_property", "property": "grants_flight", "value": true, "quantifier": "any"},
{"type": "stat_comparison", "stat": "deck_capacity", "operator": ">=", "value": 20}
],
"logic": "AND"
}
15.3 Event System Marked Provisional
Original Issue: Events use stringly-typed design with optional fields, causing potential runtime errors.
Resolution: Marked as provisional pending gameplay design finalization. Current approach is acceptable for PoC but documented for replacement with typed event hierarchy (tagged union) later.
15.4 Action Hashing Fixed
Original Issue: hash(action) fails because Action contains unhashable dict fields.
Resolution: Serialize Action to JSON before hashing: hash(json.dumps(dataclasses.asdict(action), sort_keys=True)).
15.5 Undefined Types Marked as TODO
Original Issue: Formation, StatusEffect, and ChemistryEffect referenced but never defined.
Resolution:
StatusEffect→frozenset[str]with TODO markerFormation→dict | Nonewith TODO markerChemistryEffect→tuple[dict, ...]with TODO marker
These will be properly defined after gameplay design settles.
15.6 Error Handling Strategy Added
Original Issue: No error handling approach specified.
Resolution: Added Result<T, E> pattern (section 9.3) for explicit error handling. All state transitions return Result[tuple[GameState, Events], str] instead of raising exceptions.
End of Architecture Document
This architecture separates pure computation from side effects, makes all state transitions explicit, and ensures the game is deterministic and testable. The core is a pure function. Everything else is translation between core and the outside world.
Document Status: Revised with critical fixes applied. Ready for implementation of PoC.