# Per-overlay `server.cfg` aliases — opt-in via blueprint checkbox

## Context

L4D2 overlays stack via kernel overlayfs. When two overlays both ship `left4dead2/cfg/server.cfg`, the topmost wins; lower-layer copies become unreachable. On top of that, the blueprint's own `server.cfg` is copied into `merged/.../cfg/server.cfg` at instance start (`l4d2host/instances.py:112-115`), so the merged view's `server.cfg` is always the blueprint's.

We want a per-blueprint opt-in mechanism: for each linked overlay, the blueprint owner can check a box to expose that overlay's `server.cfg` as a reloadable alias under a known name. The alias is identified by overlay id (`server_overlay_<id>.cfg`), so it's stable across overlay renames and namespaced.

Trade-off accepted: only checked overlays are addressable in the in-game console. That's intentional — explicit opt-in beats automatic exposure of every overlay's config.

Earlier rounds of this plan considered (and rejected):
- Doing it in each script overlay: too easy to forget, doesn't scale.
- A name-as-slug constraint with auto-aliasing for every overlay: more invasive (regex on names, blueprint-wide collision checks) and exposes everything by default.

## Approach

- New boolean column `expose_server_cfg` on `BlueprintOverlay` (per-blueprint, per-overlay state).
- Blueprint detail page: each linked overlay row gets a checkbox labeled with its alias (`exec server_overlay_<id>`).
- Spec yaml carries an optional `alias` per overlay; web app sets it to `overlay_<id>` when the box is checked, otherwise omits it.
- Host copies `<lowerdir>/left4dead2/cfg/server.cfg` → `merged/left4dead2/cfg/server_<alias>.cfg` at instance start, only for entries with `alias` set and an existing source. Pre-sweep removes stale aliases from prior starts.
- **Auto-inject `exec` lines into the blueprint's final `server.cfg`**: for each opted-in overlay, prepend `exec server_overlay_<id>` to the config list, in `BlueprintOverlay.position` ascending order (lowest overlay first, highest last), with the user's custom config lines appended after. Source-style cfg semantics: later lines override earlier ones, so this gives "lowest overlay's settings → higher overlay's settings → blueprint customizations" in the right precedence.

No constraint on `Overlay.name`. No alias / slug column on `Overlay`. The previously-added manual `cp` in `competitive_rework.sh` gets reverted (the framework will do it when checked).

## Changes

### 1. Schema

`l4d2web/models.py` — `BlueprintOverlay` gets:
```python
expose_server_cfg: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text("0"))
```

`l4d2web/alembic/versions/0007_blueprint_overlay_expose_server_cfg.py` — new Alembic migration:
- `op.add_column("blueprint_overlays", sa.Column("expose_server_cfg", sa.Boolean(), nullable=False, server_default=sa.text("0")))`
- Downgrade drops the column.

### 2. Spec contract (host ↔ web)

`l4d2host/spec.py`: replace `overlays: list[str]` with typed refs.
```python
@dataclass(slots=True)
class OverlayRef:
    path: str
    alias: str | None = None   # if set, copy server.cfg to server_<alias>.cfg in merged

@dataclass(slots=True)
class InstanceSpec:
    port: int
    overlays: list[OverlayRef] = field(default_factory=list)
    arguments: list[str] = field(default_factory=list)
    config: list[str] = field(default_factory=list)
```
`load_spec` accepts both shapes per overlay entry: a bare string is treated as `OverlayRef(path=string)` (back-compat for hand-written specs and existing tests); a dict carries `path` and optional `alias`.

`l4d2web/services/l4d2_facade.py`:
- `load_server_blueprint_bundle`: change select to `select(Overlay.id, Overlay.path, BlueprintOverlay.expose_server_cfg)`, ordered by `BlueprintOverlay.position` ascending (already the case). Returns the raw list of (id, path, expose) tuples to the caller.
- `build_server_spec_payload`:
  - Emit overlays as dicts: `{"path": p}` if not exposed, `{"path": p, "alias": f"overlay_{i}"}` if exposed.
  - Build `exec_lines = [f"exec server_overlay_{i}" for i, _, expose in rows if expose]` — same ordering as overlays (lowest first).
  - Set `config = exec_lines + json.loads(blueprint.config)`. Net effect: `exec` lines appear at the top of the written `instance_dir/server.cfg`, blueprint custom lines follow.

### 3. Lowerdir construction (host)

`l4d2host/instances.py:44`:
```python
lowerdirs = [str(overlay_path(o.path, root=root)) for o in spec.overlays]
```

### 4. Per-overlay copy in `start_instance` (host)

`l4d2host/instances.py`, after the existing main `server.cfg` copy. New block:
```python
emit_step("copying per-overlay server.cfg aliases...", on_stdout, passthrough)
cfg_dir = runtime_dir / "merged" / "left4dead2" / "cfg"
for stale in cfg_dir.glob("server_*.cfg"):
    stale.unlink()
for o in spec.overlays:
    if not o.alias:
        continue
    src = root / "overlays" / o.path / "left4dead2" / "cfg" / "server.cfg"
    if not src.exists():
        continue
    shutil.copy2(src, cfg_dir / f"server_{o.alias}.cfg")
```
- Sweep first: prevents orphans when a checkbox is unticked or an overlay is removed from the blueprint.
- Skip overlays with no `alias` (not opted in) and overlays whose lower dir has no `server.cfg` (workshop overlays etc.).
- Writes go to the upper layer of the overlayfs mount; lower dirs untouched.

### 5. Blueprint detail UI

`l4d2web/templates/blueprint_detail.html` — extend each linked-overlay `<li>` with a checkbox + label showing the alias inline.

`l4d2web/routes/page_routes.py` `blueprint_page`: also pass an `overlay_expose_state: dict[int, bool]` keyed by overlay_id so the template can read the current `expose_server_cfg` value.

`l4d2web/routes/blueprint_routes.py` (`replace_blueprint_overlays` and its callers): also read `expose_server_cfg_ids` from the form (`request.form.getlist("expose_server_cfg_ids")`), convert to `set[int]`, and set `BlueprintOverlay.expose_server_cfg = (overlay_id in expose_set)` per row.

### 6. Revert the manual cp

`examples/script-overlays/competitive_rework.sh`: remove the `cp "$DEST/cfg/server.cfg" "$DEST/cfg/server_competitive.cfg"` block added in the previous round. The framework handles this on demand now.

## Critical files

- `l4d2web/models.py` — `BlueprintOverlay.expose_server_cfg`
- `l4d2web/alembic/versions/0007_*.py` — new Alembic migration
- `l4d2web/routes/blueprint_routes.py` — read checkbox set on save, persist expose flag
- `l4d2web/routes/page_routes.py` — pass overlay state map to template
- `l4d2web/templates/blueprint_detail.html` — checkbox + alias display
- `l4d2web/services/l4d2_facade.py` — emit alias per overlay in spec payload + prepend exec lines
- `l4d2host/spec.py` — `OverlayRef` dataclass + spec deserialization
- `l4d2host/instances.py` — lowerdir construction + per-overlay copy step + sweep
- `examples/script-overlays/competitive_rework.sh` — remove manual `cp`

## Out of scope

- No constraint on `Overlay.name`.
- No `cfg_alias` / `slug` column on `Overlay`.
- No per-blueprint custom alias text (id-based naming is fixed: `overlay_<id>`).
- No automatic detection of which overlays ship a `server.cfg` to gate the checkbox in UI — checkbox is always available; the host silently skips at start time if the source doesn't exist.

## Verification

1. **Unit tests**:
   - `l4d2host/tests/`: `start_instance` test where two overlays exist on disk — one with `server.cfg`, one without; spec marks both with aliases; assert only the one with a source produces `server_<alias>.cfg` in merged. Pre-existing `server_old.cfg` in merged is swept.
   - `l4d2host/tests/`: spec yaml round-trip test for `OverlayRef` with and without `alias`; back-compat test for bare-string entries.
   - `l4d2web/tests/`: blueprint payload build asserts overlays without `expose_server_cfg` produce no `alias`; with, produce `overlay_<id>`.
   - `l4d2web/tests/`: blueprint payload `config` field equals `["exec server_overlay_<id_low>", "exec server_overlay_<id_high>", *blueprint_custom_lines]` — `exec` lines in `BlueprintOverlay.position` ascending order, custom lines last, no exec lines for unchecked overlays.
   - `l4d2web/tests/`: form submit with `expose_server_cfg_ids=[6, 8]` updates the matching `BlueprintOverlay` rows; unchecked rows reset to false.
   - Run: `pytest l4d2host/tests -q`, `pytest l4d2web/tests -q`.

2. **End-to-end on the test server (`ckn@10.0.4.128`)**:
   - Deploy via `deploy/deploy-test-server.sh`.
   - Blueprint detail: each linked overlay shows a checkbox with its alias label.
   - Tick the box for `competitive_rework`; save; reload; checkbox stays checked.
   - Start a server using that blueprint: `ls /var/lib/left4me/runtime/<name>/merged/left4dead2/cfg/server_*.cfg` → shows `server_overlay_<id>.cfg` for the checked overlay only.
   - Inspect the written `server.cfg`: `head -n 5 /var/lib/left4me/instances/<name>/server.cfg` → top lines are `exec server_overlay_<id>` for each checked overlay in lowest-first order, followed by the blueprint's custom lines.
   - In-game console: server boot should auto-load the per-overlay configs.
   - Untick the box, restart the server → `server_overlay_<id>.cfg` no longer present in merged, and the corresponding `exec` line is no longer in the written `server.cfg`.

3. **Negative**: tick an overlay that doesn't ship a `server.cfg` (e.g. a workshop overlay) → start succeeds, no alias file produced (host skipped silently).
