From 8635e4788aed605c89015771aee654c1864c2722 Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Sat, 10 Jan 2026 00:02:01 +0900 Subject: [PATCH] Design updates - gameplay draft - core architecture (Python core) --- .gitignore | 13 +- ARCHITECTURE.md | 1435 +++++++++++++++++++++++++++++++++++++++++++++ FILETREE.txt | 11 + GAMEPLAY.md | 703 ++++++++++++++++++++++ README.md | 5 + tools/filetree.py | 145 +++++ 6 files changed, 2311 insertions(+), 1 deletion(-) create mode 100644 ARCHITECTURE.md create mode 100644 FILETREE.txt create mode 100644 GAMEPLAY.md create mode 100644 README.md create mode 100644 tools/filetree.py diff --git a/.gitignore b/.gitignore index fc4305c..2ea5ae4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,14 @@ # claude artifacts .claude/ -CLAUDE.md \ No newline at end of file +CLAUDE.md + +# python artifacts +__pycache__/ +*.pyc +*.pyo +*.pyd + + +# uv +package-lock.json +package.json \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..aea8cf3 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,1435 @@ +# 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. diff --git a/FILETREE.txt b/FILETREE.txt new file mode 100644 index 0000000..cc19015 --- /dev/null +++ b/FILETREE.txt @@ -0,0 +1,11 @@ +. +|-- .gitignore +|-- ARCHITECTURE.md +|-- DESIGN.md +|-- FILETREE.txt +|-- GAMEPLAY.md +|-- README.md +|-- package-lock.json +|-- package.json +\-- tools + \-- filetree.py diff --git a/GAMEPLAY.md b/GAMEPLAY.md new file mode 100644 index 0000000..cd721ee --- /dev/null +++ b/GAMEPLAY.md @@ -0,0 +1,703 @@ +# Naval Composition Roguelite — Gameplay Specification + +**Purpose:** Translate DESIGN.md into concrete gameplay mechanics. Half-filled proposals, half-questionnaire. Fill in the `[DECIDE: ...]` sections. + +--- + +## 1. Run Start: Fleet Selection + +### What We Know +- Player starts with ~3 ships +- Meta-progression unlocks options, not power +- Choice should set strategic direction + +### Proposed Mechanic + +For the initial concept, player starts with a preset, relatively boring 'default deck' with: +- 1 orthodox destroyer +- 1 orthodox cruiser (the "scout cruiser" is just a cruiser for MVP - mechanically identical) +- 1 orthodox battleship + +Future extension: more presets; cost-based custom presets, etc. +But for now, the boring solution is the easiest to implement and will become un-boring if the roguelite part does its job. + +### Unlock Progression +Not important in initial version. +However, I personally hate grindy grinds, so unlocks should be rather easy, ceremonial, like a breeze; players should be able to fiddle with all possibilities as soon as possible. + +--- + +## 2. Map Structure + +### What We Know +- Branching paths (think Slay the Spire) +- ~50-60% combat, ~40-50% logistics/events +- Target runtime: 30 minutes + +### Math Check +- 30 min run = ~18-25 nodes total +- If 60% combat = ~12-15 fights +- Non-combat nodes = ~6-10 + +### Between Nodes +Player can change equipments on a ship (swap, substitute with inventory items, take off, etc) as long as not in battle or stat check events. +Player can also swap ships in/out of the 'sortie slots' of 3 (can have reserve ships), similarly as long as not in battle or stat check. + + +### Node Types + +#### Starting Node +'Home port'. +Just a placeholder for picking the starting deck. Does this and only this. +Might also do the whale stuff (of Slay the Spire) later but not now. + +#### Combat Node Types +**Tributes to KanColle here** (the fancier way of saying 'ripoff') +Several kinds of battle, not every comes in PoC/MVP/alpha alpha. +- **Normal** - surface-to-surface fights, enemy might have planes but will never have subs. +- **ASW** - submarine-only enemy fleet. +- **Aerial** - aviation-focussed fights. + +**For MVP, only normal nodes: surface combat nodes.** +Add more varieties later if ever. 3 is already a lot. + +**Enemy fleet size**: Always 1 to 3 ships. + +**Enemy composition reveal**: Enemy composition is NOT fully revealed until you lock in your sortie squad and commence battle. You commit to the fight blind (or partially blind). +- *Future extension*: More Recon power reveals more enemy info before combat. Tradeoff: Recon specialists are generally weaker in combat (could be compensated with synergies/chemistry). +- *For MVP*: Enemy count visible on map node (1/2/3 ships), but not their types or loadouts until battle starts. + +**Loot**: Your classic 'choose 1 equipment from 3'. To streamline things, no concept of 'cash' or 'universal resource'. + +**Battle termination - Tick System**: +Battles use a **tick-based system** rather than "turns" (which imply guaranteed action per turn). One action takes multiple ticks. +- **Scale reference**: The major battle phase (gunnery) should run ~100 ticks total, supporting ~2 large caliber salvos (~45 ticks per salvo). +- Small guns fire more quickly (fewer ticks per salvo), torpedoes slower, etc. +- Battle ends when: (1) one side is eliminated, OR (2) tick limit reached (exact limit TBD - probably 200-300 ticks total?) + +**Battle result grading**: Derived from enemy sunk ratios and damage delivered/received. Better battle result yields better loot quality/choice. +- **[TODO: ELABORATE VICTORY GRADE FUNCTION]** - Current thinking is too KanColle, will work as stub for now. S/A/B/C/D ranks based on performance. +- **Known vulnerability**: Losing spiral - weak fleet → bad results → bad loot → weaker fleet. May need catch-up mechanics (dockyards buff weak fleets, events offer risky power spikes). + +#### Elite Combat Node +'Elite' nodes are just combat nodes with 'better'/'stronger' enemies. (optimised compositions or cheating stats by equipments). + +#### Boss Node +- Fixed endpoint, no branching after +- Two patterns for PoC stage. Like one super battleship (force) and one super carrier (force). + With screening ships, but still under the 3-ship threshold. +- **Boss structure** + - 1 flagship which has a cheating hull (meaty stats by hull, like abnormally high HP/armour/base firepower, or more eq. slots) + - Sink flagship to win + - Other enemy ships are relatively normal + +#### Refit / Dockyard Node +Remember that the game has no 'money', so these nodes are: +- free, but some options are conditioned e.g. require specific stats/tags/traits/synergies... or has tradeoff +- mostly upgrading (improvement should always be positive at micro) + +**Possible services**, I'm just listing. Not every one should make into the PoC or final game. (if there ever is a final game): +- Repair hull damage +- Upgrade equipment (a random better one of same type, or stat boost) +- New equipment / hull +- Trade equipment (no type guarantee, but generally better) **[How to determine 'better'?]** +- Apply mod (on the spot, like 'enchant') *this is a strong one, should have some sort of cost* +- Remove any mod (positive or negative) +- Trade mods + +***Note***: overall number of visitable nodes are relatively low per map. I would like the player to have more freedom to customise, so each dockyard node might offer multiple (2~4?) charges of service. +Also that it might be more gameplay if each dockyard node is different and offer a unique set of services. But might as well offer all of them for the first version. + +#### Event Node +- Random event from pool +- Must force tradeoff (no pure upgrades) +- **[DECIDE: Examples?]** (Fill in 3-5 example events with choices and consequences) - Still thinking, might do this later + +Example template: +``` +EVENT: Salvage Drifting Hulk +Choice A: Strip for parts → Gain random equipment, -0~20% HP on one ship (represents overwork/risk) (but not deadly) +Choice B: Claim as prize → Gain damaged hull +Choice C: Ignore → Gain nothing, but also risk nothing +``` + +``` +EVENT: Storm +Choice A: Take plain damage (all the fleet incl. reserves lose 5% HP) +Choice B: (available if RADAR >= 2) Take less or no damage. # Not sure what RADAR could be, but it requires specific deckbuilding +``` + +**[DECIDE: Fill in 3-5 event examples like above]** - Still thinking, might do this later + +#### Salvage Node +Can already get hulls from the Refit/Dockyard node, so this is part of that + + +### Map Generation +- **Depth**: Depending on routes, roughly 8~10 nodes from start to boss (could fluctuate slightly). Boss always visible at end. +- **Width per row**: 2~5 paths per row. Includes 'skip' nodes (direct route jumping one row further) and intra-row lateral paths. Generation should guarantee total run length doesn't fluctuate too much (target 8-10 nodes traversed). +- **Path/node visibility**: + - **For MVP**: Fixed vision of **2 connected nodes** from current position. Boss is always visible (endpoint marker). + - *Future extension*: Recon stat/equipment/tags increase visibility range. More scouting = see farther ahead, but scout ships are weaker in combat (balanced via synergies). + +### Known Vulnerability +If map is always similar shape (e.g., always 8 rows, always ends at boss), players will plan perfectly. If too random, they'll feel helpless. You probably want consistency in structure, variance in content. + +--- + +## 3. Fleet Persistence & Loss + +### The Hard Question +**What happens when a ship is destroyed mid-run?** + +- **Ship is lost for the run** + - Equipments on sinking ships are lost as well + - but certain mechanisms can be used to recover equipments (e.g. planes can emergency rebase to another carrier if certain conditions are met) + - Player might or might not have ships in reserve. It's ok to activate reserve ships after the ship-losing battle. + - Also, revival mechanisms that can prevent that loss (e.g. emergency repair - just like KanColle) + - Still ripping off KanColle, ships above a certain health (not 'heavy damage') won't be sunk in battle but could become heavily damaged. This is a very good guardrail by KanColle in my opinion. + +### KanColle style heavy damage protection +Pseudocode here. +```python +# +def getShipDamageLevel(maxShipHp: int, currentShipHp: int): + if (currentShipHp <= (maxShipHp * 0.25)): + return HEAVY_DAMAGE + else: + return SAFE_DAMAGE + # Actual KanColle has medium damage (0.25 * maxShipHp < currentShipHp <= 0.5 * maxShipHp), light damage (0.5 * maxShipHp < currentShipHp <= 0.75 * maxShipHp), and slight damage (0.75 * maxShipHp 25% HP): **Cannot be sunk** this battle. Any killing blow is hard capped to leave ship at **1% HP minimum** (non-lethal). +- Protection is determined at **battle start**, not recalculated mid-fight. If you enter SAFE, all hits are capped for the entire battle, even if you drop below 25% mid-fight. + +**Implementation note:** "Non-lethal cap" means: if ship is at 30% HP and takes a hit that would deal 29%+ damage, reduce final damage to leave ship at 1% HP. Keeps it simple and dramatic. + +### Damage Persistence +**Does hull damage carry between fights?** +- YES: Adds attrition strategy, refit nodes become crucial + +**How much can you heal outside refit nodes?** +- Equipment/Tag-based healing (repair cranes, damage control, etc.), these check out at different stages. (e.g. DamCon can activate during or outside battle, but repair cranes can only repair outside battles) +- Dockyard nodes. Now thinking about it, dockyards should heal (repair) ships unconditionally on entrance. +- Event nodes. Requires some good storytelling but as a mechanism this works. + +### Reserve Ships System + +**Core Rules:** +- **Max fleet size**: 6 ships total (3 active sortie slots + 3 reserve slots) +- **Starting reserves**: 0 - you begin with exactly 3 ships, no reserves +- **Acquiring new hulls**: From dockyard/event nodes only +- **Hull acquisition philosophy**: Scarce and NOT immediately rewarding + +**The Balance:** +When you get a new hull (free at dockyards), it's **just a hull** - no equipment, no mods. It does nothing useful immediately. + +The strategic choice becomes: +- **Option A**: Invest resources (equipment, mods) into your existing 3 ships → stronger specialized fleet +- **Option B**: Distribute resources across more ships → flexibility, redundancy, but weaker individuals + +Getting a new hull is potential, not power. Equipment scarcity makes specialization vs diversification a real tradeoff. + +**Between-node management:** +- Can swap ships in/out of active sortie slots anytime between nodes (not during battle/stat-check events) +- Can swap equipment between any ships in fleet (active or reserve) +- Reserves can be used to replace losses or adapt to known enemy types (once you have them) + + + +--- + +## 4. Combat Resolution - The Actual Rules + +### Pre-Combat +**[DECIDE: Formation system]** +- Option A: **Simple stance** (Aggressive/Balanced/Defensive) + - Aggressive: +damage, -defense + - Defensive: -damage, +defense + - Balanced: No modifier + +- Option B: **Positioning** (Front/Mid/Back rows) + - Front ships targeted first + - Back ships safer but may not reach with short-range weapons + - Requires UI to show grid + +- Option C: **Named formations** (Line Ahead, Line Abreast, Echelon) + - Each has different rules (range mods, targeting priority, etc.) + - More flavor, more complexity + +**[DECIDE: Which formation system?]** + +### Phase-by-Phase Resolution + +Each phase needs: +1. **Initiative order** (who acts first) +2. **Targeting logic** (who shoots whom) +3. **Hit resolution** (to-hit calculation) +4. **Damage resolution** (how much damage) + +--- + +#### PHASE 1: Detection / Initiative + +**Purpose:** Determine turn order and spotting + +**[DECIDE: What happens here?]** +Proposed: +- Each ship rolls initiative = Base Speed + Equipment Modifier + d20 +- Order set for this combat +- Ships with SCOUT tag grant +1 initiative to fleet? +- Failed detection = -hit chance for first strike? + +**[DECIDE: Accept proposal or define differently?]** + +--- + +#### PHASE 2: Air Operations + +**Participants:** Ships with AVIATOR tag + +**[DECIDE: How do aircraft work?]** +- Option A: **Abstract strike value** + - Each AVIATOR ship contributes Air Power + - Total Air Power vs Enemy Air Power or AA rating + - Winner deals damage to enemy fleet (distributed how?) + +- Option B: **Plane-vs-plane then strike** + - If both sides have AVIATOR, planes fight planes (attrition?) + - Winner (or side with more) then strikes surface ships + +- Option C: **Direct bombing** + - Each carrier selects target + - AA defense reduces damage + - No air-to-air unless specific equipment + +**[DECIDE: Pick air combat model]** + +**[DECIDE: Can non-aviator ships defend against air?]** +- Only if they have AA equipment? +- All ships have base AA rating? + +--- + +#### PHASE 3: Surface Gunnery + +**Participants:** Ships with gun-type equipment (probably everyone) + +**Initiative:** Use order from Detection phase + +**Targeting:** +**[DECIDE: Who shoots whom?]** +- Option A: **Automatic priority** + - Target lowest HP enemy (focus fire) + - Or: Target enemy with most threat (highest damage?) + +- Option B: **Formation-based** + - Front-line ships forced to shoot at front-line enemies + - Back-line can only be targeted if front is dead + +- Option C: **Random distribution** + - Each ship picks random valid target + - SCREEN tag makes them more likely to be targeted? + +**[DECIDE: Pick targeting system]** + +**Hit Calculation:** +**[DECIDE: To-hit formula?]** + +Proposed: +``` +Hit Chance = Base Accuracy (from gun) + Attacker Bonuses - Target Evasion - Range Penalty +Roll d100, hit if <= Hit Chance +``` + +**[DECIDE: What are Attacker Bonuses and Target Evasion?]** +- Fire control equipment gives +accuracy? +- Target speed stat = evasion? +- ARTILLERY tag gives +X% hit? + +**Damage Calculation:** +**[DECIDE: Damage formula?]** + +Proposed: +``` +Damage = Weapon Damage × Penetration vs Armor Factor +Penetration Factor = if Pen >= Armor: 1.0, else: (Pen/Armor) × 0.7 +``` + +**[DECIDE: Accept or define differently?]** + +--- + +#### PHASE 4: Torpedo Attacks + +**Participants:** Ships with torpedo equipment or TORPEDO_SPECIALIST tag + +**Special Rule (from DESIGN.md):** SCREEN + CAPITAL synergy = screens absorb first torp hit + +**[DECIDE: How does this work mechanically?]** +Proposed: +- If player fleet has >=1 SCREEN and >=1 CAPITAL: + - First torpedo targeting a CAPITAL is redirected to a random SCREEN + - SCREEN takes damage or is destroyed +- If no SCREEN present: + - CAPITAL ships are vulnerable + +**[DECIDE: Accept or modify?]** + +**[DECIDE: Torpedo hit chance - same as gunnery or different?]** +- Torpedoes historically lower accuracy, higher damage +- Should they auto-hit but be counterable (SCREEN absorbs)? +- Or still require hit roll? + +--- + +#### PHASE 5: Damage Control / Morale + +**[DECIDE: What actually happens in this phase?]** + +Proposed options: +- **Damage Control:** Ships repair X% of damage taken this fight (incentivizes tanky builds?) +- **Morale Check:** If fleet lost >50% HP or >1 ship, roll morale check (pass = fight on, fail = retreat?) +- **Status Effects:** Fires, flooding, crew casualties affect next fight? +- **Nothing:** Phase exists for future expansion, does nothing in MVP + +**[DECIDE: Pick one or define new]** + +--- + +### Combat Outcome + +**If Player Wins:** +- **[DECIDE: Loot structure?]** + - Always 1 equipment piece (random from pool?) + - Choose 1 of 3 equipment options + - Scrap/currency + shop later? + +**If Player Loses:** +- **[DECIDE: Full run loss or ship loss?]** (see section 3) + +--- + +## 5. Ship Stats & Equipment - The Numbers + +### Hull Base Stats + +**[DECIDE: Exact stats for MVP's 3 hull types]** + +Template (fill in numbers): +``` +Destroyer: + HP: [DECIDE] + Speed: [DECIDE] (affects initiative) + Armor: [DECIDE] (reduces damage) + Evasion: [DECIDE] (reduces hit chance) + Equipment Slots: [DECIDE: 2? 3?] + Mod Slots: 1 (per design) + Starting Tags: [DECIDE: SCREEN? Other?] + +Cruiser: + HP: [DECIDE] + Speed: [DECIDE] + Armor: [DECIDE] + Evasion: [DECIDE] + Equipment Slots: [DECIDE] + Mod Slots: 1 + Starting Tags: [DECIDE: None? SCREEN? ARTILLERY?] + +Battleship: + HP: [DECIDE] + Speed: [DECIDE] + Armor: [DECIDE] + Evasion: [DECIDE] + Equipment Slots: [DECIDE] + Mod Slots: 1 + Starting Tags: [DECIDE: CAPITAL? ARTILLERY?] +``` + +### Equipment Pieces + +**[DECIDE: Define 8-10 equipment items for MVP]** + +Template (fill in 8-10 of these): +``` +[Equipment Name]: + Slot Type: [Main Gun / Torpedo / Aircraft / Utility] + Stats: [e.g., +20 damage, +15 penetration] + Tags Granted: [e.g., ARTILLERY if gun, AVIATOR if aircraft] + Special Effect: [e.g., "Fires twice per phase if target HP < 50%"] +``` + +Example: +``` +5-inch Dual Purpose Gun: + Slot Type: Main Gun + Damage: 15 + Penetration: 10 + Accuracy: 75 + Tags Granted: None + Special: Can engage aircraft (contributes +5 AA rating) +``` + +**[DECIDE: Fill in your 8-10 equipment items]** + +### Mods + +**[DECIDE: Define 5-6 mods for MVP]** + +Template: +``` +[Mod Name]: + Effect: [Mechanical change] + Tags Granted/Modified: [...] + Tradeoff: [What's the cost?] +``` + +Example from DESIGN.md: +``` +Flight Deck: + Effect: +1 Equipment Slot (must be aircraft), +20 HP cost (weight) + Tags Granted: Enables AVIATOR if aircraft equipped + Tradeoff: Reduces armor by 5 (structural weakness) +``` + +**[DECIDE: Fill in 5-6 mods]** + +--- + +## 6. Tag System - Derivation Rules + +### What We Know +- Tags are derived from equipment + mods + hull +- Tags must affect >=2 systems +- Tags used by combat phases and events + +### Required Tags for MVP (from DESIGN.md) +1. AVIATOR +2. SCREEN +3. CAPITAL +4. SCOUT +5. TORPEDO_SPECIALIST +6. ARTILLERY + +**[DECIDE: Define exact derivation rule for each tag]** + +Template: +``` +Tag: AVIATOR +Derived When: [Ship has aircraft equipment AND hull/mod provides flight capacity] +Affects: + - System 1: [e.g., Participates in Air Phase] + - System 2: [e.g., Events may reference "aviator ships"] +``` + +**[DECIDE: Fill in derivation + effects for all 6 tags]** + +Example: +``` +Tag: SCREEN +Derived When: Hull is Destroyer OR ship has "Anti-Torpedo Net" equipment +Affects: + - System 1: Can intercept torpedoes aimed at CAPITAL ships + - System 2: Events may offer "screen-only" upgrades + - System 3: Targeted first in gunnery phase (acts as tank) +``` + +--- + +## 7. Fleet Chemistry - Specific Synergies + +### What We Know (from DESIGN.md) +- SCREEN + CAPITAL = torpedo protection +- No SCREEN = +firepower, +torpedo vulnerability +- Multiple AVIATOR = strong air, weak surface + +**[DECIDE: Define exact mechanical effects]** + +Template: +``` +Synergy: [Tag Combo] +Condition: [e.g., ">=1 SCREEN and >=1 CAPITAL in fleet"] +Effect: [Exact mechanical benefit] +Tradeoff: [Any cost or weakness introduced] +``` + +**[DECIDE: Fill in 4-6 fleet chemistry effects]** + +Example: +``` +Synergy: Carrier Battle Group +Condition: >=2 AVIATOR + >=1 SCREEN +Effect: + - Air Phase attacks deal +25% damage + - SCREEN ships provide +10 AA to fleet +Tradeoff: + - If all AVIATOR ships lost, fleet has -30% surface firepower (over-specialized) +``` + +--- + +## 8. Event Design - Concrete Examples + +### Design Rules (from DESIGN.md) +- Must force commitment or introduce risk +- No pure free upgrades + +**[DECIDE: Write 5-8 event cards]** + +Template: +``` +EVENT: [Name] +Flavor: [1-2 sentences of context] +Choice A: [Option] → [Mechanical effect] +Choice B: [Option] → [Mechanical effect] +Choice C (optional): [Option] → [Mechanical effect] +Tag Requirements: [e.g., "Only appears if you have AVIATOR ship"] +``` + +**[DECIDE: Fill in 5-8 events]** + +Example: +``` +EVENT: Experimental Ammunition +Flavor: A supply ship offers prototype shells. Experimental. Unproven. Powerful. +Choice A: Load AP Rounds → All guns +5 penetration, but 10% chance to misfire (miss automatically) +Choice B: Load HE Rounds → All guns +10 damage vs low-armor targets (<10 armor), -5 damage vs high armor +Choice C: Decline → No change +Tag Requirements: None +``` + +--- + +## 9. Boss Design + +**[DECIDE: Define the MVP boss encounter]** + +### Boss Option A: Single Super-Unit +``` +Name: [DECIDE] +Hull: [Custom super-hull] +HP: [DECIDE: 3x battleship HP?] +Special Mechanics: [DECIDE: e.g., "Regenerates 5% HP each phase"] +Weakness: [DECIDE: e.g., "Vulnerable to torpedo damage"] +``` + +### Boss Option B: Elite Fleet +``` +Name: [DECIDE] +Composition: [DECIDE: e.g., "2 Cruisers, 2 Destroyers, 1 Battleship"] +Special Mechanic: [DECIDE: e.g., "All ships have +1 mod slot filled"] +Weakness: [DECIDE: e.g., "No air power"] +``` + +### Boss Option C: Phased Fight +``` +Name: [DECIDE] +Phase 1: [DECIDE: e.g., "3 Destroyers with TORPEDO_SPECIALIST"] +Phase 2 (if Phase 1 defeated): [DECIDE: e.g., "1 Battleship + 2 Cruisers"] +Special Mechanic: [DECIDE: e.g., "Phase 2 starts with you at current HP"] +``` + +**[DECIDE: Pick A, B, or C and fill in details]** + +--- + +## 10. Meta-Progression + +### What We Know +- Unlocks options, not power +- No permanent stat buffs + +**[DECIDE: What unlocks after each run?]** + +Proposed structure: +- Win Run 1 → Unlock [DECIDE: 1 new hull? 1 new starting fleet? 2 equipment items?] +- Win Run 2 → Unlock [DECIDE] +- Win Run 3 → Unlock [DECIDE] +- Lose Run (any) → Unlock [DECIDE: Nothing? Small consolation prize?] + +**[DECIDE: Fill in unlock tree for first 5 wins]** + +### Unlock Categories +Check which categories you want: +- [ ] New hulls +- [ ] New equipment +- [ ] New mods +- [ ] New starting fleet options +- [ ] New events +- [ ] New bosses (variants) +- [ ] Cosmetics (ship skins, names, etc.) + +**[DECIDE: Check boxes above]** + +--- + +## 11. Victory Condition + +**[DECIDE: What constitutes winning a run?]** + +- Option A: Defeat the boss (regardless of fleet state) +- Option B: Defeat the boss with at least X ships remaining +- Option C: Reach score threshold (score = enemies defeated × fleet HP remaining?) + +**[DECIDE: Pick one]** + +**[DECIDE: Are there difficulty tiers or modifiers?]** (e.g., Ascension levels like Slay the Spire) +- YES → [DECIDE: What do they change?] +- NO → Just one difficulty for MVP + +--- + +## 12. Known Vulnerabilities Log + +Things that will probably bite later but are acceptable for MVP: + +1. **Combat pacing:** If fights take >2min to watch, 30min promise breaks. Need fast-forward or instant resolve option. + +2. **Balance death spiral:** If one lost ship makes you too weak, runs feel doomed early. Replacement mechanic or careful balance required. + +3. **Tag discovery:** Players won't know what tags do without a codex/help. Either add tutorial or accept confusion for first 2-3 runs. + +4. **Event repetition:** With only 5-8 events, you'll see repeats in same run. Either need more events or smart draw-without-replacement system. + +5. **Solved meta:** With deterministic RNG and small pool, optimal strategies will emerge fast. Plan for balance patches or accept short shelf life. + +6. **Equipment bloat:** If loot drops every fight, inventory management becomes tedious. Either cap inventory size or allow scrapping/selling. + +--- + +## 13. Open Questions Not Covered Above + +**[DECIDE: Answer these]** + +1. **Currency system:** Is there scrap/gold/resources, or purely loot-based? + +2. **Ship naming:** Do ships have persistent names/identity, or just "Destroyer #1"? + +3. **RNG seed exposure:** Is seed visible to player (for challenge runs/sharing)? + +4. **Retreat option:** Can you retreat from combat before it starts (see enemy, back out)? + +5. **Save system:** Save between nodes, or full run in one sitting? + +6. **Difficulty indicators:** Does UI show "this fight will be hard" before committing? + +7. **Fleet size limits:** Always exactly 3 ships, or can you have 2-4? + +--- + +## Fill-In Instructions + +1. Search for every `[DECIDE: ...]` tag in this document +2. Replace with your actual design decision +3. Delete options you're NOT using +4. If you add new mechanics, document them in same format +5. When you've filled it all in, this becomes the gameplay spec + +Don't overthink it - make a decision, document it, test it. Wrong answers are fine; unknown answers will kill the project. + +--- + +**Status:** INCOMPLETE - requires design decisions before implementation. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bcebb1d --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Fleet builder (working title) + +## Description +A 30-minute, composition-first naval roguelite where you act as an admiral. Combat is auto-resolved and phase-based; the core decisions happen before battles through fleet composition, mods, and equipment. +Runs are short and final, with meta-progression unlocking options rather than raw power. diff --git a/tools/filetree.py b/tools/filetree.py new file mode 100644 index 0000000..7f3e160 --- /dev/null +++ b/tools/filetree.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +""" +Create an ASCII file tree while respecting .gitignore when possible. + +Usage: + python tools/filetree.py [output_path] +""" +from __future__ import annotations + +import fnmatch +import os +import subprocess +import sys +from typing import Dict, Iterable, List + + +def find_repo_root(start: str) -> str: + cur = os.path.abspath(start) + while True: + if os.path.isdir(os.path.join(cur, ".git")): + return cur + parent = os.path.dirname(cur) + if parent == cur: + return os.path.abspath(start) + cur = parent + + +def git_available() -> bool: + try: + subprocess.run(["git", "--version"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return True + except Exception: + return False + + +def list_files_via_git(root: str) -> List[str]: + result = subprocess.run( + ["git", "-C", root, "ls-files", "--others", "--cached", "--exclude-standard"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + files = [line.strip() for line in result.stdout.splitlines() if line.strip()] + return files + + +def parse_gitignore(root: str) -> List[str]: + path = os.path.join(root, ".gitignore") + if not os.path.isfile(path): + return [] + patterns: List[str] = [] + with open(path, "r", encoding="utf-8") as f: + for raw in f: + line = raw.strip() + if not line or line.startswith("#"): + continue + patterns.append(line) + return patterns + + +def is_ignored(path_rel: str, is_dir: bool, patterns: List[str]) -> bool: + # Minimal .gitignore matching: supports *, ?, **, leading /, trailing / + path_rel = path_rel.replace(os.sep, "/") + ignored = False + for pat in patterns: + negate = pat.startswith("!") + if negate: + pat = pat[1:] + if pat.startswith("/"): + pat = pat[1:] + if pat.endswith("/") and not is_dir: + continue + pat = pat.rstrip("/") + if fnmatch.fnmatch(path_rel, pat) or fnmatch.fnmatch(os.path.basename(path_rel), pat): + ignored = not negate + return ignored + + +def list_files_with_fallback(root: str) -> List[str]: + patterns = parse_gitignore(root) + files: List[str] = [] + for dirpath, dirnames, filenames in os.walk(root): + rel_dir = os.path.relpath(dirpath, root) + if rel_dir == ".": + rel_dir = "" + # Prune ignored directories early + pruned = [] + for d in dirnames: + rel_path = os.path.join(rel_dir, d) if rel_dir else d + if is_ignored(rel_path, True, patterns): + continue + pruned.append(d) + dirnames[:] = pruned + for name in filenames: + rel_path = os.path.join(rel_dir, name) if rel_dir else name + if is_ignored(rel_path, False, patterns): + continue + files.append(rel_path.replace(os.sep, "/")) + return files + + +def build_tree(paths: Iterable[str]) -> Dict[str, dict]: + root: Dict[str, dict] = {} + for path in paths: + parts = [p for p in path.split("/") if p] + node = root + for part in parts: + node = node.setdefault(part, {}) + return root + + +def render_tree(node: Dict[str, dict], prefix: str = "") -> List[str]: + lines: List[str] = [] + entries = sorted(node.keys()) + for i, name in enumerate(entries): + is_last = i == len(entries) - 1 + connector = "\\-- " if is_last else "|-- " + lines.append(f"{prefix}{connector}{name}") + child = node[name] + if child: + extension = " " if is_last else "| " + lines.extend(render_tree(child, prefix + extension)) + return lines + + +def main() -> int: + out_path = sys.argv[1] if len(sys.argv) > 1 else "FILETREE.txt" + root = find_repo_root(os.getcwd()) + if os.path.isdir(os.path.join(root, ".git")) and git_available(): + files = list_files_via_git(root) + else: + files = list_files_with_fallback(root) + + tree = build_tree(files) + lines = ["."] + lines.extend(render_tree(tree)) + out_abs = os.path.join(root, out_path) + with open(out_abs, "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())