Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 39 additions & 21 deletions .github/agents.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Beyond All Reason - Copilot Instructions
# Beyond All Reason - Agent Instructions

Mixed script types: LuaUI widgets, LuaRules gadgets, BOS animation scripts, shaders, RmlUi documents, busted specs.

Expand All @@ -17,10 +17,23 @@ Before editing code:

Keep patches narrowly scoped and easy to review. Large cross-subsystem refactors need an explicit request.

Before you hand work over:

Four checks decide whether a change is mergeable. Three of them run Lua tooling pinned to a version you can install
locally and reproduce exactly. Run the ones your changes touch.

| Check | Tool and pin | What it scopes | Local command |
| --- | --- | --- | --- |
| `busted` | lux-cli 0.28.3 | Whole spec suite, never a diff | `lx --lua-version 5.1 test` |
| `stylua` | StyLua 2.5.2 | Only hunks overlapping lines you wrote | `stylua --check --respect-ignores <files>` |
| `emmylua_check` | emmylua 0.24.0 | Whole tree, compared against the base | `emmylua_check -c .emmyrc.json .` |
| `docker compose up (headless)` | stock engine | Whole game, headless | `tools/headless_testing/README.md` |

## Keeping These Instructions Current

Instruction files: this one, `.github/RmlUi-instructions.md`, and
`luarules/mission_api/mission-api-instructions.md`. The rules below apply to all of them.
Instruction files: this one, `.github/RmlUi-instructions.md`, `.github/spec-instructions.md`, and
(only on the `mission-api/dev` branch) `luarules/mission_api/mission-api-instructions.md`.
The rules below apply to all of them.

- Update the affected file and section in the same pull request whenever a change alters a convention, tool,
workflow, directory, or command they describe. A change to a subsystem covered by its own file updates that file,
Expand Down Expand Up @@ -113,11 +126,13 @@ Match the validator to the file type. If unsure of a tool's scope, inspect the p
- lux 0.28.x appends duplicate `dependencies` and `entrypoints` entries to `lux.lock` on every cold sync (a run with
no `.lux/` tree), growing the file by ~13 lines each time without ever converging. Tests still pass. Do not commit
that churn: `git checkout -- lux.lock` afterwards, and only commit a lockfile change you made deliberately.
- Lint: `luacheck` 1.2.0 with `.luacheckrc`; CI reports only lines changed in the PR (`.github/workflows/lint.yml`).
- Format: StyLua with `.stylua.toml` (tabs, indent width 4, 120 columns, CRLF, sorted requires) and `.styluaignore`;
`.editorconfig` mirrors the indent and whitespace rules.
- Types: EmmyLua analyzer via `.emmyrc.json`, stubs in `types/`, engine definitions from the `recoil-lua-library`
submodule. The codebase is at zero type errors — keep it there.
- Lint: `luacheck` 1.2.0 with `.luacheckrc`. Treat `.luacheckrc` as a style record, not as a gate.
- Format: StyLua 2.5.2 with `.stylua.toml` (tabs, indent width 4, 120 columns, CRLF, sorted requires) and
`.styluaignore`; `.editorconfig` mirrors the indent and whitespace rules. The gate fails only on changed hunks.
- Types: EmmyLua analyzer 0.24.0 via `.emmyrc.json`, stubs in `types/`, engine definitions from the
`recoil-lua-library` submodule. This is not a zero-errors gate. A new error blocks anywhere, and warnings run on
two budgets: fifteen net-new warnings per thousand changed lines, and thirty total warnings per thousand lines in
the files you changed. A change with no type errors still fails when it adds warnings faster than that per line.
- Integration tests: headless engine via `docker compose -f tools/headless_testing/docker-compose.yml`
(`.github/workflows/test_integration.yml`). They can also be run without docker against an engine already
downloaded by an installed BAR client — see `tools/headless_testing/README.md`.
Expand All @@ -128,17 +143,18 @@ Match the validator to the file type. If unsure of a tool's scope, inspect the p
### Commands

```sh
lx --lua-version 5.1 test # full busted suite (what CI runs)
busted --output=plainTerminal # same suite, when the .lux tree is already synced
busted spec/common/lib_spline_spec.lua # single spec file
lx lint # luacheck over the project; provisions luacheck itself
luacheck path/to/file.lua # lint one file, if luacheck is installed directly
stylua path/to/file.lua # format one file (`lx fmt` reformats the whole codebase)
lx --lua-version 5.1 test # full busted suite (what CI runs); needs lux-cli 0.28.3
busted --output=plainTerminal # same suite, when the .lux tree is already synced
busted spec/common/lib_spline_spec.lua # single spec file
stylua --check --respect-ignores path/to/file.lua # what the format gate sees; needs stylua 2.5.2
stylua path/to/file.lua # format one file (`lx fmt` reformats the whole codebase)
emmylua_check -c .emmyrc.json . # what the type gate sees; needs emmylua 0.24.0
```

Scope `luacheck` and `stylua` to the files you touched; repository-wide runs create large unrelated diffs. `lx lint`
reports pre-existing warnings across the tree, so compare against the baseline rather than assuming your change
caused them.
These mean nothing without the pinned versions, and the versions are not installed for you.

Scope `stylua` to the files you touched; repository-wide runs create large unrelated diffs. The type check has no
scoped mode, so read its output against the baseline rather than assuming your change caused everything in it.

### By file type

Expand All @@ -153,16 +169,17 @@ caused them.

## Tests

Read `.github/spec-instructions.md` before adding or changing anything under `spec/`.

- Add or update unit tests for the behavior you change, not only for shared logic in `common/` and `modules/`: new
logic arrives with tests, changed logic has its tests updated, and a bug fix gets a test that fails without it.
- A new test must fail against the commit before yours and pass against yours. A test that passes both is asserting
something nobody changed, and it goes stale without anyone noticing. Never write pending tests.
- When rendering or engine callins make code hard to test, extract the decision-making part into a testable function
and cover that. Only genuinely rendering-bound behavior stays manual and in-engine.
- Tests live in `spec/`, mirroring source layout (`spec/common/`, `spec/luaui/Widgets/`, `spec/gamedata/`) and named
`*_spec.lua`. `.busted` sets `pattern = "_spec"` and `ROOT = spec/`, and puts `common/`, `luarules/`, `luaui/`, and
`spec/` on `package.path`, so require modules by their repo-relative path.
- `spec/spec_helper.lua` mocks the engine surface (`Spring`, `LOG`, `GG`, `unpack`) — extend it instead of re-mocking
per file. Build engine state with `spec/builders/` (`spring_synced_builder`, `unit_def_builder`, and friends)
rather than hand-rolled tables.

## Compatibility and Data Ownership

Expand Down Expand Up @@ -204,7 +221,8 @@ caused them.

The data-driven mission runtime has load-order and dispatch conventions of its own. Read
`luarules/mission_api/mission-api-instructions.md` before editing `luarules/mission_api/`,
`luarules/gadgets/api_missions*.lua`, `singleplayer/`, or `spec/mission_api/`.
`luarules/gadgets/api_missions*.lua`, `singleplayer/`, or `spec/mission_api/`. That file, and most of what it
describes, lives on `mission-api/dev` rather than `master`.

## RmlUi

Expand Down
87 changes: 87 additions & 0 deletions .github/spec-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Spec Instructions — busted unit tests

This file covers everything under `spec/`. Read it before adding or changing a spec, a builder, or
`spec/spec_helper.lua`. Subsystem conventions that sit on top of these rules live with their subsystem, currently
`luarules/mission_api/mission-api-instructions.md`.

The rules here exist because a spec is read far more often than it is written, and usually by someone who did not
write it and is trying to work out whether a red test means their change is wrong.

## The gates that judge a spec

The gate table, the pinned versions and the handoff rule live in `.github/agents.md` under "Before You Hand Work
Over". Three things about those gates are specific to `spec/` and are easy to get wrong.

Two exemption lists apply here and they do not agree with each other. `.styluaignore` exempts nothing under `spec/`,
so every spec and every builder is format-gated. `.emmyrc.json` exempts `spec/spec_helper.lua` and
`spec/builders/**` from type checking, and nothing else. So a builder is formatted but never type-checked, while the
spec beside it is both. Never assume a sibling directory carries the same exemptions as yours.

Because the builders are unchecked, a mistake in one surfaces as a confusing type warning in each spec that uses it
rather than as an error where the mistake is. When warnings appear in a spec you did not expect to touch types,
suspect the builder first.

Do not read an absent check as a passing one. `gh pr checks <n>` lists which gates actually ran, and a gate that
never fired looks exactly like one that passed. Run the suite yourself before you hand anything over, particularly
for work based on a long-lived branch, where a spec can go stale for days without anyone seeing red.

## What a spec has to be

**Readable without a tour.** Someone looking at one failing assertion must be able to decide whether the expected
value is right by reading the spec file and at most one helper. If understanding your test means opening three
builders and a subsystem helper, inline the fixture instead.

**Indented with tabs.** The repo uses tabs and StyLua enforces them on every line you write. A space-indented spec
fails the format gate on almost every line of the file, which buries whatever else the check found.

**Honest about what it proves.** A new test must fail against the commit before yours and pass against yours. If it
passes both, it is asserting something nobody changed, and it will go stale without anyone noticing.

**About behavior the code actually promises.** Read the implementation before you write the assertion. A test that
invents a rule the module never implemented is worse than no test, because it fails later for a reason that has
nothing to do with the change that tripped it, and the person who hits it has no way to tell which side is wrong.

**Self-consistent.** Before adding a case, read the neighbouring cases in the same file. Two tests in one spec that
imply contradictory rules mean at least one of them is wrong.

## What a spec must not do

Do not re-implement production logic inside the test harness. A builder that mirrors a production module is a second
copy that drifts, and every spec that trusts it inherits the drift. Call the production module instead. A comment of
the form `Mirrors <production file>` in anything under `spec/builders/` is a defect, not documentation.

Do not assert only that a stub was called. `assert.equal(1, #calls.doThing)` passes when `doThing` does nothing. Wire
the real module into the mock and assert the state it should have produced.

Do not import the whole builder barrel. Require the builders you use by path rather than including
`spec/builders/index.lua`, which pulls in every builder on every spec file that touches it.

Do not mix setup conventions inside one directory. Whatever a `spec/<subsystem>/` directory does, hand-assigned
globals, a subsystem helper, or a builder, every spec in it does the same thing, so that fixing one teaches you how
to fix the next.

Do not write specs for behavior that is not implemented. Open an issue instead. A green suite is supposed to mean
the code works.

## How much to write

New logic arrives with tests, changed logic has its tests updated, and a bug fix gets a test that fails without it.
That mandate has no upper bound in it, so apply one yourself.

Specs that add more than twice the lines of the implementation they cover need a sentence in the pull request saying
why. Sometimes the answer is good, because a validation layer really does need a case per rule. More often it means
the same machinery is being driven from several directions, and two specs are covering one behavior.

Test infrastructure changes, meaning `spec/builders/` and `spec/spec_helper.lua`, land in their own pull request
ahead of the feature that needs them. They are the files every other spec depends on, and they are impossible to
review inside a large feature diff.

## Where specs live

Specs mirror the source tree under `spec/` and are named `*_spec.lua`. `.busted` sets `pattern = "_spec"` and
`ROOT = spec/`, and puts `common/`, `luarules/`, `luaui/` and `spec/` on `package.path`, so require modules by their
repository-relative path.

`spec/spec_helper.lua` mocks the engine surface, currently `Spring`, `LOG`, `GG` and `unpack`. Extend it rather than
re-mocking per file, and keep in mind that it is excluded from type checking, so mistakes in it surface as confusing
type warnings in the specs that use it rather than as errors in the helper.
4 changes: 2 additions & 2 deletions .github/workflows/test_unit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: Run Unit Tests

on:
push:
branches: ['*']
branches: ['**'] # needs to tolerate a slash but not run on tag pushes
pull_request:
branches: ['*']
branches: ['**'] # needs to tolerate a slash but not run on tag pushes

jobs:
busted:
Expand Down
6 changes: 6 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
# September
- [Legion changes]
- Perdition doesn't gain extra range from elevation
- Martyr damages are added to its team's damage dealt
- EMP damage prevents reactive armor from regenerating
- Disable Air Units now removes and refunds drone spawners
- Range rings, graphics, blueprints, and other housekeeping
- [Scavenger Zombies]
- After 15 minutes into the game, zombies will swarm all teams evenly once they reach 10% of the value of all players combined.
- Zombies spawn with XP skewed to the minimum veterancy so they aren't so tanky so often.
Expand Down
4 changes: 4 additions & 0 deletions common/configs/keybind_catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,10 @@
"label": "actions.factory.showPresets",
"alwaysModifier": "any"
},
{
"action": "factory_preset_toggle",
"label": "actions.factory.togglePresets"
},
{
"action": "fov_inc 5",
"label": "actions.camera.fovIncrease"
Expand Down
37 changes: 36 additions & 1 deletion gamedata/alldefs_post.lua
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,19 @@ end
-- MODULE FUNCTIONS
-------------------------

local function spawnedAirUnit(carriedUnits)
if not carriedUnits then
return
end
for carriedName in string.gmatch(carriedUnits, "%S+") do
-- Scav units still have the base unit here but check against both sets anyway.
local carriedDef = UnitDefs[carriedName] or UnitDefs[(string.gsub(carriedName, "_scav$", ""))] ---@as table
if carriedDef and carriedDef.canfly then
return carriedDef
end
end
end

local function unitDef_Post(name, uDef)
local isScav = string.sub(name, -5, -1) == "_scav"
local basename = isScav and string.sub(name, 1, -6) or name
Expand Down Expand Up @@ -311,8 +324,30 @@ local function unitDef_Post(name, uDef)
customparams.modoption_blocked = true
elseif uDef.canfly then
customparams.modoption_blocked = true
elseif customparams.restrictions_inclusion and string.find(customparams.restrictions_inclusion, "_noair_") then --used to remove factories and drone carriers with no other purpose (ex. leghive but not rampart)
elseif customparams.restrictions_inclusion and string.find(customparams.restrictions_inclusion, "_noair_") then --used to remove factories with no other purpose (ex. legap)
customparams.modoption_blocked = true
else
local strippedDrones = false
for weaponName, weaponDef in pairs(weapondefs) do
local carriedUnit = weaponDef.customparams and weaponDef.customparams.carried_unit
local carriedDef = spawnedAirUnit(carriedUnit)
if carriedDef then
weapondefs[weaponName] = nil
strippedDrones = true
-- Make a minimal effort toward cost adjustments:
local count = weaponDef.customparams.startingdronecount
if count and tonumber(count) then
uDef.metalcost = (uDef.metalcost or 0) - count * (carriedDef.metalcost or 0)
uDef.energycost = (uDef.energycost or 0) - count * (carriedDef.energycost or 0)
end
uDef.metalcost = (uDef.metalcost or 0) * 0.95
uDef.energycost = (uDef.energycost or 0) * 0.95
end
end
-- Keep drone spawners that have other weapons:
if strippedDrones and not next(weapondefs) then
customparams.modoption_blocked = true
end
end
end

Expand Down
3 changes: 2 additions & 1 deletion language/en/commands.json
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@
"queueMode": "Toggle factory repeat mode",
"loadPreset": "Load factory preset %{n}",
"savePreset": "Save factory preset %{n}",
"showPresets": "Show factory presets"
"showPresets": "Show factory presets",
"togglePresets": "Toggle factory presets"
},
"gridMenu": {
"buildKey": "Row %{row}, column %{col}",
Expand Down
Loading
Loading