Skip to content

The Modules Format - #8304

Closed
keithharvey wants to merge 46 commits into
beyond-all-reason:sharing/05-game-modes-exportfrom
keithharvey:modules
Closed

The Modules Format#8304
keithharvey wants to merge 46 commits into
beyond-all-reason:sharing/05-game-modes-exportfrom
keithharvey:modules

Conversation

@keithharvey

@keithharvey keithharvey commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

🧩 Chain PR 3 of 3 — stacked on #5704 (The Sharing Tab)

Warning

Draft, stacked. Based on sharing/05-game-modes-export — the top of #5704's split stack — so the diff below is exactly this PR's own work: the 16 commits from modules: module framework + loader hooks onward. The sharing feature itself is reviewed in #5704's split PRs; this PR reviews the module format.

Important

This branch is deterministically regenerated, same discipline as the sharing split: the ~80-file relocation commit is generated from a move map against sharing_tab's tip, and the hand-authored overlay commits are cherry-picked on top — so when #5704 moves under review, just bar::sharing-module rebuild && verify replays the branch and conflicts can only appear in the small overlay, never the move commit. Regeneration is byte-identical (git rev-parse HEAD^{tree} equal before/after). Tooling lives beside bar::sharing-split in BAR-Devtools and may fold into it.

My [no LLM editor] Description

Intro

So you find yourself needing to encapsulate state gameside in an expressive way that isn't fighting other hook-based gadget architectures for supremacy. You want to put all of your modifications to game behavior in one place so it's easy to understand and discover. How do you organize this? I had a modular factoring of Sharing that I had originally described then coded for sharing_tab like a year ago, then saw what CampaignAPI was dealing with and did a Leo pointing at the TV when I saw what they were up to.

image

Background Recap

This section is basically a quick recap of Game Controllers & Policies, so if you've read that already, skip it.

Capabilities and concepts this branch leverages from upstream branches worth understanding before you dive into this:

  • assume type comprehension and intellisense (i.e. EmmyLua) works - because this is also on top of the fmt-llm branch, we have a working type checker and access to patterns that benefit from intellisense
  • policies - strip state out of behavior definition. Turn behavior into data
  • inversion of control (IoC) - we are explicitly introducing a service layer in the form of a subset of multiplayer behavior, but this pattern is highly generalizable. This is introducing complexity, but also giving us control we didn't have before.

Going to copy a mermaid diagram I stole from my deeper dive of these topics over in Transfer Library and change it to include modules:

flowchart TD
    Engine[Engine]

    subgraph Synced
        subgraph SL["Internal Service Layer/Module"]
            direction TB
            Controller["behavior_controller<br/>(game_unit_transfer_controller, …)<br/>executes commands within<br/>bounds set by PolicyResult"]
            Context["Context<br/>(cached)"]
            Policy[Policy]
            Result[PolicyResult]
            Controller --> Context --> Policy --> Result
            Result -.->|bounds execution| Controller
        end
        Gadgets["External gadgets"]
    end

    subgraph Unsynced
        UI[UI]
    end

    Command["«command»<br/>GG.* action request<br/>(independent data type)"]
    classDef iface fill:none,stroke:#888,stroke-width:2px,stroke-dasharray:6 4;
    class Command iface

    Engine --> Controller
    Result -->|published cache| Gadgets
    Result -->|published cache| UI
    Gadgets -.->|send| Command
    UI -.->|send| Command
    Command -.->|request| Controller
Loading

Modules

image

Ok, so let's talk about this.

  • leaning heavily on the type system for correctness between files

  • has exactly one way to do something

  • that exactly one way is explicit and typed

    • module.lua acts as a manifest, think "package.json" in node apps

      ---@type ModuleManifestFile
      return {
          name = "sharing",
          version = "0.1.0",
          description = "Team resource & unit sharing: transfer runtime, policies, tech blocking, and the sharing tab UI",
          requires = { "economy" },
          provides = {
              shared = "modules/sharing/api.lua",
              unsynced = "modules/sharing/api_unsynced.lua",
          },
      }
    • api.lua makes public an API (bags of methods) to shared (synced and unsynced) contexts, this is equivalent to "index.ts" in node apps:

      return {
          Enums = VFS.Include("modules/sharing/enums.lua"),
          -- unit surface safe in both states: validation, mode unit types, cached pair policy
          Units = VFS.Include("modules/sharing/unit/shared.lua"),
          Take = VFS.Include("modules/sharing/take/comms.lua"),
      }
    • api_unsynced.lua does the same for the unsynced context:

      local Units = VFS.Include("modules/sharing/unit/shared.lua")
      Units.GetCachedPolicyResult = PolicyEvaluation.GetUnitPolicyCached
      -- widget-side verb grafted onto the unit surface (selection -> synced controller)
      Units.ShareUnits = VFS.Include("modules/sharing/unit/unsynced.lua").ShareUnits
      
      return {
          Resources = Resources,
          Units = Units,
          PolicyViews = {
              Helpers = VFS.Include("modules/sharing/policy_views/helpers.lua"),
              ApiExtensions = VFS.Include("modules/sharing/policy_views/api_extensions.lua"),
          },
      }
  • not redundant - namespaces and file names don't repeat themselves. Files are relevant to their directory peers. modules/sharing/actions/unit_transfer.lua has 0 ambiguity

  • individual files are concise

Policies

Policies constrain runtime behavior and drive the UI.

For example, sharing/policies/unit.lua

    ---@param ctx PolicyContext
    ---@param modOptions table
    ---@param canShare boolean
    ---@return UnitPolicyResult
    local function buildUnitPolicyResult(ctx, modOptions, canShare)
        local stunSeconds = tonumber(modOptions[ModeEnums.ModOptions.UnitShareStunSeconds]) or 0
        local stunCategory = modOptions[ModeEnums.ModOptions.UnitStunCategory] or ModeEnums.UnitFilterCategory.Resource
        local buildDelaySeconds = tonumber(modOptions[ModeEnums.ModOptions.ConstructorBuildDelay]) or 0
        return {
            canShare = canShare,
            senderTeamId = ctx.senderTeamId,
            receiverTeamId = ctx.receiverTeamId,
            sharingModes = Helpers.ResolveSharingModes(ctx, modOptions),
            stunSeconds = stunSeconds,
            stunCategory = stunCategory,
            buildDelaySeconds = buildDelaySeconds,
            techBlocking = ctx.ext and ctx.ext.techBlocking or nil,
        }
    end

    Policies.Pipeline()
        -- Sender and receiver must be allied, the effective sharing modes must
        -- allow something, and (unless cheating) the receiver must have players.
        :Gate("UnitCanShareGate", function(ctx)
            local modOptions = ctx.springRepo.GetModOptions()
            local modes = Helpers.ResolveSharingModes(ctx, modOptions)
            local canShare = ctx.areAlliedTeams and not (#modes == 1 and modes[1] == ModeEnums.UnitFilterCategory.None)
            if canShare and not ctx.isCheatingEnabled and not Helpers.TeamActive(ctx.springRepo, ctx.receiverTeamId) then
                canShare = false
            end
            if canShare then
                return nil
            end
            return buildUnitPolicyResult(ctx, modOptions, false)
        end)
        -- The gate passed: build the pair's allowed UnitPolicyResult.
        :Compute("ComputeUnitPolicy", function(ctx)
            return buildUnitPolicyResult(ctx, ctx.springRepo.GetModOptions(), true)
        end)
        :Register()

Declaration order is evaluation order, first result wins, Compute always answers - the file registers its pipeline and returns nothing, and the filename is the category. A policy can insert its own gate anywhere in the order as long as it conforms.

Actions

Writing an action file is like writing a widget. You define your functions, you register them, you return nothing.

  • then, sharing/actions/unit_transfer.lua. Notice how the ctx is injecting useful data from our pipeline into our function.
    ---@param ctx UnitTransferContext
    ---@return UnitTransferResult
    Actions.RegisterExecute(function(ctx)
        local policyResult = ctx.policyResult

        if not policyResult.canShare then
            ---@type UnitTransferResult
            return {
                success = false,
                outcome = Enums.UnitValidationOutcome.Failure,
                senderTeamId = ctx.senderTeamId,
                receiverTeamId = ctx.receiverTeamId,
                validationResult = ctx.validationResult,
                policyResult = ctx.policyResult,
            }
        end

        for _, unitId in ipairs(ctx.validationResult.validUnitIds) do
            -- ctx.given should always be false here because we short-circuit inside AllowResourceTransfer
            ctx.springRepo.TransferUnit(unitId, ctx.receiverTeamId, ctx.given)
        end

        ---@type UnitTransferResult
        return {
            success = true,
            outcome = ctx.validationResult.status,
            senderTeamId = ctx.senderTeamId,
            receiverTeamId = ctx.receiverTeamId,
            validationResult = ctx.validationResult,
            policyResult = ctx.policyResult,
        }
    end)

ctx (short for context, sorry -- I like brevity in lexical scoped variables) provides module-scoped data primitives. But the framework itself provides a baseline primitive to inherrit from. For example UnitTransferContext inherits from PolicyActionContext, ResourcePolicyResult inherits from PolicyResult, and so on. In this way we can explicitly classify overlap between modules.

Domain Namespacing

  • organized by domain category. This means when you look in the sharing directory/namespace, nothing is talking about anything other than sharing in its file structure. This is in contrast to technical categorization, common in things like Ruby on Rails (e.g. directories named controllers). To me, this just makes sense for a domain layer to be organized by domain categories and it tends to lead to cleaner code because it get people focused on the correct semantic task with their file naming.

mod options get broken up

One of the big wins here is modoptions get split up, modules/sharing/modoptions.lua is now a thing, root modoptions still returns one flat list - it just assembles it from module fragments. One of @WatchTheFort's biggest fears is mod option bloat, so we move them all to individual modules that need them. This should also benefit the packageability of mods.

modes too

Currently, the only ModeCategory that exists (i.e. the Sharing Tab's top-of-tab mode dropdown is just modes where category=Sharing). So it makes a lot of sense to move those same modes to the module that owns them at modules/sharing/modes/.

specs

Having the specs live in the module just makes sense. Put them next to the code they're working.

modules can be back-ported

Once you have this thing self-contained like this, it's easy to rip these things back out to the engine as exemplars.

Campaign API

Campaign API is currently an eventing system plus a behavioral override sidecar. The events and scheduler could live as a module that works the same as every other module then we could flip it on or off. But importantly, instead of a sidecar implementation, I'm arguing that we should refactor those behavioral subsystems and make them expose their own internal state as an easy to configure API, via policies or whatever makes sense for their factoring.

Conclusion

Whew. Sorry. It's a lot. But I do think THIS PR is not boring. This one has a lot of great ideas in it that kind of become apparent (to me anyway) under this organizational structure. Hopefully with ideas from both CampaignAPI and this branch, we can make expressing game behavior considerably easier over time.

Summary (LLM-generated, claude-fable-5)

Gives the sharing stack its final form: an encapsulated module format at root-level modules/, with opinionated auto-loaded subdirectories, manifests + per-state contracts between modules, and the policy/action patterns from #5704 promoted to framework.

  • Adds modules/module_handler.lua: module discovery (module.lua manifests with requires/provides, validated with loud errors), contract resolution (ModuleHandler.Get(name)), and registration-style loading for actions/ and policies/ — files call Actions.RegisterValidate/RegisterExecute or end pipelines with :Register() via loader-injected registrars (the widget-handler idiom: explicit named calls on an explicit local) and return nothing; identity is the filename; the runtime descriptor is assembled by the loader, never hand-authored. No hand-written parameter schemas — controllers are statically typed; a derived-from-LuaCATS schema returns when a data-driven dispatcher (mission_api/CampaignAPI) creates a boundary the type checker can't see.
  • Per-state contracts, explicit partition / implicit resolution: a manifest declares provides = { shared, synced, unsynced } and Get() merges shared + the current Lua state into one flat api — a widget never sees synced-only keys and vice versa; wrong-state access is nil at the first index. Contracts are plain eager tables (no lazy metatables).
  • Gadget/widget handlers scan modules/*/gadgets|widgets|rml_widgets game-side — the shim for engine-native module loading (Recoil RFC to follow); the layout is the contract.
  • Relocates the sharing domain into modules/sharing/ as a domain-scoped tree (resource/, unit/, take/, tech/, policy_views/, root-level enums.lua/helpers.lua — the module name is the namespace, no lib/). Mode presets ship with the module (modules/sharing/modes/), and the module owns its lobby options via modules/sharing/modoptions.lua, aggregated by root modoptions.lua.
  • Policies are pipelines: policies/resource.lua / policies/unit.lua express The Sharing Tab #5704's exact gate→compute control flow through a fluent builder (Policies.Pipeline():Gate(...):Compute(...):Register()) emitting plain descriptor lists — declaration order is evaluation order, pure functions only, category = filename. Decision code lives with the policy layer (policy_evaluation.lua holds both the live pipelines and the cached-factor rebuilds); unit validation is the action's declared validate precondition; shared.lua files are serialization + transfer math only.
  • Extracts modules/economy/ (waterfill solver, share stats, manual share ledger, the team resource snapshot) as the first cross-module dependency: sharing declares requires = {"economy"} and consumes it via contract; the solver is tax-agnostic (sharing injects its tax resolver).
  • Verification: 293/293 busted specs (the 269 from The Sharing Tab #5704 unchanged + 24 framework specs), emmylua clean (0 errors), headless engine smoke with all module gadgets/widgets loading and a widget-failure set byte-identical to sharing_tab's.

🤖 Built with Claude Code (human-directed); every commit spec-verified as above.

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Integration Test Results

18 tests  ±0   10 ✅ +1   20s ⏱️ ±0s
 1 suites ±0    8 💤 ±0 
 1 files   ±0    0 ❌  - 1 

Results for commit 8a0d5bd. ± Comparison against base commit 01905ea.

♻️ This comment has been updated with latest results.

@keithharvey
keithharvey force-pushed the modules branch 2 times, most recently from cbc46f5 to 4601e24 Compare July 16, 2026 05:11
@keithharvey
keithharvey changed the base branch from fmt-llm to sharing/05-game-modes-export July 16, 2026 05:14
@keithharvey
keithharvey force-pushed the modules branch 11 times, most recently from cbbe7ae to 778083b Compare July 16, 2026 10:21
@keithharvey
keithharvey force-pushed the sharing/05-game-modes-export branch from 182ddb2 to d095e4e Compare July 16, 2026 10:53
keithharvey and others added 10 commits July 16, 2026 05:42
…ring

Renames spec/builders/{spring->engine}_{,un}synced_builder.lua + their
builder_specs, Builders.Spring->Builders.EngineSynced,
Builders.SpringUnsynced->Builders.EngineUnsynced, @Class names, and all
call sites. The renamed builders own every Engine-related test edit so no
two prereqs touch one file:
  - engine_synced_builder.lua: gamedata/system.lua defs mock aliases
    Engine.{Shared,Synced,Unsynced} + BAR to the _G.Spring/_G.BAR mocks
  - engine_unsynced_builder.lua: widget sandbox gets env.Engine; capture
    spies install on Engine.Shared.*

Prefix branch -> lands in fmt once, every leaf+mig inherits it.

Recovered from origin/mig-spring-split@4e25bfe82a (branch lost in a rename);
files transplanted verbatim (formatting is normalized by run_fmt).
Add Utilities, I18N, Debug, Lava, and GetModOptionsCopy to the
System tables in luaui/system.lua and luarules/system.lua so that
widgets and gadgets can access them after detach-bar-modules moves
them off the Spring table.

Also create .emmyrc.json (the EmmyLua analyzer config) with the
detached modules in the globals list, plus type stubs for LSP/CLI
support. The .emmyrc.json content matches what
vscode-recommended-extensions ships, with 5 extra globals
(Utilities/Debug/Lava/GetModOptionsCopy/I18N) that only become
real top-level identifiers after detach-bar-modules runs. When
vscode-recommended-extensions has already merged, -Xtheirs in the
cherry-pick keeps this version (the superset).

# Conflicts:
#	.emmyrc.json
Restructures the 20 files under luaui/Tests/, luaui/TestsExamples/,
plus the headless-only common/testing/infologtest.lua, from bare-
global hook declarations to a return-table shape. Updates the
dbg_test_runner widget to read test hooks from the returned table.

Motivation: the pre-existing shape required the test files to run
under setfenv(chunk, testEnvironment) and define `function test()`,
`function setup()`, etc. as bare module-level globals that setfenv
redirected into the environment. That works at runtime but emmylua
can't model the sandboxing — it sees 20+ files declaring project-
wide globals like `test`, `setup`, `skip`, `cleanup`. To keep
emmylua happy, .emmyrc.json had to blacklist both test directories
under workspace.ignoreDir — a kludge on clearly-ours code. Lives on
its own leaf so the convention change can be discussed in isolation.

Minimal shape change per file — just prepend `local` to each top-
level `function` declaration, and append a final `return { ... }`
block listing whichever lifecycle hooks (skip/setup/test/cleanup)
that file actually defines. Original indentation and formatting
preserved (no stylua reformatting noise — the fmt transform runs
after this one in the mig pipeline).

Runner patch — luaui/Widgets/dbg_test_runner.lua, loadTestFromFile:
  - capture the return value of pcall(chunk)
  - require it to be a table
  - merge its keys into testEnvironment so runTestInternal still
    reads bare `skip`/`setup`/`test`/`cleanup` under setfenv
Vendored LuaCATS annotations for busted/luassert to provide
IntelliSense for the unit-test surface. Lives on its own leaf so the
discussion around 'vendoring LuaCATS types' can happen in isolation —
prior pushback on the same direction in an earlier unit-testing PR
makes this the right place to litigate it rather than burying it in a
broader env commit.

Why vendored instead of declared as a Lux dep: Lux does not yet
support pulling LuaCATS annotations from library deps, and quick
attempts to wire this up in Lux failed. Upstream tracking issue:
lumen-oss/lux#953 — once that lands, these
directories should be deleted in favor of declaring busted as a
normal Lux dev-dep.

Sources (pinned SHAs):
  - types/busted/   https://github.com/LuaCATS/busted
                    @ 5ed85d0e016a5eb5eca097aa52905eedf1b180f1
  - types/luassert/ https://github.com/LuaCATS/luassert
                    @ d3528bb679302cbfdedefabb37064515ab95f7b9

See types/busted/provenance.md and types/luassert/provenance.md for
per-directory upstream refs + license status.
…ixes

Human-curated environment that, together with the LLM type-triage pass
(fmt-llm), drives emmylua_check to zero on the migrated tree. Per-change
rationale lives in PR beyond-all-reason#7447 review comments.

- .emmyrc.json globals + diagnostics; types/* stubs; busted mock; CI gate
- forward-decl / assertEqual declarations; reverted orphaned kikito loader
- rationale-comment strip; types/IntegrationTests rename
- deterministic pins for type-triage leftovers the LLM mishandles on big
  files (multi_attack opts, HighlightUnit forward-decl, ripairs suppress,
  gui_pip gameFrame use-before-declare)
- json.lua forward-decl tidy (relocate null, drop dead decode_scan*)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
IsDevModeCached (upstream beyond-all-reason#6918) references `utilities` inside its own
table constructor, where the local is not yet in scope — Lua resolves
those reads as GLOBALS, so the first real call would index nil. Dormant
today only because nothing calls it. Forward-declare the local so the
closure captures it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
keithharvey and others added 19 commits July 16, 2026 13:45
Generated by BAR-Devtools scripts/sharing-module/generate.sh from
move_map.tsv: git mv + quote-anchored include-path rewrites, plus the
.busted ROOT addition for module-local specs. No logic changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
modules/sharing/module.lua declares the manifest; api.lua is the public
contract — the only surface code outside the module may consume, reached
via ModuleHandler.Get("sharing"). Entries resolve lazily and load once
per Lua state, so synced consumers never pull unsynced-only files.
External widgets/gadgets/tests switch from deep include paths to the
contract; modoptions.lua and mode enums stay direct data includes
(lobby context, root-level vocabulary).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extract the resource deny gates + terminal compute out of
resource/synced.lua into policies/resource/ (one pure policy per file,
filename order, first result wins) and the unit share gate + compute out
of unit/synced.GetPolicy into policies/unit/. Transfer executors move to
actions/ as ActionDescriptors — the only effectful layer. The synced
libraries keep their public API as thin delegates so existing specs
prove behavior is unchanged; controllers consume the auto-registered
action registry via module_handler. Shared gate helpers land in
modules/sharing/helpers.lua.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
After the descriptor extraction, resource/synced.lua and unit/synced.lua
were thin delegate shells. Dissolve them into their real
responsibilities: policy_evaluation.lua (pipeline entry points) and
resource/factor_cache.lua / unit/factor_cache.lua (the per-team factor
caches). Controllers and specs point at the real homes; transfer
execution goes through the action registry everywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Modules ship their own modoptions.lua fragment (same entry format as the
root file, including their section entry); ModuleHandler.ModOptions()
merges fragments in module-name order and the game's modoptions.lua
appends them. The 25 sharing options + Sharing section move into
modules/sharing/modoptions.lua, deleting every sharing reference from
the root file. module_handler logging falls back to print so the
lobby/unitsync LuaParser (no Spring global) can parse modoptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mode presets travel with the module that owns the modoptions they lock:
modes/sharing/* live at modules/sharing/modes/. The root modes/ system
stays the vocabulary (enums, helpers) and aggregation point;
ModuleHandler.ModeDirs() surfaces module preset directories and the
game-modes export scans them alongside root modes/<category>/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Waterfill solver, share stats, and the manual share ledger move to
modules/economy/ with their own manifest and contract; sharing declares
requires = { "economy" } and consumes them via ModuleHandler.Get — the
first real exercise of cross-module requires/provides.

shared_config stays behind as modules/sharing/config.lua: it reads
sharing's modoptions and tech state, so an economy module owning it
would have a fake boundary. The solver is now tax-agnostic — callers
with a tax policy pass their own resolver (sharing passes
config.getTeamTaxRate); the default is tax-free. gui_top_bar and
gui_teamstats consume ShareStats from economy's contract instead of
sharing's, which shrinks sharing's api to things that are actually
sharing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The numbered per-policy files (010_/020_/100_) used the filesystem as
control flow and paid descriptor boilerplate four times over for one
policy. Each category is now a single pipeline file —
policies/resource.lua, policies/unit.lua — expressed with the builder's
new Pipeline layer: gates in declaration order, one terminal Compute,
Build() emitting the same plain PolicyDescriptor[] the loader always
consumed. Includes hoisted once per file; evaluation semantics, stage
names, and results unchanged (same specs prove it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The api is organized by domain, never by file layout: Enums, Unsynced
(the widget facade: .Resources, .Units incl. Units.ShareUnits), Units
(the both-state unit surface: validation, factors, cached pair policy),
Take, and AdvPlayerList.{Helpers,ApiExtensions} as a nested namespace.
Flat keys that echoed the pre-module filenames (UnitUnsynced,
UnitShared, TakeComms, AdvPlayerList*) are gone; nested entries resolve
lazily like everything else in the contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…esolution)

The manifest declares the state partition explicitly — provides as a
plain path (state-agnostic) or { shared, synced, unsynced } — and
ModuleHandler.Get merges shared + current state into one flat api, so a
consumer holds exactly the surface that exists where it stands: wrong-
state access is nil at the first index, never a crash three calls deep.
The consumer never picks a state (it has one), mirroring how the engine
exposes state-appropriate API implicitly.

sharing splits into api.lua (shared: Enums, Units, Take) and
api_unsynced.lua (Resources, Units + ShareUnits graft, AdvPlayerList) —
the OG spike shipped exactly this pair as api_team_transfer.lua + the
synced service. Per-state contracts are plain eager tables; the lazy
metatable existed only to keep one file safe in both states, so it is
gone everywhere (economy's contract simplifies too), and the root
unsynced.lua facade is inlined into the unsynced contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-pipeline errors

Pipeline() takes no category argument — a pipeline's identity is its
filename (policies/<category>.lua), stamped by LoadPolicies: one source
of truth, no magic strings to drift against the file. Lookups go through
Enums.PolicyCategory and a missing pipeline is a loud error instead of a
silent empty evaluation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… action

The shared libs were harboring decision code: resource/shared.lua and
unit/shared.lua carried CreateDenyPolicy/CombineResourcePolicy/
BuildUnitPolicyResult/ResolveSharingModes and — worse — cached
GetCachedPolicyResult rebuilds whose gates hand-mirrored the pipeline
gates in policies/. Evictions: result constructors + mode resolution to
helpers.lua; both cached pair rebuilds to policy_evaluation.lua, where
they sit beside the live pipelines they mirror (one file to review both
decision paths); ValidateUnits to actions/unit_transfer.lua as the
action's declared `validate` precondition (ActionDescriptor grows the
optional field — the beyond-all-reason#8226 prevalidation idea carried to its home).
shared.lua files are now serialization + transfer math + category
matching only. The unsynced contract grafts the cached reads onto
Resources/Units, so widget call sites are unchanged; the controller
validates through the action registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Kill the hand-authored beyond-all-reason#8226 return-table: action files register via an
injected registrar — Actions.RegisterValidate (optional, first) and
Actions.RegisterExecute (required, exactly one) — and return nothing;
the runtime descriptor is assembled by the loader, never authored.
Identity is the filename (actions/unit_transfer.lua -> unit_transfer),
the same rule as policies/<category>.lua. parameters/ValidateActionArgs/
ActionParameter die now: zero runtime callers, and {type="table"} is
strictly weaker than the LuaCATS annotations emmylua already checks; a
DERIVED schema (generated from annotations) returns if a data-driven
dispatcher (mission_api/CampaignAPI) ever creates a boundary the type
checker cannot see.

One idiom framework-wide: policy pipelines also register
(:Register() terminal, sink injected per file) instead of returning.
The registrar reaches files by loader env-injection with __index
fallthrough — the widget-handler idiom (local Actions = Actions);
explicit named calls on an explicit local, NOT anonymous setfenv
globals (the dbg_test_runner fragility). Registration includes are
deliberately uncached so brackets re-fire; registrar state is plain
assignment (synced strips rawset). Boundary leak fixed: advplayerlist
no longer includes action files directly — registry access only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The engine's VFS.Include is uncached (LuaVFS.cpp loads + pcalls every
call), which is real — but ModuleHandler.Include only deduped within one
handler instance, and module_handler.lua is itself re-included per
consumer, so 'load once per Lua state' was never what it did. Delete
the wrapper: contracts use plain VFS.Include (familiar, honest), Get()/
LoadActions/LoadPolicies stay memoized per handler, and true
load-once-per-state is the engine RFC's require() where it belongs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-team resource snapshot (current/storage/pull/income/expense/
shareSlider from engine reads) is economy vocabulary, and the waterfill
solver was already typed against TeamResourceData — economy depending on
a shape housed in sharing was inverted. It moves to modules/economy/,
exports on economy's contract, and sharing's context factory + resource
controller consume it via Get("economy") like the rest of the boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PolicyBuilder.new/When/Allied/Enemy/Returns/Deny had spec-only
references — and worse, no registration path: since registration-style
landed, LoadPolicies consumes only pipeline files, so a standalone
descriptor from new():Build() cannot be registered at all. Its modder
story was superseded by Gate/Compute. policy_builder.lua is now the
pipeline builder, full stop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Named-local-then-register-at-the-tail split the declaration from its
registration — and a forgotten Register is a silent nothing until load.
Inline the function literals into the Register calls (the describe/it
shape, and what pipelines already do with Gate): the registration IS
the declaration, so it cannot be forgotten. Annotations sit on the
literals and stay fully checked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BuildUnitPolicyResult had exactly one consumer — policies/unit.lua — so
it lives there as a local, not in the helpers grab-bag. What stays in
helpers stays for structural reasons: pipeline files register and export
nothing by design, so anything shared between the live pipeline and the
cached rebuild/factor caches (ResolveSharingModes, TeamActive,
IsNonPlayerTeam, the resource result constructors) needs a home outside
the pipeline file, and helpers.lua is that home.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stricter analyzer's first pass over our 21 authored files found 19
diagnostics and one buried body: the single-descriptor builder deleted
earlier had been silently RESURRECTED by a stale rerere resolution
during a branch replay — dead code, invisible to specs (its specs died
with it), caught only by undefined-doc-param breadcrumbs. Excised again;
rerere is now disabled for this repo and its cache purged, because
auto-applied resolutions recorded against superseded overlay content are
exactly wrong for a regenerated branch.

The rest: @cast (not @as) for loader-built partial tables; the spec
VFS shim widened to engine arity (its 1-param SubDirs/FileExists
shadowed the real signatures under 0.24's overload handling); stale doc
params; unused locals underscored or dropped; justified suppressions
commented (Spring truthiness in lobby LuaParser, _G in the synced-state
spec).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
keithharvey and others added 5 commits July 16, 2026 22:07
Test-runner and synced sandboxes don't expose setmetatable, so the
weak-keyed cache crashed the module include chain under the
integration tests. Callers only ever pass UnitDefs, so weak keys
bought nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The four top-level pack/unpack helpers pushed gui_chat's main chunk
past Lua 5.1's 200-local ceiling, so the widget failed to load. Each
helper has exactly one caller (Get/SetConfigData); define them there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The runner's whitelist env omitted them, so any module code with
metatable-based classes (policy_builder pipelines, module_handler's
registration includes) crashed when loaded from a test file. Every
real environment (widget, gadget, synced) exposes both; the omission
was incidental. Reverts the flat-copy workaround in module_handler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
module_handler resolves its include-fallback env via _G, then
pcall(getfenv, 1). The sandbox has neither, so registration files got
__index = nil and lost VFS. getfenv(1) now yields the sandbox env,
which carries everything module code needs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Manual resource sharing depended on gadget:ResourceExcess
(RecoilEngine#2642) to drive ProcessEconomy, which also refreshes the
policy factor cache. On engines without the port the cache stayed at
its pre-game snapshot (taxedSendable=0) and every share was denied.
Fall back to GameFrame for the cadence there, and guard the
AddTeamResourceExcessStats call that would crash the first tick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@keithharvey

Copy link
Copy Markdown
Collaborator Author

Superseded by #8408 — recreated with its head on beyond-all-reason instead of the fork (no fork-headed PRs; branch content identical, review history stays readable here).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant