Skip to content

Add Modes - #1041

Open
keithharvey wants to merge 20 commits into
beyond-all-reason:masterfrom
keithharvey:sharing_tab
Open

Add Modes#1041
keithharvey wants to merge 20 commits into
beyond-all-reason:masterfrom
keithharvey:sharing_tab

Conversation

@keithharvey

@keithharvey keithharvey commented Aug 19, 2025

Copy link
Copy Markdown

Closes #1040

Summary

Creates a dedicated Sharing tab that loads the game's modes/sharing/*.lua presets, applies per-mode values/locks/visibility, and shows ranked eligibility. Enables clean separation between sharing policy configuration and gadget implementation.

Changes

  • New Sharing tab with Mode dropdown and organized category sections
  • Load and apply declarative modes: set values, lock controls, hide rows
  • Read the declarative ModeConfig presets from modes/sharing/
  • Visual lock indicators with read-only overlays
  • Display "Not Ranked" badge and communicate ranked eligibility to lobby (Note: this is currently ranked=true for every sharing mode)

Screenshot of BAR changes without this PR

image This screenshot shows the BAR side WITHOUT the Chobby side. As you can see, it still works just fine, and this demonstrates specifically what this Chobby PR specifically does:
  • populate the Sharing Mode dropdown
  • hides and locks mod options based on the selected sharing mode

Related PRs

BAR: beyond-all-reason/Beyond-All-Reason#5704
Architecture: beyond-all-reason/Beyond-All-Reason#8018
Issue: #1040

Why

Moves sharing configuration from scattered individual modoptions to coherent, mode-driven policies. Players choose high-level intent (Enabled, Disabled, Easy Tax, Tech Core, Customize) rather than manually configuring multiple interdependent options.

Screenshots

(See the bar PR)

LLM Disclosure

The Chobby part of this PR was generated with grok-code-fast-1. I’d already written and reviewed and had a JSON version, then manually implemented the lua sharing mode type, each of the modes and modoption changes on the BAR side. I asked the model to update Chobby to match the new declarative format—needed only minor fixes, and it performed well. LGTM after full review.

@AntlerForce

Copy link
Copy Markdown
Collaborator

Marked as draft because I tested and this isn't working as-is. With BAR on branch pr/5074 and BYAR-Chobby on branch pr/1041 clicking on Skirmish does nothing and produces the following error:
[t=00:00:20.320549][f=-000001] [Chili] Error: in `Battle Room Window`:singleplayerWrapper : [string "LuaMenu/Widgets/gui_modoptions_panel.lua"]:1432: attempt to index global 'json' (a nil value) [t=00:00:20.322711][f=-000001] [Chili] Error: stacktrace: [C]: in UseArchive [string "LuaMenu/Widgets/gui_modoptions_panel.lua"]:1439: in LoadModoptions [string "LuaMenu/Widgets/gui_battle_room_window.lua"]:950: in SetupInfoButtonsPanel [string "LuaMenu/Widgets/gui_battle_room_window.lua"]:3209: in InitializeControls ... (18 calls) [string "LuaHandler/Utilities/specialCallinHandlers.lua"]:229 [string "libs/chiliui/chili/controls/object.lua"]:277: in SetParent [t=00:00:20.322772][f=-000001] [Chili] Error: In widget: Battle Room Window
infolog (22).txt

@keithharvey
keithharvey force-pushed the sharing_tab branch 2 times, most recently from 6a15372 to f2c74d8 Compare October 24, 2025 05:05
@keithharvey
keithharvey marked this pull request as ready for review October 24, 2025 05:23
@keithharvey keithharvey changed the title Add declarative sharing modes system Add Sharing Modes Oct 25, 2025
@keithharvey
keithharvey marked this pull request as draft October 25, 2025 18:13
@keithharvey

keithharvey commented Oct 25, 2025

Copy link
Copy Markdown
Author

Converting this to draft again. It is technically ready for review (and entirely functional) on this side but the BAR side is not quite feature complete yet so holding off until the whole package is ready to go.


isProgrammaticUpdate = true
if not allowRanked then
localModoptions["ranked_game"] = "0"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a mode has allowRanked false this forces ranked_game to '0', but I don't see anywhere that sets it back when you switch to a mode that does allow ranked. So if I pick a non-ranked mode and then switch back to a ranked one, won't ranked_game stay at '0'? And since it goes out through SetModOptions as a normal value change, I think it actually gets sent to the host, not just shown locally. It's also easy to miss because SetRankedModeAllowed just below doesn't seem to be defined anywhere, so nothing updates the battle-room UI. Could this restore ranked_game on the allowRanked path?

end

if mode.modOptions then
for optKey, rule in pairs(mode.modOptions) do

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only sets or clears lockedOptions for keys that are in the new mode's modOptions, and lockedOptions itself looks like it's only reset when the window is first created, not here. So if mode A locks an option and you then switch to a Customize/retain mode that doesn't list it, won't that key keep its old lockedOptions entry and then get overwritten with the server value on Accept? I think someone who had set that option would see it silently revert. Would resetting lockedOptions at the top of applyMode, the same way modeLocked is rebuilt, fix it?

mode_key = params[1]
kv_params = params[2:]

category_data = _as_dict(_modes().get('categories')).get(category)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This calls .get('categories') straight on whatever _modes() returns. If the fetched or cached json is ever not an object (a list, say), won't that throw an AttributeError right into the perl bridge? The accesses below it go through _as_dict but this first one doesn't. Could the top level be wrapped the same way?

if isinstance(spec, dict) and spec.get('locked')
)
for (key, _val) in settings:
if key in locked_keys:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The user's key gets lowercased up at line 209, but locked_keys is built from the mod_options keys as-is. So if a mode locks 'ResourceMult' and someone passes resourcemult=something, won't this check miss it and apply the override anyway? I think BAR option names are all lowercase in practice so it may not bite today, but it makes the lock weaker than it looks. Lowercasing both sides would close it.


for url in urls:
try:
with urllib.request.urlopen(url, timeout=_FETCH_TIMEOUT) as resp:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a synchronous urlopen on what I understand is SPADS's single thread, with a 5s timeout and up to two URLs, so a slow or unreachable release could stall the host for around 10s on the first !mode after a rehost. From what I can see the vote-eligibility check reaches it too, which a normal player can trigger. It's once per version so not constant, but a multi-second freeze of the autohost still seems bad. Could this be backgrounded or moved to load time?

@burnhamrobertp

Copy link
Copy Markdown
Contributor

I'm not deep in the SPADS side or a lot of this Lua, so some of this might be me misreading it.

  1. Fetching game_modes.json over the network at runtime. The plugin pulls it from a GitHub release the first time someone runs !mode after a rehost. Is there a reason it has to be fetched at runtime instead of shipped inside the game archive the host already has synced? I get that the host can't regenerate it itself since it's baked in CI from the modes lua, but if CI is already producing the file, couldn't it be bundled into the archive so the host just reads it locally? My worry with the release fetch is that the failure modes are all silent: if the asset is missing, or the host's network is locked down, or the slug doesn't match, the host just quietly serves stale or empty data, which seems painful to debug for whoever is running the host.

  2. How do we guarantee the host's modes match the version players are actually on? From what I can tell the right file is picked by slugifying the game's display name, and the schemaVersion field in the json isn't read by anything. If the pinned asset isn't there it falls back to a rolling channel file that isn't tied to the hosted version. Won't that let the host apply one version's settings while clients read a different version's modes out of the archive, with nothing flagging the mismatch? Even just checking schemaVersion and logging loudly when it falls back would help, I think.

  3. The expansion logic lives in two places. The rule that turns a mode into its full set of modoptions is in the Lua resolver (moderesolver.lua) and again in the CI generator that bakes the json, in two different languages, and I don't see anything testing that they agree. Since the drift between copies looks like what caused several of the regressions this PR is already fixing, would a shared fixture or a test that runs both and compares be worth it? Otherwise I think it just drifts again the next time someone touches one side.

@keithharvey
keithharvey marked this pull request as ready for review July 15, 2026 05:40
@keithharvey

keithharvey commented Jul 15, 2026

Copy link
Copy Markdown
Author

@burnhamrobertp Man those were great catches. I apologize for the lapse in reply. Ironically, I have been working in there and reviewing this myself ignorant of your review. I'm going to go over your comments with a more fine toothed comb now but I do think I addressed a lot of these already.

Re your concerns for my quick hot takes:

  1. This is purely to avoid talking to Marek, it pains me. I am sure with a little more time I could find a way to get my JSON file from game -> spads plugin IN infrastructure code (correct), with a path to also do that locally. That said, I don't love the blocking but yeah, I stuck my hand in the infrastructure hornets nest and would rather not go there to get my json file in the directory. This spike is big enough already. Aaaaaand the client is theoretically dead. People tell me it's dead. I don't necessarily believe them but if this gets merged we have a master version of game_modes.json and we can go over to the new client keep symmetry easily enough.
  2. It's pretty fregin hokey because bar release versioning is kind of hokey, especially true in the Chobby runtime. Let me get back to you on this one with more detail. I know this was an imperfect solution relying on "stable" = god, with customization of branches allowed by publishing an individual commit SHA on explicit running of an action in BAR. That seemed like enough for now since these are unlikely to change fast. If yall let me support devops during the first release after publication, we're going to be gucci.
  3. This was a great callout, I have since fixed this. I would think adding busted here (and unit tests on the munging and unpacking in particular) makes a ton of sense. That said, I am running low on mana right now. I need to merge these spikes then look to the future. I'm carrying too much code rn. That said, I would do it as an independent PR if you can review/merge it. 👯

Add the server-side ModeCommand SPADS plugin backing the Chobby sharing
tab. !mode <category> <key> [opt=val ...] expands a mode preset to its
full modoption set (a bare !mode applies the whole preset) and levels
each option identically to the built-in bSet, rejecting unknown option
keys instead of silently setting them.

Game-mode/preset data is fetched directly via the plugin and pinned to a
short-sha game version, reading the host-regenerated game_modes.json
(which replaces the committed sharing_presets.json; the generated file is
gitignored). The plugin records the selector key so the client can
reflect an externally-set mode.
Add the sharing tab to the modoptions panel and battle room: pick a
sharing mode, route the category change through the !mode plugin, and
reflect a mode set externally (live-refreshing the panel when it changes
under us). Mode descriptions show as hover tooltips.

Introduce a shared ModeResolver (libs/liblobby/lobby/moderesolver.lua)
that resolves a mode + user deviations to a full modoption set, used by
both the panel and skirmish so single-player expands a mode locally with
the same logic the SPADS plugin mirrors server-side. !mode deviations are
scoped to the mode's own options, and only deviations are sent (guarded
against the 256-char say cap).

Rounding/retain fixes along the way: round number values to their step in
the editbox seed, locked overlay, and summary; honor retainValues for
non-sticky modes; reset category to defaults when switching mode; hide
subsection headers with no visible options; and stop leaking
subheader/separator pseudo-rows as modoptions.
GetDesyncGameStates logged "Failed to load" then fell through to
string.len(infolog) on a nil result. Wrap processing in the else branch
so a failed VFS.LoadFile just logs and skips.
Configuration.password is false when rememberPassword is off; the old
unconditional auto-login passed that boolean to lobby:Login, which then
gsub'd a boolean in TextEraseNewline. Gate auto-login on a string
password (or steamMode) so manual login still works.
The hand-rolled modules/*/modes scan mirrored the game's layout
convention; use ModuleHandler.ModeDirs() when the archive ships it so
new module-contributed mode dirs need no lobby change. Older archives
without module_handler keep the fallback scan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
keithharvey and others added 14 commits July 24, 2026 13:36
Opening the modoptions window auto-applied the first preset file each
category shipped: the default fell back to a hardcoded "enabled", which
no category is obliged to have. For missions that turned every game into
a Mission game on Apply — deathmode pinned, every unit def loaded — for
having opened the options window.

Selection now seeds from the battle's selector value, else the
selector's own default, re-read on every open so a pick that was never
applied does not survive a cancel. The !mode send treats an unset
selector as the default mode: a coop room that fielded a ScavengersAI by
hand and tuned scav_* options was in "none" all along, and reflecting a
nil selector back as "!mode scavengers none" would have reset options
the room set by other means.

A preset's bots now take the field in skirmish: scavengers activate on a
ScavengersAI being present, which no modoption can say, so picking the
Scavengers mode adds the AI and switching away removes it. Only AIs the
reconciliation itself added are ever removed; multiplayer bot fielding
stays with the server's !mode handling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A modoption section may now declare `mode_category`: the axis that owns
the choice its options belong to. BAR's game axis is the first user —
scav_defense_options declares mode_category = "game", so the Scavengers
choice lives on game_mode beside standard and mission, where it is
orthogonal to them, instead of every flavor minting a selector of its
own that could contradict its neighbours'.

A governed section gets no tab: its dials fold into the governing
category's mode panel, where the active mode already decides what shows
and what locks. The resolver scopes a category to its governed sections
the same way — section entries ride in the defs list it already
receives — so a mode's full effective set covers the dials it pins
across sections, in the panel, the !mode send, and the skirmish
expansion alike.

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

The mode panel rendered only what the active preset claimed, which made
Standard and Mission blank pages: Standard claims nothing on purpose,
and Mission's pins live in other sections. Three rendering rules replace
the one:

 * The category's own section keeps the strict whitelist — the mode
   owns it outright, as the sharing tab always worked.
 * A section governed by declaration (mode_category) renders editable
   unless claimed: these are shared dials the axis carries — BAR hands
   Main to the game axis — so a mode locks what it pins and leaves the
   rest open.
 * A governed section bound to one preset (mode_key) renders only while
   that preset is the mode: scav dials appear when Scavengers is picked
   and not before.

The mode's description now opens the panel, and options a mode pins
outside the panel's sections (a mission pins forceallunits, which lives
with the cheats) render beneath — locked rows included — so picking a
mode shows everything it does. Options hidden from the lobby stay
hidden; their pins still apply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
processChildrenLocks shifts every sibling below a locked row by
comparing rowOrginal, which only option rows carry. The mode panel's
description TextBox has none, so the first lock that fired in a mode
panel — deathmode's territorial domination locks, now that Main folds
under Game — compared a number with nil and took the whole modoptions
window down with it, mid-build, leaving a blank page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A long description (Mission's) wrapped past the fixed row-and-a-half
reserved for it and rendered into the first option row. Measure the
text against a conservative width — layout has not run when the panel
is built — and start the rows below what it will actually occupy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
game_modes.json named the file before "game" was itself a category; now
it reads as that category's slice of a file that carries them all. The
exporter is export_modes.lua, the command is /exportmodes, the file is
modes.json. Comments here follow (one also pointed at the exporter's
pre-module path).

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

The export renamed game_modes.json to modes.json — the file carries
every category, and "game" is itself a category now — so the plugin's
asset scheme follows: modes-<shortsha>.json, modes-<channel>.json, the
`modes` release tag, the local and cache paths. Nothing had shipped
under the old names.

The help examples spoke a selector that no longer exists (!mode sharing
— the category is transfer now) and showed nothing from the game axis;
both fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An unranked mode now declares itself in its preset — Ranked(false) pins
ranked_game off, and the locked row in the panel is the indicator, next
to every other thing the mode decides. A floating red label above
options that visibly contradict... nothing, was explaining a mechanism
the options themselves now show.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The middle rule (governed sections rendering editable-unless-claimed)
made every mode wear all of Main: draft order under a mission, TeamFFA
box shuffling under scavengers. It dies, and mode_key with it. One rule
remains, the one the transfer tab always had: claimed and not hidden is
shown, anything else is not. The open game modes now claim the ordinary
dials explicitly — Standard's preset is the inventory of what an
ordinary game lets a lobby decide — and territorial domination's round
dials ride on its end rule, shown in no other mode.

Sections keep their remaining jobs: grouping, reset scope, and the
resolver's category defaults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The whitelist left every filtered-out group's ruled line behind, so a
curated mode drew a stack of separators between nothing. A separator
now renders only when visible rows sit on both sides of it — held until
the next visible row flushes it, dropped at the end if none does.

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

A mission is written for factions. Its manifest publishes side indexes
through the mission_name items (the archive resolves names to indexes at
discovery, so the lobby does a plain lookup), and picking the Mission
mode applies them: the player moves to the story's side, and the
seat-filler bot is fielded as the enemy's — factioned, per mission,
without the mode preset having to know any mission's particulars.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An AI the user placed is the user's business — unless it sits in the
seat the mode just set: a leftover skirmish AI beside a mission's
seat-filler plays a skirmish against the scenery. Bots now clear their
own ally slot when fielded (with a console line saying so), uniformly
for every bot-fielding mode; co-op teammates on other allies stay put,
and re-adding is one click if someone truly wants both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mode-bot ledger outlived the battle: the lobby object is a singleton,
so a fresh skirmish inherited last battle's 'already fielded' entries and
skipped re-adding the seat-filler. The ledger now resets at the new-battle
boundary, the reconcile prunes entries whose AI left the field, and the
skirmish path applies SetMode even when nothing 'changed' — modoptions
persist across battles, the table does not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

Sharing Modes

3 participants