# 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 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.