diff --git a/.claude/skills/generate-plugin-doc/SKILL.md b/.claude/skills/generate-plugin-doc/SKILL.md
new file mode 100644
index 0000000..2e46673
--- /dev/null
+++ b/.claude/skills/generate-plugin-doc/SKILL.md
@@ -0,0 +1,48 @@
+---
+name: generate-plugin-doc
+description: Generates docs/plugins/{name}.md (EN) and docs/plugins/{name}.fr.md (FR) for a CTLD plugin, from its plugins/{name}/README.md (optional) and metadata extracted from its .lua source. Use when a plugin author wants to publish or refresh the documentation pages for their plugin.
+---
+
+
+
+Generate the documentation pages for a CTLD plugin.
+
+## Inputs to gather
+
+1. **Plugin name** — infer from context or ask.
+2. **`plugins/{name}/README.md`** — read if it exists (front-matter YAML + French prose). Optional.
+3. **`plugins/{name}/src/*.lua`** — extract `requiresCtld`, `modTypes`, `requiresMod`, and the scene `name`.
+4. **`plugins/_template/README.md`** — reference for front-matter structure.
+
+## What to produce
+
+Two files, both beginning with the `do not edit` comment:
+
+```
+
+```
+
+### `docs/plugins/{name}.fr.md`
+
+- Title: `# {scene name}` (from `.lua`)
+- Download button (primary): `[⬇ {name}.lua](../downloads/{lua_file}){ download="{name}.lua" .md-button .md-button--primary }`
+- Sections: Description, Prérequis, Installation, Remarques (omit if empty)
+- Content: the French prose from `README.md` body (if present), otherwise minimal generated text
+- Mod links: for each entry in `modUrls`, add a link in the Prérequis section:
+ `[⬇ {mod}]({url}){ .md-button }`
+- External prose links: write them as plain markdown links — the MkDocs hook (`hooks.py`) automatically adds `target` and `rel="noopener"` to all external links at build time
+
+### `docs/plugins/{name}.md`
+
+- Same structure as the FR file, fully translated to English
+- Translate all prose, section headings, and UI labels
+- Keep code literals, type names, version numbers, and URLs untouched
+
+## Rules
+
+- Never edit `docs/plugins/` files by hand after generation — they are owned by this skill.
+- If `README.md` is absent: generate minimal pages from `.lua` metadata only (no prose, no modUrls).
+- Download buttons always use a relative path (`../downloads/{lua_file}`) — never an absolute URL.
+- After writing both files, remind the author to add the plugin to `docs/index.md` and `docs/index.fr.md` if not already present. In those index files, the download button path is `downloads/{lua_file}` (no leading `../`).
+
+
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d569c0a..eb7fa54 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -56,7 +56,18 @@ jobs:
sudo apt-get update && sudo apt-get install -y lua5.1 liblua5.1-dev luarocks
sudo luarocks install busted
- name: Run busted
- run: busted tests/
+ run: busted tests/ plugins/
+
+ # Documentation coherence gate: README front-matter + generated doc pages.
+ validate-docs:
+ name: Validate plugin docs
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install PyYAML
+ run: pip install pyyaml
+ - name: Check README / generated docs coherence
+ run: python tools/ci/validate_docs.py
# Build every plugin into dist/.lua and upload as artifacts.
build:
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 597cd6f..0d4c0ef 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -2,16 +2,16 @@ name: Docs
on:
push:
- branches: [develop]
- paths: ['docs/**', 'mkdocs.yml', '.github/workflows/docs.yml']
+ branches: [develop, master]
+ paths: ['docs/**', 'plugins/**/src/*.lua', 'mkdocs.yml', '.github/workflows/docs.yml']
workflow_dispatch:
permissions:
contents: write
jobs:
- build-deploy:
- name: Build & deploy catalogue
+ deploy:
+ name: Deploy versioned docs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -20,10 +20,30 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.x'
- - name: Install mkdocs-material + i18n
- run: pip install mkdocs-material mkdocs-static-i18n
- - name: Build
- run: mkdocs build --strict
- - name: Deploy to gh-pages
+ - name: Install dependencies
+ run: pip install mkdocs-material mkdocs-static-i18n mike
+ - name: Configure git for mike
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ - name: Copy plugin .lua files to docs/downloads/
+ run: |
+ mkdir -p docs/downloads
+ cp plugins/*/src/*.lua docs/downloads/
+ - name: Inject branch into download URLs
+ # Replace /master/ with the actual branch in raw download links,
+ # so the dev docs point to develop-branch sources.
+ run: |
+ branch="${GITHUB_REF_NAME}"
+ if [ "$branch" != "master" ]; then
+ find docs -name "*.md" -exec sed -i \
+ "s|/CTLD_plugins/master/|/CTLD_plugins/${branch}/|g" {} \;
+ fi
+ - name: Deploy latest (master → latest)
+ if: github.ref == 'refs/heads/master'
+ run: |
+ mike deploy --push --update-aliases master latest
+ mike set-default --push latest
+ - name: Deploy dev (develop → dev)
if: github.ref == 'refs/heads/develop'
- run: mkdocs gh-deploy --force
+ run: mike deploy --push --update-aliases develop dev
diff --git a/.luacheckrc b/.luacheckrc
index 9aafa10..8bb1c79 100644
--- a/.luacheckrc
+++ b/.luacheckrc
@@ -11,10 +11,14 @@ self = false
exclude_files = {
"tools/**",
- "vendor/**", -- vendored CTLD.lua build (not ours to lint)
- "tests/data/**", -- generated datamine type set
+ "vendor/**", -- vendored CTLD.lua build (not ours to lint)
+ "tests/data/**", -- generated datamine type set
+ "tests/helpers/dcs_stubs.lua", -- vendored DCS API stubs (defines the DCS globals)
}
+-- `ctld` is writable: plugin scenes populate ctld.i18n[...] at load.
+globals = { "ctld" }
+
read_globals = {
-- DCS World API
"env", "world", "coalition", "country", "timer", "trigger",
@@ -23,7 +27,6 @@ read_globals = {
"Weapon", "Runway", "Warehouse",
"require", "dofile", "loadfile", "loadstring", "io", "os",
-- CTLD runtime (provided by CTLD, consumed by plugins)
- "ctld",
"CTLDObjectRegistry", "CTLDSceneManager", "CtldScene",
"CTLDCrateManager", "CTLDCrateAssemblyManager",
"CTLDPlayerManager", "CTLDPlayer",
@@ -33,7 +36,9 @@ read_globals = {
}
files["tests/"] = {
+ -- busted globals + the CTLD singletons a test may spy on (mutate).
globals = { "describe", "it", "setup", "teardown",
"before_each", "after_each",
- "assert", "spy", "mock", "stub" },
+ "assert", "spy", "mock", "stub",
+ "CTLDObjectRegistry", "CTLDSceneManager" },
}
diff --git a/CONTEXT.md b/CONTEXT.md
new file mode 100644
index 0000000..96d3ed8
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,57 @@
+# CONTEXT.md — CTLD_plugins
+
+Glossary and domain decisions. Contains no implementation details.
+
+---
+
+## Glossary
+
+**Plugin**
+A single `.lua` file that self-registers in CTLD when loaded. One plugin = one scene. Plugins live in `plugins/{name}/src/`.
+
+**Scene**
+What a plugin builds in-game (e.g. a metal FARP). Runtime DCS/CTLD term.
+
+**Mission maker**
+End user of a plugin: downloads the `.lua`, loads it in the DCS Mission Editor, and installs any required mods on their machine.
+
+**DCS mod**
+Third-party extension for DCS World providing non-stock object types. A plugin that requires a mod declares it in `modTypes` (runtime) and in `modUrls` (documentation).
+
+**modUrls**
+Table of `{mod, url}` pairs in the YAML front-matter of a plugin's `README.md`. Authoritative source for links to required mods. Distinct from `modTypes` (Lua runtime only).
+
+**Plugin README**
+File `plugins/{name}/README.md`. Single source of truth for a plugin's documentation. Contains a YAML front-matter (structured metadata including `modUrls`) and French prose. Follows the template at `plugins/_template/README.md`.
+
+**Generated doc**
+The files `docs/plugins/{name}.md` (EN) and `docs/plugins/{name}.fr.md` (FR) generated by an interactive Claude skill from the plugin README. Never edited by hand.
+
+**Skill generate-plugin-doc**
+Claude skill that reads `plugins/{name}/README.md` and the doc template, assembles and translates FR→EN, and produces the two `docs/plugins/` files.
+
+**Catalogue**
+Index page (`docs/index.md` / `docs/index.fr.md`) listing all available plugins with their download button.
+
+**Download button**
+Link to the `.lua` file served from `docs/downloads/` (same origin as the docs site).
+Relative URL: `downloads/{file}.lua` (from the index) or `../downloads/{file}.lua` (from a plugin page).
+Carries the `download` attribute to trigger a native browser file download.
+Present in both the catalogue table and the plugin description page.
+
+**Mod button**
+Link in the Prerequisites section of a plugin page, pointing to the external mod page.
+Carries `target="ext-{mod}" rel="noopener"`: multiple clicks reuse the same named browser tab rather than opening a new one each time.
+
+**External prose link**
+Hyperlink in generated prose pointing to an external site (e.g. a mod's GitHub repository).
+Carries `target="_blank" rel="noopener"` to open an anonymous new tab.
+
+**{branch}**
+Variable injected at doc generation time. Value is `master` (site `latest`) or `develop` (site `dev`). Not applicable to download buttons (which point to `docs/downloads/`, copied from the current branch at CI build time).
+
+**Versioned docs site**
+MkDocs Material site managed by **mike**. Two versions coexist on GitHub Pages:
+- `latest` → branch `master` (stable)
+- `dev` → branch `develop` (staging)
+Each version has its own download links coherent with its branch.
diff --git a/README.md b/README.md
index f57d657..02e7918 100644
--- a/README.md
+++ b/README.md
@@ -49,3 +49,28 @@ docs/ mkdocs bilingual (EN + FR) catalogue
The vendored `vendor/CTLD.lua` and `tests/data/dcs_types.lua` are pinned to a CTLD baseline; refresh
them together when bumping the supported CTLD version.
+
+- **Document a plugin:** create `plugins//README.md` (use `plugins/_template/README.md` as
+ model), then run the `generate-plugin-doc` skill in Claude to produce `docs/plugins/.md`
+ and `docs/plugins/.fr.md`. Finally add the plugin row to `docs/index.md` and
+ `docs/index.fr.md`.
+
+### CI gate — `validate-docs`
+
+The `validate-docs` job runs `tools/ci/validate_docs.py` on every push and PR. It:
+
+- **Warns** (exit 0) when a plugin has no `README.md` (docs will have no prose, but the build is not blocked).
+- **Errors** (exit 1) when:
+ - `README.md` is present but its YAML front-matter is absent or malformed.
+ - `modUrls` exists but is not a list of `{mod, url}` pairs.
+ - `docs/plugins/.md` or `docs/plugins/.fr.md` is absent.
+
+Run it locally before pushing: `python tools/ci/validate_docs.py` (requires `pyyaml`).
+
+## Documentation site
+
+| Version | URL |
+|---------|-----|
+| Production (`latest`, from `master`) | |
+| Recette (`dev`, from `develop`) | |
+| Racine (redirects to `latest`) | |
diff --git a/docs/adr/0001-readme-as-doc-source-of-truth.md b/docs/adr/0001-readme-as-doc-source-of-truth.md
new file mode 100644
index 0000000..30a5bd9
--- /dev/null
+++ b/docs/adr/0001-readme-as-doc-source-of-truth.md
@@ -0,0 +1,14 @@
+# Plugin README as documentation source of truth
+
+Each plugin has a `plugins/{name}/README.md` (YAML front-matter + French prose) that is the single source of truth for its documentation. The files `docs/plugins/{name}.md` and `{name}.fr.md` are **generated** by an interactive Claude skill — never edited by hand.
+
+## Alternatives considered
+
+- **Separate `meta.yaml` + prose in `docs/`**: two files to keep in sync, risk of divergence.
+- **Metadata embedded in the `.lua`**: the `.lua` runs inside DCS — embedding GitHub URLs there pollutes runtime code with pure documentary metadata that has no value at execution time.
+
+## Consequences
+
+- `docs/plugins/` files must be regenerated after every change to a plugin's `README.md`.
+- `plugins/_template/README.md` is the authoritative template for plugin authors.
+- The FR→EN translation is produced by Claude on demand from the designer (interactive), not by CI.
diff --git a/docs/adr/0002-mike-versioned-docs.md b/docs/adr/0002-mike-versioned-docs.md
new file mode 100644
index 0000000..98f8af5
--- /dev/null
+++ b/docs/adr/0002-mike-versioned-docs.md
@@ -0,0 +1,22 @@
+# Versioned docs site with mike (latest / dev)
+
+The MkDocs Material site is managed by **mike**, with two versions coexisting on GitHub Pages: `latest` (branch `master`) and `dev` (branch `develop`). Each version injects its own `{branch}` value into download buttons, ensuring that a tester on `dev` downloads the `.lua` from `develop` and not from `master`.
+
+## Alternatives considered
+
+- **Single site (master only)**: impossible to test the docs and download links in staging without workarounds — the tester would have clicked "download" and received the stable version, not the one under test.
+- **Two separate sites (`/` and `/dev/`)**: works but loses the built-in version selector, already validated in the VEAF org (`veaf.github.io/documentation/`).
+
+## Site URLs
+
+| Version | URL | Trigger |
+| ------- | --- | ------- |
+| Production (`latest`) | | push to `master` |
+| Staging (`dev`) | | push to `develop` |
+| Root | | redirects to `latest` |
+
+## Consequences
+
+- The `docs.yml` workflow calls `mike deploy dev` on push to `develop` and `mike deploy latest` on push to `master`.
+- `{branch}` is a variable injected at doc generation time (Claude skill) — value is `develop` or `master`.
+- The `versions.json` managed by mike must stay on `gh-pages`, not committed to `develop`/`master`.
diff --git a/docs/adr/0003-serve-lua-from-docs-downloads.md b/docs/adr/0003-serve-lua-from-docs-downloads.md
new file mode 100644
index 0000000..952ed54
--- /dev/null
+++ b/docs/adr/0003-serve-lua-from-docs-downloads.md
@@ -0,0 +1,19 @@
+# Serve plugin `.lua` files from `docs/downloads/` instead of `raw.githubusercontent.com`
+
+Plugin `.lua` files are copied into `docs/downloads/` by CI before the MkDocs build, so they are served from the same origin as the docs site (`veaf.github.io`). Download buttons point to this relative path and carry the `download` attribute, guaranteeing a native browser file download in all browsers.
+
+## Problem
+
+The HTML `download` attribute is silently ignored by Chrome (and Chromium-based browsers) when the link points to a cross-origin URL, even if CORS headers are present. `raw.githubusercontent.com` is a different origin from `veaf.github.io`: clicking the button was opening the file in the browser instead of downloading it.
+
+## Alternatives considered
+
+- **`download` attribute on the raw URL**: ignored by Chrome for cross-origin links — the problem persists.
+- **Link to the GitHub blob page (`/blob/`)**: requires two clicks; the mission maker lands on the GitHub UI before being able to download.
+- **Package `.lua` as a GitHub Release asset**: requires a dedicated release workflow and adds disproportionate operational complexity.
+
+## Consequences
+
+- `.lua` files are duplicated in the built site (negligible size).
+- Branch coherence is automatic: CI copies the files from the branch currently being built.
+- The existing `sed` branch-injection step in `docs.yml` no longer applies to `.lua` download URLs (harmless: these URLs are now relative paths).
diff --git a/docs/index.fr.md b/docs/index.fr.md
index 89de7a7..e326a01 100644
--- a/docs/index.fr.md
+++ b/docs/index.fr.md
@@ -16,6 +16,6 @@ mission ne les embarque que si elle le décide.
## Plugins disponibles
-| Plugin | Ce qu'il construit | Requiert |
-|--------|--------------------|----------|
-| [Metal FARP](plugins/metal-farp.md) | Un FARP à hélisurface métallique | Mod DCS `Farp_FG_Petit_Helipad` |
+| Plugin | Ce qu'il construit | Requiert | Télécharger |
+|--------|--------------------|----------|-------------|
+| [Metal FARP](plugins/metal-farp.md) | Un FARP à hélisurface métallique | Mod DCS `FG_small_Helipad` | [⬇ metal-farp.lua](downloads/CTLD_metalFarpScene.lua){ download .md-button } |
diff --git a/docs/index.md b/docs/index.md
index 40559a1..4d288c3 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -15,6 +15,6 @@ only pays for them if it opts in.
## Available plugins
-| Plugin | What it builds | Requires |
-|--------|----------------|----------|
-| [Metal FARP](plugins/metal-farp.md) | A metal-helipad FARP | DCS mod `Farp_FG_Petit_Helipad` |
+| Plugin | What it builds | Requires | Download |
+|--------|----------------|----------|----------|
+| [Metal FARP](plugins/metal-farp.md) | A metal-helipad FARP | DCS mod `FG_small_Helipad` | [⬇ metal-farp.lua](downloads/CTLD_metalFarpScene.lua){ download .md-button } |
diff --git a/docs/plugins/metal-farp.fr.md b/docs/plugins/metal-farp.fr.md
index 68d1e6a..981bb7a 100644
--- a/docs/plugins/metal-farp.fr.md
+++ b/docs/plugins/metal-farp.fr.md
@@ -1,25 +1,24 @@
+
+
# Metal FARP
-Construit un FARP autour d'une hélisurface métallique du mod DCS **`Farp_FG_Petit_Helipad`**, avec
-le mobilier FARP habituel (camion carburant, camion de réparation, tente, munitions, éclairage,
-manche à air).
+[⬇ metal-farp.lua](../downloads/CTLD_metalFarpScene.lua){ download="metal-farp.lua" .md-button .md-button--primary }
+
+Ce plugin permet via CTLD de faire apparaître un héliport métallique de façon progressive pour
+simuler sa construction, en y ajoutant des objets pour obtenir un héliport "décoré" plus réaliste.
## Prérequis
-- **CTLD** ≥ 2.0.0 chargé en premier (le plugin prévient en jeu si CTLD est plus ancien).
-- Le mod DCS fournissant le type statique **`Farp_FG_Petit_Helipad`**, installé sur **tous** les
- clients. Sans lui, l'hélisurface ne peut pas apparaître.
+- **CTLD** ≥ 2.0.0 chargé en premier.
+- Le mod DCS **`FG_small_Helipad`** installé sur **tous** les clients.
+ Sans lui, l'héliport ne peut pas apparaître.
+
+ [⬇ FG_small_Helipad](https://github.com/FullGas1/DCS-mods/tree/main/FG_small_Helipad){ .md-button }
## Installation
-1. Téléchargez `metal-farp.lua`.
+1. Téléchargez le fichier `.lua` du plugin (bouton ci-dessus).
2. Dans l'éditeur de mission, ajoutez un déclencheur `DO SCRIPT FILE` au **démarrage de la
mission**, **après** le déclencheur qui charge `CTLD.lua`.
-3. La scène ajoute une caisse dans le menu *Request Equipment* de CTLD ; déployez-la comme n'importe
- quelle caisse de scène FARP.
-
-## Remarques
-
-La validation au design-time ne peut pas vérifier que le client a réellement le mod installé —
-seulement que le nom de type est un mod déclaré. S'assurer que le mod est présent sur tous les
-clients relève de la responsabilité du créateur de mission.
+3. La scène ajoute une caisse dans le menu *Request Equipment* de CTLD ; déployez-la comme
+ n'importe quelle caisse de scène FARP.
diff --git a/docs/plugins/metal-farp.md b/docs/plugins/metal-farp.md
index 6ee96bc..cf5e7ea 100644
--- a/docs/plugins/metal-farp.md
+++ b/docs/plugins/metal-farp.md
@@ -1,24 +1,25 @@
+
+
# Metal FARP
-Builds a FARP around a metal helipad from the DCS mod **`Farp_FG_Petit_Helipad`**, plus the usual
-FARP furniture (fuel truck, repair truck, tent, ammo, lighting, windsock).
+[⬇ metal-farp.lua](../downloads/CTLD_metalFarpScene.lua){ download="metal-farp.lua" .md-button .md-button--primary }
+
+This plugin uses CTLD to progressively deploy a metal helipad, simulating its construction,
+and surrounds it with equipment objects to create a more realistic "dressed" forward arming
+and refuelling point.
## Prerequisites
-- **CTLD** ≥ 2.0.0 loaded first (the plugin warns in-game if CTLD is older).
-- The DCS mod providing the static type **`Farp_FG_Petit_Helipad`**, installed on **every** client.
- Without it the FARP helipad will not spawn.
+- **CTLD** ≥ 2.0.0 loaded first.
+- The DCS mod **`FG_small_Helipad`** installed on **all** clients.
+ Without it, the helipad cannot spawn.
-## Install
+ [⬇ FG_small_Helipad](https://github.com/FullGas1/DCS-mods/tree/main/FG_small_Helipad){ .md-button }
-1. Download `metal-farp.lua`.
-2. In the Mission Editor, add a `DO SCRIPT FILE` trigger at **MISSION START**, **after** the trigger
- that loads `CTLD.lua`.
-3. The scene registers a crate in the CTLD *Request Equipment* menu; deploy it like any other FARP
- scene crate.
-
-## Notes
+## Install
-Design-time validation cannot check whether a client actually has the mod installed — only that the
-type name is a declared mod. Ensuring the mod is present on all clients is the mission maker's
-responsibility.
+1. Download the plugin `.lua` file (button above).
+2. In the Mission Editor, add a `DO SCRIPT FILE` trigger at **MISSION START**, **after** the
+ trigger that loads `CTLD.lua`.
+3. The scene registers a crate in the CTLD *Request Equipment* menu; deploy it like any other
+ FARP scene crate.
diff --git a/docs/plugins/template.fr.md b/docs/plugins/template.fr.md
new file mode 100644
index 0000000..cc93900
--- /dev/null
+++ b/docs/plugins/template.fr.md
@@ -0,0 +1,30 @@
+# Template (pour les auteurs de plugins)
+
+`plugins/_template/` est une **scène de référence**, pas un plugin déployable. Copiez-la pour
+démarrer un nouveau plugin — elle exerce tous les points d'extension d'une scène, abondamment
+commentée :
+
+- **i18n** dans les quatre langues obligatoires (en / fr / es / ko) ;
+- les déclarations **ObjectRegistry** de chaque type DCS spawné ;
+- un **modèle de scène** avec des steps `polar` et `func` ;
+- une **caisse** injectée dans le menu *Request Equipment* de CTLD ;
+- un **sous-menu radio F10** câblé via `deferMenuSection` (fonctionne que la scène soit chargée
+ avant ou après l'init de CTLD — le contrat d'indépendance à la position de chargement) ;
+- les métadonnées `requiresCtld` (version CTLD minimale) et `modTypes` (types hors-stock déclarés).
+
+## Checklist de création
+
+1. Copiez `plugins/_template/` vers `plugins//`, renommez le fichier et le `name` du
+ modèle.
+2. Déclarez chaque type spawné dans le BLOCK 2. Si l'un est un type **mod**, ajoutez-le à
+ `model.modTypes` (et renseignez `requiresMod` pour le catalogue). Tous les autres types doivent
+ être stock.
+3. `busted tests/ plugins/` — le gate d'assets échoue sur tout type inconnu/non déclaré.
+4. `tools/build/merge_plugin.ps1 -Plugin ` → `dist/.lua`.
+5. Copiez `plugins/_template/README.md` vers `plugins//README.md`. Renseignez le
+ front-matter (`modUrls` pour chaque mod requis) et rédigez la prose de description en **français**.
+ La section `modUrls` peut être supprimée entièrement si le plugin n'utilise aucun mod.
+6. Demandez à Claude d'exécuter le skill `generate-plugin-doc` pour générer
+ `docs/plugins/.md` et `docs/plugins/.fr.md` depuis votre README.
+ Committez les fichiers générés.
+7. Ajoutez le plugin dans le tableau du catalogue dans `docs/index.md` et `docs/index.fr.md`.
diff --git a/docs/plugins/template.md b/docs/plugins/template.md
new file mode 100644
index 0000000..fe7198a
--- /dev/null
+++ b/docs/plugins/template.md
@@ -0,0 +1,26 @@
+# Template (for plugin authors)
+
+`plugins/_template/` is a **reference scene**, not a deployable plugin. Copy it to start a new
+plugin — it exercises every extension point a scene can use, heavily commented:
+
+- **i18n** in the four mandatory languages (en / fr / es / ko);
+- **ObjectRegistry** declarations for every spawned DCS type;
+- a **scene model** with `polar` and `func` steps;
+- a **crate** injected into the CTLD *Request Equipment* menu;
+- an **F10 radio submenu** wired via `deferMenuSection` (works whether the scene is loaded before
+ or after CTLD init — the load-position-independent contract);
+- `requiresCtld` (minimum CTLD version) and `modTypes` (declared non-stock types) metadata.
+
+## Authoring checklist
+
+1. Copy `plugins/_template/` to `plugins//`, rename the file and the model `name`.
+2. Declare every spawned type in BLOCK 2. If any is a **mod** type, add it to `model.modTypes`
+ (and set `requiresMod` for the catalogue). All other types must be stock.
+3. `busted tests/ plugins/` — the asset gate fails on any undeclared/unknown type.
+4. `tools/build/merge_plugin.ps1 -Plugin ` → `dist/.lua`.
+5. Copy `plugins/_template/README.md` to `plugins//README.md`. Fill in the
+ front-matter (`modUrls` for each required mod) and write the description prose in **French**.
+ The `modUrls` section can be removed entirely if the plugin uses no mods.
+6. Ask Claude to run the `generate-plugin-doc` skill to generate `docs/plugins/.md`
+ and `docs/plugins/.fr.md` from your README. Commit the generated files.
+7. Add the plugin to the catalogue table in `docs/index.md` and `docs/index.fr.md`.
diff --git a/hooks.py b/hooks.py
new file mode 100644
index 0000000..7f29c14
--- /dev/null
+++ b/hooks.py
@@ -0,0 +1,48 @@
+"""
+MkDocs hook: add target and rel attributes to external links.
+
+- External links get target="ext-{stable-id}" so that multiple clicks
+ on the same URL reuse the same browser tab instead of opening new ones.
+- Internal links (same site) are left untouched.
+"""
+
+import re
+import urllib.parse
+
+
+def _url_target(href: str) -> str:
+ """Return a stable, sanitised target name derived from the URL."""
+ parsed = urllib.parse.urlparse(href)
+ raw = (parsed.netloc + parsed.path).strip("/")
+ safe = re.sub(r"[^a-zA-Z0-9]", "-", raw)
+ safe = re.sub(r"-{2,}", "-", safe).strip("-")
+ return "ext-" + safe[:48]
+
+
+def on_page_content(html, page, config, **kwargs):
+ site = config.get("site_url", "")
+
+ def process(match):
+ tag = match.group(0)
+ attrs = match.group(1)
+
+ href_m = re.search(r'href="([^"]*)"', attrs)
+ if not href_m:
+ return tag
+
+ href = href_m.group(1)
+
+ # Skip non-HTTP links and links to the same site
+ if not href.startswith(("http://", "https://")):
+ return tag
+ if site and href.startswith(site):
+ return tag
+
+ # Skip if target is already set
+ if "target=" in attrs:
+ return tag
+
+ target = _url_target(href)
+ return f''
+
+ return re.sub(r"]+)>", process, html)
diff --git a/mkdocs.yml b/mkdocs.yml
index d314dfd..a8532dd 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -47,7 +47,17 @@ plugins:
name: Français
build: true
+hooks:
+ - hooks.py
+
+extra:
+ version:
+ provider: mike
+ default: latest
+
nav:
- Home: index.md
- Plugins:
- Metal FARP: plugins/metal-farp.md
+ - For authors:
+ - Template: plugins/template.md
diff --git a/plugins/_template/README.md b/plugins/_template/README.md
new file mode 100644
index 0000000..4af506d
--- /dev/null
+++ b/plugins/_template/README.md
@@ -0,0 +1,42 @@
+---
+# Métadonnées documentaires du plugin.
+# Ce fichier est la source de vérité pour la génération des pages docs/plugins/.
+# Les fichiers docs/plugins/{nom}.md et {nom}.fr.md sont générés à partir d'ici
+# via le skill generate-plugin-doc — ne pas les éditer à la main.
+
+# Mods DCS requis pour ce plugin.
+# Supprimer la section modUrls entière si le plugin n'utilise aucun mod.
+# Chaque entrée : nom du type DCS tel que déclaré dans modTypes du .lua, + URL de téléchargement.
+modUrls:
+ - mod: NomDuTypeDCS
+ url: https://github.com/auteur/repo/tree/main/dossier-du-mod
+---
+
+# Nom du plugin (titre affiché sur la page de description)
+
+
+
+Courte description de ce que construit le plugin (1-2 phrases). Par exemple : "Construit un FARP
+autour d'une hélisurface métallique, avec le mobilier FARP habituel."
+
+## Prérequis
+
+- **CTLD** ≥ X.Y.Z chargé en premier.
+- Le mod DCS **`NomDuTypeDCS`** installé sur **tous** les clients.
+ Sans lui, [décrire l'effet visible : l'objet X ne peut pas apparaître].
+
+
+
+## Installation
+
+1. Téléchargez le fichier `.lua` du plugin (bouton ci-dessus).
+2. Dans l'éditeur de mission, ajoutez un déclencheur `DO SCRIPT FILE` au **démarrage de la
+ mission**, **après** le déclencheur qui charge `CTLD.lua`.
+3. [Décrire comment l'utilisateur active la scène — ex : "La scène ajoute une caisse dans le menu
+ *Request Equipment* de CTLD ; déployez-la comme n'importe quelle caisse de scène FARP."]
+
+## Remarques
+
+
+
+[Avertissements, limitations connues, comportements non évidents pour le mission maker.]
diff --git a/plugins/_template/src/CTLD_templateScene.lua b/plugins/_template/src/CTLD_templateScene.lua
new file mode 100644
index 0000000..339aafa
--- /dev/null
+++ b/plugins/_template/src/CTLD_templateScene.lua
@@ -0,0 +1,159 @@
+---@diagnostic disable
+-- CTLD_templateScene.lua — REFERENCE plugin scene.
+--
+-- Copy this file to start a new plugin. It exercises EVERY extension point a scene can use, so
+-- whatever your scene needs, there is an example here. It is load-position-independent: the exact
+-- same source works whether merged into CTLD.lua or loaded as a plugin after CTLD.
+--
+-- A plugin scene is loaded from a MISSION START trigger, AFTER CTLD. Structure (by convention):
+-- BLOCK 1 i18n — the 4 mandatory languages for every user-facing string
+-- BLOCK 2 ObjectRegistry — declare every DCS type the scene spawns
+-- BLOCK 3 scene model — metadata, crate, steps
+-- BLOCK 4 menu section — optional F10 radio submenu
+-- BLOCK 5 self-registration (always last)
+--
+-- Dependencies (all provided by the CTLD runtime): ctld, CTLDObjectRegistry, CTLDSceneManager,
+-- CTLDPlayerManager, ctld.MenuManager, ctld.tr, ctld.utils.
+-- ====================================================================================================
+
+-- ====================================================================================================
+-- BLOCK 1 : i18n — every user-facing string, in en / fr / es / ko.
+-- ====================================================================================================
+ctld.i18n["en"]["Template Crate"] = "Template Crate"
+ctld.i18n["fr"]["Template Crate"] = "Caisse Template"
+ctld.i18n["es"]["Template Crate"] = "Caja Plantilla"
+ctld.i18n["ko"]["Template Crate"] = "템플릿 화물"
+
+ctld.i18n["en"]["Deploy Template"] = "Deploy Template"
+ctld.i18n["fr"]["Deploy Template"] = "Déployer le Template"
+ctld.i18n["es"]["Deploy Template"] = "Desplegar Plantilla"
+ctld.i18n["ko"]["Deploy Template"] = "템플릿 배치"
+
+ctld.i18n["en"]["Template"] = "Template"
+ctld.i18n["fr"]["Template"] = "Template"
+ctld.i18n["es"]["Template"] = "Plantilla"
+ctld.i18n["ko"]["Template"] = "템플릿"
+
+ctld.i18n["en"]["Template: say hello"] = "Template: say hello"
+ctld.i18n["fr"]["Template: say hello"] = "Template : dire bonjour"
+ctld.i18n["es"]["Template: say hello"] = "Plantilla: saludar"
+ctld.i18n["ko"]["Template: say hello"] = "템플릿: 인사하기"
+
+ctld.i18n["en"]["Hello from %1!"] = "Hello from %1!"
+ctld.i18n["fr"]["Hello from %1!"] = "Bonjour de la part de %1 !"
+ctld.i18n["es"]["Hello from %1!"] = "¡Hola de parte de %1!"
+ctld.i18n["ko"]["Hello from %1!"] = "%1이(가) 인사합니다!"
+
+ctld.i18n["en"]["--- Template deployed by %1 ---"] = "--- Template deployed by %1 ---"
+ctld.i18n["fr"]["--- Template deployed by %1 ---"] = "--- Template déployé par %1 ---"
+ctld.i18n["es"]["--- Template deployed by %1 ---"] = "--- Plantilla desplegada por %1 ---"
+ctld.i18n["ko"]["--- Template deployed by %1 ---"] = "--- %1이(가) 템플릿을 배치했습니다 ---"
+
+-- ====================================================================================================
+-- BLOCK 2 : ObjectRegistry — declare every DCS type the scene spawns.
+-- registerIfAbsent is a no-op if the key already exists (CTLD pre-registers many common ones).
+-- Use only STOCK types, OR declare mod types in model.modTypes (BLOCK 3) so the asset gate accepts
+-- them while still validating every other type.
+-- ====================================================================================================
+CTLDObjectRegistry.registerIfAbsent("Windsock", {
+ groupType = "STATIC", namePrefix = "Windsock", type = "Windsock", category = "Fortifications",
+})
+CTLDObjectRegistry.registerIfAbsent("FARP_Tent", {
+ groupType = "STATIC", namePrefix = "Tent", type = "FARP Tent", category = "Fortifications",
+})
+
+-- ====================================================================================================
+-- BLOCK 3 : scene model — metadata + crate + steps.
+-- ====================================================================================================
+local templateScene = {}
+templateScene.name = "Template"
+
+-- Minimum CTLD version providing the plugin machinery; CTLD warns at load if it is older.
+templateScene.requiresCtld = "2.0.0"
+
+-- Non-stock (mod) DCS types this scene spawns. Empty here (all stock). If your scene uses a mod
+-- static/unit, list its exact type name(s): the asset gate then accepts them while still catching
+-- typos in every stock type. Also set requiresMod = "" for the catalogue.
+templateScene.modTypes = {}
+
+-- Crate: auto-injected into the CTLD "Request Equipment" menu (weight is the 1001.xx handle).
+templateScene.crate = {
+ weight = 1001.50,
+ i18nKey = "Template Crate",
+ deployKey = "Deploy Template",
+ cratesRequired = 1,
+ side = nil,
+ showSets = false,
+}
+
+templateScene.steps = {
+ -- polar step: deterministic position relative to the trigger unit snapshot.
+ {
+ registryKey = "Windsock",
+ polar = { distance = 15, angle = 0 },
+ relativeHeadingInDegrees = 0,
+ relativeAltitudeInMeters = 0,
+ delayAfterPreviousStep = 0,
+ },
+ {
+ registryKey = "FARP_Tent",
+ polar = { distance = 20, angle = 90 },
+ relativeHeadingInDegrees = 0,
+ relativeAltitudeInMeters = 0,
+ delayAfterPreviousStep = 1,
+ },
+ -- func step: no spawn, a post-spawn hook. ctx.scene / ctx.unit / ctx.spawnedObj are available.
+ {
+ delayAfterPreviousStep = 0,
+ func = function(ctx)
+ if trigger and trigger.action and trigger.action.outText then
+ trigger.action.outText(
+ ctld.tr("--- Template deployed by %1 ---", ctx.unit:getName()), 10)
+ end
+ end,
+ },
+}
+
+-- ====================================================================================================
+-- BLOCK 4 : F10 radio submenu (optional).
+-- deferMenuSection works whether the scene loads before or after CTLD init (load-position-
+-- independent). buildTemplateSection creates the container once per player; refreshTemplateSection
+-- (re)fills it — called on menu build and on land.
+-- ====================================================================================================
+function templateScene:refreshTemplateSection(playerObj)
+ local mm = ctld.MenuManager:getInstance()
+ local menu = mm:getMenuByGroupId(playerObj.groupId)
+ if not menu then return end
+
+ local root = ctld.tr("CTLD")
+ local sub = ctld.tr("Template")
+ menu:clearBranch({ root, sub })
+
+ menu:addCommand({ root, sub }, ctld.tr("Template: say hello"),
+ function(arg)
+ local u = Unit.getByName(arg.unitName)
+ local who = (u and u:isExist()) and u:getName() or arg.unitName
+ trigger.action.outText(ctld.tr("Hello from %1!", who), 10)
+ end,
+ { unitName = playerObj.unitName })
+end
+
+function templateScene:buildTemplateSection(playerObj, menu)
+ local root = ctld.tr("CTLD")
+ local sub = ctld.tr("Template")
+ menu:addSubMenu({ root }, sub, { order = 80 })
+ self:refreshTemplateSection(playerObj)
+end
+
+-- ====================================================================================================
+-- BLOCK 5 : self-registration (always last).
+-- ====================================================================================================
+CTLDSceneManager.getInstance():registerSceneModel(templateScene)
+
+CTLDPlayerManager.deferMenuSection({
+ key = "template_section",
+ manager = templateScene,
+ method = "buildTemplateSection",
+ refreshMethod = "refreshTemplateSection",
+ order = 80,
+})
diff --git a/plugins/_template/tests/template_spec.lua b/plugins/_template/tests/template_spec.lua
new file mode 100644
index 0000000..1ac0bde
--- /dev/null
+++ b/plugins/_template/tests/template_spec.lua
@@ -0,0 +1,50 @@
+---@diagnostic disable
+-- plugins/_template/tests/template_spec.lua
+-- Smoke test for the reference template scene: it registers its model, injects a crate, and wires a
+-- radio submenu — proving the full extension surface (and CTLD's load-position-independent
+-- deferMenuSection) works for a plugin loaded after CTLD.
+-- ============================================================
+
+local ROOT = debug.getinfo(1, "S").source:match("^@(.+)plugins[\\/]") or ""
+
+describe("template plugin scene", function()
+
+ setup(function()
+ dofile(ROOT .. "plugins/_template/src/CTLD_templateScene.lua")
+ end)
+
+ -- Sections live on the instance (_instance._menuSections) once CTLD is initialised, or in the
+ -- class-level pre-init queue (_deferredSections) if not. deferMenuSection routes to whichever
+ -- applies (the load-position-independent fix), so accept either.
+ local function menuSectionWired(key)
+ local inst = CTLDPlayerManager._instance
+ if inst and inst._menuSections then
+ for _, s in ipairs(inst._menuSections) do if s.key == key then return true end end
+ end
+ for _, s in ipairs(CTLDPlayerManager._deferredSections or {}) do
+ if s.key == key then return true end
+ end
+ return false
+ end
+
+ it("registers the Template scene model", function()
+ assert.is_true(CTLDSceneManager.getInstance():isSceneEnabled("Template"))
+ end)
+
+ it("declares a crate for the Request Equipment menu", function()
+ local model = CTLDSceneManager.getInstance():getModel("Template")
+ assert.is_not_nil(model.crate)
+ assert.is_not_nil(model.crate.weight)
+ end)
+
+ it("declares requiresCtld and an (empty) modTypes list", function()
+ local model = CTLDSceneManager.getInstance():getModel("Template")
+ assert.equals("2.0.0", model.requiresCtld)
+ assert.equals(0, #model.modTypes)
+ end)
+
+ it("wires the F10 radio submenu via deferMenuSection", function()
+ assert.is_true(menuSectionWired("template_section"))
+ end)
+
+end)
diff --git a/plugins/metal-farp/README.md b/plugins/metal-farp/README.md
new file mode 100644
index 0000000..b955830
--- /dev/null
+++ b/plugins/metal-farp/README.md
@@ -0,0 +1,33 @@
+---
+# Métadonnées documentaires du plugin.
+# Ce fichier est la source de vérité pour la génération des pages docs/plugins/.
+# Les fichiers docs/plugins/{nom}.md et {nom}.fr.md sont générés à partir d'ici
+# via le skill generate-plugin-doc — ne pas les éditer à la main.
+
+# Mods DCS requis pour ce plugin.
+# Supprimer la section modUrls entière si le plugin n'utilise aucun mod.
+# Chaque entrée : nom du type DCS tel que déclaré dans modTypes du .lua, + URL de téléchargement.
+modUrls:
+ - mod: FG_small_Helipad
+ url: https://github.com/FullGas1/DCS-mods/tree/main/FG_small_Helipad
+---
+
+# Metal FARP
+
+Ce plugin permet via CTLD de faire apparaître un héliport métallique de façon progressive pour simuler sa construction, en y ajoutant des objets pour obtenir un héliport "décoré" plus réaliste.
+
+## Prérequis
+
+- **CTLD** ≥ 2.0.0 chargé en premier.
+- Le mod DCS **`FG_small_Helipad`** installé sur **tous** les clients.
+ Sans lui, l'héliport ne peut pas apparaître.
+
+## Installation
+
+1. Téléchargez le fichier `.lua` du plugin (bouton ci-dessus).
+2. Dans l'éditeur de mission, ajoutez un déclencheur `DO SCRIPT FILE` au **démarrage de la
+ mission**, **après** le déclencheur qui charge `CTLD.lua`.
+3. La scène ajoute une caisse dans le menu
+ *Request Equipment* de CTLD ; déployez-la comme n'importe quelle caisse de scène FARP.
+
+## Remarques
diff --git a/plugins/metal-farp/src/CTLD_metalFarpScene.lua b/plugins/metal-farp/src/CTLD_metalFarpScene.lua
index 3215cab..84eba1e 100644
--- a/plugins/metal-farp/src/CTLD_metalFarpScene.lua
+++ b/plugins/metal-farp/src/CTLD_metalFarpScene.lua
@@ -1,14 +1,14 @@
---@diagnostic disable
-- CTLD_metalFarpScene.lua
-- Metal FARP deployment scene — compact forward arming/refueling point using the
--- Farp_FG_Petit_Helipad mod (visible metallic helipad platform).
+-- FG_small_Helipad mod (visible metallic helipad platform).
--
--- Requires the Farp_FG_Petit_Helipad mod to be installed on all clients.
+-- Requires the FG_small_Helipad mod to be installed on all clients.
-- probeSkip=true is set on the registry entry — the mod cannot be validated at runtime
-- (DCS getDesc().life == 0 whether the mod is installed or not).
--
-- Layout (all offsets from trigger unit position):
--- Farp_FG_Petit_Helipad heliport — 58 m ahead of trigger unit
+-- FG_small_Helipad heliport — 58 m ahead of trigger unit
-- Fuel truck — 35 m / 8° heading 90° (t+5 s)
-- Repair truck — 35 m / 11° heading 90° (t+5 s)
-- Tent — 35 m / 10° heading 90° (t+5.5 s)
@@ -24,32 +24,32 @@
-- BLOCK 1 : i18n -- 4 mandatory languages
-- ====================================================================================================
-ctld.i18n["en"]["Metal FARP Crate"] = "Metal FARP Crate"
-ctld.i18n["fr"]["Metal FARP Crate"] = "Caisse FARP Métal"
-ctld.i18n["es"]["Metal FARP Crate"] = "Caja FARP Metal"
-ctld.i18n["ko"]["Metal FARP Crate"] = "메탈 FARP 화물"
+ctld.i18n["en"]["Metal FARP Crate"] = "Metal FARP Crate"
+ctld.i18n["fr"]["Metal FARP Crate"] = "Caisse FARP Métal"
+ctld.i18n["es"]["Metal FARP Crate"] = "Caja FARP Metal"
+ctld.i18n["ko"]["Metal FARP Crate"] = "메탈 FARP 화물"
-ctld.i18n["en"]["Deploy Metal FARP"] = "Deploy Metal FARP"
-ctld.i18n["fr"]["Deploy Metal FARP"] = "Déployer FARP Métal"
-ctld.i18n["es"]["Deploy Metal FARP"] = "Desplegar FARP Metal"
-ctld.i18n["ko"]["Deploy Metal FARP"] = "메탈 FARP 배치"
+ctld.i18n["en"]["Deploy Metal FARP"] = "Deploy Metal FARP"
+ctld.i18n["fr"]["Deploy Metal FARP"] = "Déployer FARP Métal"
+ctld.i18n["es"]["Deploy Metal FARP"] = "Desplegar FARP Metal"
+ctld.i18n["ko"]["Deploy Metal FARP"] = "메탈 FARP 배치"
-ctld.i18n["en"]["--- Metal FARP Deployment by %1 : Complete! ---"] = "--- Metal FARP Deployment by %1 : Complete! ---"
-ctld.i18n["fr"]["--- Metal FARP Deployment by %1 : Complete! ---"] = "--- Déploiement FARP Métal par %1 : Terminé ! ---"
-ctld.i18n["es"]["--- Metal FARP Deployment by %1 : Complete! ---"] = "--- Despliegue FARP Metal por %1 : ¡Completo! ---"
-ctld.i18n["ko"]["--- Metal FARP Deployment by %1 : Complete! ---"] = "--- %1에 의한 메탈 FARP 배치 완료! ---"
+ctld.i18n["en"]["--- Metal FARP Deployment by %1 : Complete! ---"] = "--- Metal FARP Deployment by %1 : Complete! ---"
+ctld.i18n["fr"]["--- Metal FARP Deployment by %1 : Complete! ---"] = "--- Déploiement FARP Métal par %1 : Terminé ! ---"
+ctld.i18n["es"]["--- Metal FARP Deployment by %1 : Complete! ---"] = "--- Despliegue FARP Metal por %1 : ¡Completo! ---"
+ctld.i18n["ko"]["--- Metal FARP Deployment by %1 : Complete! ---"] = "--- %1에 의한 메탈 FARP 배치 완료! ---"
-- ====================================================================================================
-- BLOCK 2 : Registry entries required by this scene.
-- registerIfAbsent() is a no-op when the key already exists.
-- ====================================================================================================
-CTLDObjectRegistry.registerIfAbsent("Farp_FG_Petit_Helipad", {
+CTLDObjectRegistry.registerIfAbsent("FG_small_Helipad", {
groupType = "STATIC",
namePrefix = "FARP_Helipad",
- type = "Farp_FG_Petit_Helipad",
+ type = "FG_small_Helipad",
category = "Heliports",
- shape_name = "Farp_FG_Petit_Helipad.edm",
+ shape_name = "FG_small_Helipad.edm",
heliport_frequency = "127.5",
heliport_callsign_id = 1,
heliport_modulation = 0,
@@ -63,14 +63,16 @@ CTLDObjectRegistry.registerIfAbsent("Fuel_Truck", {
namePrefix = "Fuel_Truck_Grp",
task = "Ground Nothing",
category = Unit.Category.GROUND_UNIT,
- units = {
+ units = {
{
- namePrefix = "Fuel_Truck_Unit",
- unitType = function(cid)
+ namePrefix = "Fuel_Truck_Unit",
+ unitType = function(cid)
return cid == coalition.side.RED and "ATZ-10" or "M978 HEMTT Tanker"
end,
playerCanDrive = false,
- dx = 0, dz = 0, dh = 0,
+ dx = 0,
+ dz = 0,
+ dh = 0,
},
},
})
@@ -80,14 +82,16 @@ CTLDObjectRegistry.registerIfAbsent("repare_Truck", {
namePrefix = "repare_Truck_Grp",
task = "Ground Nothing",
category = Unit.Category.GROUND_UNIT,
- units = {
+ units = {
{
- namePrefix = "repare_Truck_Unit",
- unitType = function(cid)
+ namePrefix = "repare_Truck_Unit",
+ unitType = function(cid)
return cid == coalition.side.RED and "Ural-375" or "M 818"
end,
playerCanDrive = false,
- dx = 0, dz = 0, dh = 0,
+ dx = 0,
+ dz = 0,
+ dh = 0,
},
},
})
@@ -132,17 +136,17 @@ CTLDObjectRegistry.registerIfAbsent("Windsock", {
-- BLOCK 3 : scene model + crate descriptor
-- ====================================================================================================
-local metalFarpScene = {}
-metalFarpScene.name = "Metal FARP"
-metalFarpScene.requiresMod = "Farp_FG_Petit_Helipad" -- human-readable required-mod label (docs/catalogue)
+local metalFarpScene = {}
+metalFarpScene.name = "Metal FARP"
+metalFarpScene.requiresMod = "FG_small_Helipad" -- human-readable required-mod label (docs/catalogue)
-- Non-stock (mod) DCS types this scene spawns. Added to the known set by the design-time
-- asset hard-gate (datamine ∪ modTypes) so validation still catches typos in every stock type.
-metalFarpScene.modTypes = { "Farp_FG_Petit_Helipad" }
+metalFarpScene.modTypes = { "FG_small_Helipad" }
-- Minimum CTLD version providing the plugin-scene machinery (load-position-independent menus,
-- requiresCtld check). CTLD warns at load if it is older.
metalFarpScene.requiresCtld = "2.0.0"
-metalFarpScene.crate = {
+metalFarpScene.crate = {
weight = 1001.26,
i18nKey = "Metal FARP Crate",
deployKey = "Deploy Metal FARP",
@@ -152,10 +156,10 @@ metalFarpScene.crate = {
showSets = false,
}
-metalFarpScene.steps = {
+metalFarpScene.steps = {
-- ----------------------------------------------------------------
- -- Step 1: Farp_FG_Petit_Helipad heliport (delay=0).
+ -- Step 1: FG_small_Helipad heliport (delay=0).
-- Spawned 50 m ahead of the trigger unit to avoid overlapping it.
-- Saves the spawned airbase name for the warehouse-stocking step.
-- critical=true: if the mod is absent the helipad cannot spawn; abort the whole scene
@@ -166,9 +170,9 @@ metalFarpScene.steps = {
delayAfterPreviousStep = 0,
relativeHeadingInDegrees = 0,
relativeAltitudeInMeters = 0,
- registryKey = "Farp_FG_Petit_Helipad",
- critical = true,
- func = function(ctx)
+ registryKey = "FG_small_Helipad",
+ critical = true,
+ func = function(ctx)
if not ctx.spawnedObj then return false end
ctx.scene._params.farpName = ctx.spawnedObj:getName()
return true
@@ -183,7 +187,7 @@ metalFarpScene.steps = {
delayAfterPreviousStep = 5,
relativeHeadingInDegrees = 90,
relativeAltitudeInMeters = 0,
- registryKey = "Fuel_Truck",
+ registryKey = "Fuel_Truck",
},
-- ----------------------------------------------------------------
@@ -194,7 +198,7 @@ metalFarpScene.steps = {
delayAfterPreviousStep = 0,
relativeHeadingInDegrees = 90,
relativeAltitudeInMeters = 0,
- registryKey = "repare_Truck",
+ registryKey = "repare_Truck",
},
-- ----------------------------------------------------------------
@@ -205,7 +209,7 @@ metalFarpScene.steps = {
delayAfterPreviousStep = 0.5,
relativeHeadingInDegrees = 90,
relativeAltitudeInMeters = 0,
- registryKey = "FARP_Tent",
+ registryKey = "FARP_Tent",
},
-- ----------------------------------------------------------------
@@ -216,7 +220,7 @@ metalFarpScene.steps = {
delayAfterPreviousStep = 4.5,
relativeHeadingInDegrees = 0,
relativeAltitudeInMeters = 0,
- registryKey = "ammo_cargo",
+ registryKey = "ammo_cargo",
},
-- ----------------------------------------------------------------
@@ -227,7 +231,7 @@ metalFarpScene.steps = {
delayAfterPreviousStep = 5,
relativeHeadingInDegrees = 310,
relativeAltitudeInMeters = 4,
- registryKey = "NF-2_LightOn",
+ registryKey = "NF-2_LightOn",
},
-- ----------------------------------------------------------------
@@ -238,7 +242,7 @@ metalFarpScene.steps = {
delayAfterPreviousStep = 0,
relativeHeadingInDegrees = 220,
relativeAltitudeInMeters = 0,
- registryKey = "Windsock",
+ registryKey = "Windsock",
},
-- ----------------------------------------------------------------
@@ -249,7 +253,7 @@ metalFarpScene.steps = {
delayAfterPreviousStep = 0,
relativeHeadingInDegrees = 90,
relativeAltitudeInMeters = 0,
- registryKey = "us carrier shooter",
+ registryKey = "us carrier shooter",
},
-- ----------------------------------------------------------------
@@ -266,16 +270,16 @@ metalFarpScene.steps = {
local w = ab:getWarehouse()
-- If this is a redeployed FARP, restore the snapshot; otherwise stock defaults.
local snap = ctx.scene._params.repackData
- and ctx.scene._params.repackData.warehouseSnapshot
+ and ctx.scene._params.repackData.warehouseSnapshot
if snap and snap.liquid then
for fuelType = 0, 3 do
w:setLiquidAmount(fuelType, snap.liquid[fuelType] or 0)
end
else
- w:addLiquid(0, 10000) -- jet fuel
- w:addLiquid(1, 10000) -- aviation gasoline
- w:addLiquid(2, 10000) -- MW50
- w:addLiquid(3, 10000) -- diesel
+ w:addLiquid(0, 10000) -- jet fuel
+ w:addLiquid(1, 10000) -- aviation gasoline
+ w:addLiquid(2, 10000) -- MW50
+ w:addLiquid(3, 10000) -- diesel
end
end
end
@@ -291,7 +295,7 @@ metalFarpScene.steps = {
-- Captures the current warehouse fuel levels so they can be restored on next deployment.
-- ====================================================================================================
-metalFarpScene.onRepack = function(scene, repackData)
+metalFarpScene.onRepack = function(scene, repackData)
local farpName = scene._params and scene._params.farpName
if not farpName then return end
local ab = Airbase.getByName(farpName)
@@ -299,10 +303,10 @@ metalFarpScene.onRepack = function(scene, repackData)
local w = ab:getWarehouse()
repackData.warehouseSnapshot = {
liquid = {
- [0] = w:getLiquidAmount(0), -- jet fuel
- [1] = w:getLiquidAmount(1), -- aviation gasoline
- [2] = w:getLiquidAmount(2), -- MW50
- [3] = w:getLiquidAmount(3), -- diesel
+ [0] = w:getLiquidAmount(0), -- jet fuel
+ [1] = w:getLiquidAmount(1), -- aviation gasoline
+ [2] = w:getLiquidAmount(2), -- MW50
+ [3] = w:getLiquidAmount(3), -- diesel
}
}
end
diff --git a/tests/scene_asset_gate_spec.lua b/tests/scene_asset_gate_spec.lua
index 5401e93..1ca1259 100644
--- a/tests/scene_asset_gate_spec.lua
+++ b/tests/scene_asset_gate_spec.lua
@@ -20,17 +20,36 @@ local ROOT = debug.getinfo(1, "S").source:match("^@(.+)tests[\\/][^\\/]+_spec%.l
local PLUGIN_SCENES = {
"plugins/metal-farp/src/CTLD_metalFarpScene.lua",
+ "plugins/_template/src/CTLD_templateScene.lua",
}
+-- STATIC → desc.type; GROUND → desc.units[i].unitType(coalitionId) (a per-coalition function),
+-- with a static desc.units[i].type fallback. Kept self-contained so this gate stays copyable;
+-- CTLD ships the same logic as CTLDTypeCollector.typesOfDescriptor.
local function spawnedTypesOf(desc)
- local out = {}
+ local out, seen = {}, {}
if type(desc) ~= "table" then return out end
- if desc.groupType == "GROUND" and type(desc.units) == "table" then
+ local function push(tn)
+ if type(tn) == "string" and tn ~= "" and not seen[tn] then
+ seen[tn] = true
+ out[#out + 1] = tn
+ end
+ end
+ if desc.groupType == "STATIC" then
+ push(desc.type)
+ elseif desc.groupType == "GROUND" and type(desc.units) == "table" then
for _, u in ipairs(desc.units) do
- if type(u) == "table" and u.type then out[#out + 1] = u.type end
+ if type(u) == "table" then
+ if type(u.unitType) == "function" then
+ for _, cid in ipairs({ 1, 2 }) do
+ local ok, tn = pcall(u.unitType, cid)
+ if ok then push(tn) end
+ end
+ else
+ push(u.type)
+ end
+ end
end
- elseif desc.type then
- out[#out + 1] = desc.type
end
return out
end
@@ -111,7 +130,7 @@ describe("plugin scene asset hard-gate", function()
end)
it("metalFarp's mod type is declared via modTypes", function()
- assert.is_true(modUnion["Farp_FG_Petit_Helipad"] == true)
+ assert.is_true(modUnion["FG_small_Helipad"] == true)
end)
end)
diff --git a/tools/ci/validate_docs.py b/tools/ci/validate_docs.py
new file mode 100644
index 0000000..23ebc35
--- /dev/null
+++ b/tools/ci/validate_docs.py
@@ -0,0 +1,116 @@
+#!/usr/bin/env python3
+"""
+validate_docs.py — CI gate for plugin documentation coherence.
+
+For each plugin directory under plugins/ (excluding _template):
+ - WARNING if plugins/{name}/README.md is absent
+ - ERROR if README.md is present but front-matter YAML is malformed
+ - ERROR if README.md is present and modUrls is not a list of {mod, url} pairs
+ - ERROR if docs/plugins/{name}.md or docs/plugins/{name}.fr.md is absent
+"""
+
+import sys
+import os
+import re
+
+PLUGINS_DIR = "plugins"
+DOCS_DIR = os.path.join("docs", "plugins")
+TEMPLATE = "_template"
+
+errors = []
+warnings = []
+
+
+def parse_frontmatter(path):
+ """Return parsed front-matter dict, None if absent, or Exception if malformed."""
+ with open(path, encoding="utf-8") as f:
+ content = f.read()
+
+ if not content.startswith("---\n"):
+ return None # front-matter truly absent
+
+ # Opening --- found: expect a closing --- too
+ match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
+ if not match:
+ return Exception("opening '---' found but closing '---' is missing or malformed")
+
+ raw_yaml = match.group(1)
+ # Remove YAML comment lines
+ yaml_lines = [l for l in raw_yaml.splitlines() if not l.strip().startswith("#")]
+ clean_yaml = "\n".join(yaml_lines)
+
+ try:
+ import yaml
+ return yaml.safe_load(clean_yaml) or {}
+ except Exception as e:
+ return e # signal parse error
+
+
+def validate_mod_urls(mod_urls):
+ """Return error message if modUrls is malformed, else None."""
+ if not isinstance(mod_urls, list):
+ return "modUrls must be a list"
+ for i, entry in enumerate(mod_urls):
+ if not isinstance(entry, dict):
+ return f"modUrls[{i}] must be a dict with 'mod' and 'url' keys"
+ if "mod" not in entry or "url" not in entry:
+ return f"modUrls[{i}] is missing 'mod' or 'url' key"
+ return None
+
+
+def main():
+ if not os.path.isdir(PLUGINS_DIR):
+ print(f"ERROR: {PLUGINS_DIR}/ directory not found — run from repo root")
+ sys.exit(1)
+
+ plugins = [
+ p for p in os.listdir(PLUGINS_DIR)
+ if os.path.isdir(os.path.join(PLUGINS_DIR, p)) and p != TEMPLATE
+ ]
+
+ if not plugins:
+ print("No plugins found (excluding _template) — nothing to validate.")
+ sys.exit(0)
+
+ for plugin in sorted(plugins):
+ readme_path = os.path.join(PLUGINS_DIR, plugin, "README.md")
+ doc_en = os.path.join(DOCS_DIR, f"{plugin}.md")
+ doc_fr = os.path.join(DOCS_DIR, f"{plugin}.fr.md")
+
+ # README absent → warning only
+ if not os.path.isfile(readme_path):
+ warnings.append(f"[{plugin}] README.md absent — doc pages will have no narrative prose")
+ else:
+ fm = parse_frontmatter(readme_path)
+ if fm is None:
+ errors.append(f"[{plugin}] README.md has no YAML front-matter")
+ elif isinstance(fm, Exception):
+ errors.append(f"[{plugin}] README.md front-matter is malformed: {fm}")
+ else:
+ mod_urls = fm.get("modUrls")
+ if mod_urls is not None:
+ err = validate_mod_urls(mod_urls)
+ if err:
+ errors.append(f"[{plugin}] README.md modUrls invalid: {err}")
+
+ # Generated doc pages must exist
+ for doc_path in (doc_en, doc_fr):
+ if not os.path.isfile(doc_path):
+ errors.append(f"[{plugin}] missing generated doc: {doc_path}")
+
+ # Report
+ for w in warnings:
+ print(f"WARNING: {w}")
+ for e in errors:
+ print(f"ERROR: {e}")
+
+ if errors:
+ print(f"\n{len(errors)} error(s) — fix before merging.")
+ sys.exit(1)
+ else:
+ print(f"OK — {len(plugins)} plugin(s) checked, {len(warnings)} warning(s), 0 errors.")
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/vendor/CTLD.lua b/vendor/CTLD.lua
index ce00718..611ede4 100644
--- a/vendor/CTLD.lua
+++ b/vendor/CTLD.lua
@@ -6090,21 +6090,6 @@ CTLDObjectRegistry._db = {
rate = 100,
},
- ["Farp_FG_Petit_Helipad"] = { -- specific mod
- groupType = "STATIC",
- namePrefix = "FARP_Helipad",
- type = "Farp_FG_Petit_Helipad",
- category = "Heliports",
- shape_name = "Farp_FG_Petit_Helipad.edm",
- heliport_frequency = "127.5",
- heliport_callsign_id = 1,
- heliport_modulation = 0,
- -- DCS scripting API limitation: for custom mod heliports, getDesc().life == 0 whether the mod
- -- is installed or not (identical to an invalid type). No reliable discriminant exists.
- -- probeSkip suppresses the false NOT FOUND alarm; the mod cannot be validated at runtime.
- probeSkip = true,
- },
-
-- ------------------------------------------------------------------
-- FORTIFICATIONS
-- ------------------------------------------------------------------
@@ -23434,321 +23419,6 @@ CTLDSceneManager.getInstance():registerSceneModel(farpAlphaScene)
-- End : scenes/CTLD_farpAlphaScene.lua
-- ====================================================================================================
--- Start : scenes/CTLD_metalFarpScene.lua
----@diagnostic disable
--- CTLD_metalFarpScene.lua
--- Metal FARP deployment scene — compact forward arming/refueling point using the
--- Farp_FG_Petit_Helipad mod (visible metallic helipad platform).
---
--- Requires the Farp_FG_Petit_Helipad mod to be installed on all clients.
--- probeSkip=true is set on the registry entry — the mod cannot be validated at runtime
--- (DCS getDesc().life == 0 whether the mod is installed or not).
---
--- Layout (all offsets from trigger unit position):
--- Farp_FG_Petit_Helipad heliport — 58 m ahead of trigger unit
--- Fuel truck — 35 m / 8° heading 90° (t+5 s)
--- Repair truck — 35 m / 11° heading 90° (t+5 s)
--- Tent — 35 m / 10° heading 90° (t+5.5 s)
--- Ammo cargo — 75 m / 346° (t+10 s)
--- M92 light panel — 75 m / 355° alt+4 m (t+15 s)
--- Windsock — 73 m / 346° (t+15 s)
--- Warehouse stocking — 10 000 L × 4 fuel types (t+20 s)
---
--- Dependencies: CTLDObjectRegistry, CTLDSceneManager, CTLDUtils
--- ====================================================================================================
-
--- ====================================================================================================
--- BLOCK 1 : i18n -- 4 mandatory languages
--- ====================================================================================================
-
-ctld.i18n["en"]["Metal FARP Crate"] = "Metal FARP Crate"
-ctld.i18n["fr"]["Metal FARP Crate"] = "Caisse FARP Métal"
-ctld.i18n["es"]["Metal FARP Crate"] = "Caja FARP Metal"
-ctld.i18n["ko"]["Metal FARP Crate"] = "메탈 FARP 화물"
-
-ctld.i18n["en"]["Deploy Metal FARP"] = "Deploy Metal FARP"
-ctld.i18n["fr"]["Deploy Metal FARP"] = "Déployer FARP Métal"
-ctld.i18n["es"]["Deploy Metal FARP"] = "Desplegar FARP Metal"
-ctld.i18n["ko"]["Deploy Metal FARP"] = "메탈 FARP 배치"
-
-ctld.i18n["en"]["--- Metal FARP Deployment by %1 : Complete! ---"] = "--- Metal FARP Deployment by %1 : Complete! ---"
-ctld.i18n["fr"]["--- Metal FARP Deployment by %1 : Complete! ---"] = "--- Déploiement FARP Métal par %1 : Terminé ! ---"
-ctld.i18n["es"]["--- Metal FARP Deployment by %1 : Complete! ---"] = "--- Despliegue FARP Metal por %1 : ¡Completo! ---"
-ctld.i18n["ko"]["--- Metal FARP Deployment by %1 : Complete! ---"] = "--- %1에 의한 메탈 FARP 배치 완료! ---"
-
--- ====================================================================================================
--- BLOCK 2 : Registry entries required by this scene.
--- registerIfAbsent() is a no-op when the key already exists.
--- ====================================================================================================
-
-CTLDObjectRegistry.registerIfAbsent("Farp_FG_Petit_Helipad", {
- groupType = "STATIC",
- namePrefix = "FARP_Helipad",
- type = "Farp_FG_Petit_Helipad",
- category = "Heliports",
- shape_name = "Farp_FG_Petit_Helipad.edm",
- heliport_frequency = "127.5",
- heliport_callsign_id = 1,
- heliport_modulation = 0,
- -- DCS scripting API limitation: getDesc().life == 0 whether the mod is installed or not.
- -- probeSkip suppresses the false NOT FOUND alarm from CTLDModValidator.
- probeSkip = true,
-})
-
-CTLDObjectRegistry.registerIfAbsent("Fuel_Truck", {
- groupType = "GROUND",
- namePrefix = "Fuel_Truck_Grp",
- task = "Ground Nothing",
- category = Unit.Category.GROUND_UNIT,
- units = {
- {
- namePrefix = "Fuel_Truck_Unit",
- unitType = function(cid)
- return cid == coalition.side.RED and "ATZ-10" or "M978 HEMTT Tanker"
- end,
- playerCanDrive = false,
- dx = 0, dz = 0, dh = 0,
- },
- },
-})
-
-CTLDObjectRegistry.registerIfAbsent("repare_Truck", {
- groupType = "GROUND",
- namePrefix = "repare_Truck_Grp",
- task = "Ground Nothing",
- category = Unit.Category.GROUND_UNIT,
- units = {
- {
- namePrefix = "repare_Truck_Unit",
- unitType = function(cid)
- return cid == coalition.side.RED and "Ural-375" or "M 818"
- end,
- playerCanDrive = false,
- dx = 0, dz = 0, dh = 0,
- },
- },
-})
-
-CTLDObjectRegistry.registerIfAbsent("FARP_Tent", {
- groupType = "STATIC",
- namePrefix = "FARP_Tent",
- type = "FARP Tent",
- category = "Fortifications",
-})
-
-CTLDObjectRegistry.registerIfAbsent("ammo_cargo", {
- groupType = "STATIC",
- namePrefix = "ammo_box_cargo",
- type = "ammo_cargo",
- category = "Cargos",
- shape_name = "ammo_box_cargo",
- rate = 1,
-})
-
-CTLDObjectRegistry.registerIfAbsent("NF-2_LightOn", {
- groupType = "STATIC",
- namePrefix = "LightOn",
- type = "NF-2_LightOn",
- category = "Fortifications",
- shape_name = "M92_NF-2_LightOn",
- rate = 100,
-})
-
-CTLDObjectRegistry.registerIfAbsent("Windsock", {
- groupType = "STATIC",
- namePrefix = "Windsock",
- type = "Windsock",
- category = "Fortifications",
- shape_name = "H-Windsock_RW",
- rate = 3,
-})
-
--- "us carrier shooter" is already registered in the global CTLDObjectRegistry default entries.
-
--- ====================================================================================================
--- BLOCK 3 : scene model + crate descriptor
--- ====================================================================================================
-
-local metalFarpScene = {}
-metalFarpScene.name = "Metal FARP"
-metalFarpScene.requiresMod = "Farp_FG_Petit_Helipad" -- human-readable required-mod label (docs/catalogue)
--- Non-stock (mod) DCS types this scene spawns. Added to the known set by the design-time
--- asset hard-gate (datamine ∪ modTypes) so validation still catches typos in every stock type.
-metalFarpScene.modTypes = { "Farp_FG_Petit_Helipad" }
-
-metalFarpScene.crate = {
- weight = 1001.26,
- i18nKey = "Metal FARP Crate",
- deployKey = "Deploy Metal FARP",
- groundKey = "You must be on the ground to deploy a FARP.",
- cratesRequired = 1,
- side = nil,
- showSets = false,
-}
-
-metalFarpScene.steps = {
-
- -- ----------------------------------------------------------------
- -- Step 1: Farp_FG_Petit_Helipad heliport (delay=0).
- -- Spawned 50 m ahead of the trigger unit to avoid overlapping it.
- -- Saves the spawned airbase name for the warehouse-stocking step.
- -- critical=true: if the mod is absent the helipad cannot spawn; abort the whole scene
- -- rather than deploying trucks and a tent with no landing pad.
- -- ----------------------------------------------------------------
- {
- polar = { distance = 58, angle = 0 },
- delayAfterPreviousStep = 0,
- relativeHeadingInDegrees = 0,
- relativeAltitudeInMeters = 0,
- registryKey = "Farp_FG_Petit_Helipad",
- critical = true,
- func = function(ctx)
- if not ctx.spawnedObj then return false end
- ctx.scene._params.farpName = ctx.spawnedObj:getName()
- return true
- end,
- },
-
- -- ----------------------------------------------------------------
- -- Step 2: Fuel truck — right side under tent (t0 + 5 s).
- -- ----------------------------------------------------------------
- {
- polar = { distance = 60, angle = 342.5 },
- delayAfterPreviousStep = 5,
- relativeHeadingInDegrees = 90,
- relativeAltitudeInMeters = 0,
- registryKey = "Fuel_Truck",
- },
-
- -- ----------------------------------------------------------------
- -- Step 3: Repair truck — left side under tent (t0 + 5 s).
- -- ----------------------------------------------------------------
- {
- polar = { distance = 61, angle = 340.5 },
- delayAfterPreviousStep = 0,
- relativeHeadingInDegrees = 90,
- relativeAltitudeInMeters = 0,
- registryKey = "repare_Truck",
- },
-
- -- ----------------------------------------------------------------
- -- Step 4: Tent — over both trucks (t0 + 5.5 s).
- -- ----------------------------------------------------------------
- {
- polar = { distance = 61, angle = 341 },
- delayAfterPreviousStep = 0.5,
- relativeHeadingInDegrees = 90,
- relativeAltitudeInMeters = 0,
- registryKey = "FARP_Tent",
- },
-
- -- ----------------------------------------------------------------
- -- Step 5: Ammo cargo (t0 + 10 s).
- -- ----------------------------------------------------------------
- {
- polar = { distance = 75, angle = 346 },
- delayAfterPreviousStep = 4.5,
- relativeHeadingInDegrees = 0,
- relativeAltitudeInMeters = 0,
- registryKey = "ammo_cargo",
- },
-
- -- ----------------------------------------------------------------
- -- Step 6: M92 light panel at tent height (t0 + 15 s).
- -- ----------------------------------------------------------------
- {
- polar = { distance = 75, angle = 355 },
- delayAfterPreviousStep = 5,
- relativeHeadingInDegrees = 310,
- relativeAltitudeInMeters = 4,
- registryKey = "NF-2_LightOn",
- },
-
- -- ----------------------------------------------------------------
- -- Step 7: Windsock near the light, same timing (t0 + 15 s).
- -- ----------------------------------------------------------------
- {
- polar = { distance = 73, angle = 346 },
- delayAfterPreviousStep = 0,
- relativeHeadingInDegrees = 220,
- relativeAltitudeInMeters = 0,
- registryKey = "Windsock",
- },
-
- -- ----------------------------------------------------------------
- -- Step 8: Carrier Seaman on the helipad (t0 + 15 s).
- -- ----------------------------------------------------------------
- {
- polar = { distance = 67, angle = 2 },
- delayAfterPreviousStep = 0,
- relativeHeadingInDegrees = 90,
- relativeAltitudeInMeters = 0,
- registryKey = "us carrier shooter",
- },
-
- -- ----------------------------------------------------------------
- -- Step 9: Stock warehouse + completion message (t0 + 20 s).
- -- Fills all fuel types so aircraft can refuel/rearm at this point.
- -- ----------------------------------------------------------------
- {
- delayAfterPreviousStep = 5,
- func = function(ctx)
- local farpName = ctx.scene._params and ctx.scene._params.farpName
- if farpName then
- local ab = Airbase.getByName(farpName)
- if ab then
- local w = ab:getWarehouse()
- -- If this is a redeployed FARP, restore the snapshot; otherwise stock defaults.
- local snap = ctx.scene._params.repackData
- and ctx.scene._params.repackData.warehouseSnapshot
- if snap and snap.liquid then
- for fuelType = 0, 3 do
- w:setLiquidAmount(fuelType, snap.liquid[fuelType] or 0)
- end
- else
- w:addLiquid(0, 10000) -- jet fuel
- w:addLiquid(1, 10000) -- aviation gasoline
- w:addLiquid(2, 10000) -- MW50
- w:addLiquid(3, 10000) -- diesel
- end
- end
- end
- trigger.action.outText(
- ctld.tr("--- Metal FARP Deployment by %1 : Complete! ---", ctx.unit:getName()), 10)
- return true
- end,
- },
-}
-
--- ====================================================================================================
--- BLOCK 4 : onRepack — called by CTLDSceneManager:packScene before objects are destroyed.
--- Captures the current warehouse fuel levels so they can be restored on next deployment.
--- ====================================================================================================
-
-metalFarpScene.onRepack = function(scene, repackData)
- local farpName = scene._params and scene._params.farpName
- if not farpName then return end
- local ab = Airbase.getByName(farpName)
- if not ab then return end
- local w = ab:getWarehouse()
- repackData.warehouseSnapshot = {
- liquid = {
- [0] = w:getLiquidAmount(0), -- jet fuel
- [1] = w:getLiquidAmount(1), -- aviation gasoline
- [2] = w:getLiquidAmount(2), -- MW50
- [3] = w:getLiquidAmount(3), -- diesel
- }
- }
-end
-
--- ====================================================================================================
--- BLOCK 5 : self-registration
--- ====================================================================================================
-
-CTLDSceneManager.getInstance():registerSceneModel(metalFarpScene)
-
--- End : scenes/CTLD_metalFarpScene.lua
--- ====================================================================================================
-- Start : CTLD_core.lua
-- ============================================================
-- CTLD_core.lua