1436 lines
44 KiB
Markdown
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.
|