Files
fleet-builder-project/planning_docs/ARCHITECTURE.md
2026-01-10 16:39:42 +09:00

1436 lines
44 KiB
Markdown

# 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
```python
@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
```python
@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
```python
@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
```python
@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
```python
@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
```python
@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
```python
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
```python
@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
```python
# 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.
```python
@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
```python
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
```python
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
```json
// 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
}
```
```json
// 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
```python
# 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
```python
# 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
```python
# 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
```python
# 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
```python
# 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
```python
# 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
```python
# 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
```python
# 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
```python
# 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
```python
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
```python
@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
```python
# 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
```python
@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
```python
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:
```python
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:**
```python
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)
1. Implement `core/state.py` (GameState, Ship, Fleet)
2. Implement `core/tags.py` (Tag, TagDerivationRule, derive_tags)
3. Write unit tests for tag derivation
4. Implement `core/rng.py` (SeededRNG)
### Phase 2: Data Loading (Week 1-2)
1. Define JSON schemas for hulls, equipment, mods
2. Implement `loaders/loader.py` and `loaders/validator.py`
3. Create sample data files (3 hulls, 8 equipment, 5 tags)
4. Write tests that load and validate data
### Phase 3: Combat Core (Week 2-3)
1. Implement `core/combat/resolver.py` (resolve_combat entry point)
2. Implement `core/combat/phases.py` (phase execution)
3. Implement `core/combat/events.py` (event emission)
4. Write golden tests for simple 1v1 combat
### Phase 4: CLI Adapter (Week 3)
1. Implement `adapters/cli.py` (game loop)
2. Implement `adapters/events_to_text.py` (event rendering)
3. Manual playtesting with CLI
### Phase 5: Map and Progression (Week 4)
1. Implement `engine/map.py` (map generation)
2. Implement `engine/nodes.py` (node handlers)
3. Implement `core/actions.py` (action system)
4. End-to-end run through CLI
### Phase 6: Polish and Testing (Week 5)
1. Add remaining equipment and enemies
2. Balance testing
3. Golden test suite for full runs
4. Documentation and examples
---
## 14. Success Criteria
Architecture is successful if:
1. **Determinism**: Same inputs always produce same outputs
2. **Testability**: >80% test coverage, golden tests pass
3. **Portability**: Core has zero external dependencies
4. **Modularity**: Can swap CLI for GUI without touching core
5. **Clarity**: New developer can understand combat flow in <1 hour
6. **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:
```json
{
"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 marker
- `Formation``dict | None` with TODO marker
- `ChemistryEffect``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.