Skip to content

The Modules Format - #8408

Closed
keithharvey wants to merge 3 commits into
sharing/05-game-modes-exportfrom
modules
Closed

The Modules Format#8408
keithharvey wants to merge 3 commits into
sharing/05-game-modes-exportfrom
modules

Conversation

@keithharvey

@keithharvey keithharvey commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

🧩 The Modules Format — tip of the sharing stack (#8411)

Note

Based on sharing/05-game-modes-export (#8095) — the top of the sharing split — so the diff below is exactly this PR's own work: the overlay commits from modules: module framework + loader hooks onward. The sharing feature itself is reviewed in the sharing stack 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 the sharing stack moves under it, 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 #8412 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 & The Modules Format — feature tracking (stack #8411) #8412'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 stack 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.

@keithharvey

Copy link
Copy Markdown
Collaborator Author

Synced with the format decision from the bar_editor track: framework-shared types now live in root types/ (modules/types/modules.luatypes/modules.lua) — sorenmarkert's review point. The loader's requires = {} manifest dependency support was already present here, so that's the whole delta; mission/matchflow manifests stay in their own chain (#8382/#8378/#8380). 288/288 unit tests green.

@keithharvey
keithharvey force-pushed the modules branch 2 times, most recently from cd62200 to 7af4934 Compare July 22, 2026 23:42
keithharvey and others added 3 commits July 22, 2026 17:48
Encapsulated game modules live at modules/<name>/ with opinionated
subdirectories (widgets/, rml_widgets/, gadgets/, actions/, policies/).
module_handler.lua provides discovery, an include-once cache, contracts
(manifest requires/provides), action/policy auto-registration with
schema validation (shape shared with luarules/mission_api and PR #8226),
and first-result-wins policy evaluation. policy_builder.lua is the
modder-facing fluent layer that emits the same descriptors.

Gadget/widget handlers scan module subdirectories game-side until the
engine loads them natively (Recoil RFC).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
The encapsulated module format at root-level modules/: auto-loaded
subdirectories, module.lua manifests with requires-dependencies and
per-state contracts (explicit manifest partition, implicit resolution),
registration-style actions and pipelines with filename identity, and
framework-shared types in root types/. Policy code lives with the
policy, validation with the action; plain VFS.Include everywhere — no
game-side include cache. The economy module owns the team resource
snapshot; sharing's policies/actions move to descriptor form on the
pipeline DSL.

Squashed from the incremental format slices for a linear history the
bar_editor chain stacks onto.

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

Copy link
Copy Markdown
Contributor

Integration Test Results

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

Results for commit 4a82a41. ± Comparison against base commit fc72c0d.

@keithharvey

Copy link
Copy Markdown
Collaborator Author

Superseded: the module framework now lands with the mission api stack (#8424), and the sharing content — expressed through the mode grammar, folded into modules/sharing — continues as the sharing v2 PR on top of #8462. (This PR's base is locked by the stack widget, so it is re-minted rather than retargeted.)

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