Skip to content

feat: Add optional auto-leave on defeat countdown with online host enforcement - #594

Closed
JawadYzbk wants to merge 4 commits into
GeneralsOnlineDevelopmentTeam:mainfrom
JawadYzbk:feature/auto-kick-defeat
Closed

JawadYzbk wants to merge 4 commits into
GeneralsOnlineDevelopmentTeam:mainfrom
JawadYzbk:feature/auto-kick-defeat

Conversation

@JawadYzbk

@JawadYzbk JawadYzbk commented Sep 16, 2026

Copy link
Copy Markdown

Adds an opt-in countdown that returns a defeated player's client to the score screen on its own, instead of leaving them sitting in a passive spectating state until they quit by hand.

Disabled by default. LAN and online games only.

Important

The host-enforced half is inert until the Generals Online service stores and echoes one new lobby field. The contract is in §6 below. The per-player half works today and needs nothing server side.


1. What it does

A player picks a duration in the options menu — Off, 30 seconds, 1, 2, 3 or 5 minutes. When they are defeated, their client counts down, announces it on screen, and then returns itself to the score screen.

An online lobby host can additionally enforce a duration for everyone in the lobby with /autoleave <seconds>. The host value wins when non-zero; zero means the host does not enforce one and each player's own setting applies.

Auto-leave is advisory, not enforceable — the behaviour is client side, so a modified client can ignore it. It is a convenience, not an anti-abuse measure.

2. Why a local preference rather than a game option

The effect — "my client returns to the score screen" — is observable only by the player it happens to. It changes no simulation state, so there is no desync surface and nothing for peers to agree on, and it only automates a quit that player can already perform by hand.

Routing it through GameInfo + GameInfoToAsciiString would have cost a wire format break, a replay compatibility break, and a SkirmishGameInfo::xfer version bump, for no benefit. Concretely, ParseAsciiStringToGameInfo rejects the entire options string on an unrecognised key:

else
{
    optionsOk = false;
    break;
}

So a new tag there would make a patched host's lobby unjoinable and invisible to unpatched clients, and would change the replay header. None of that applies to this change. GameInfoToAsciiString, the replay header and the skirmish save version are all untouched.

The host-enforced value travels in the backend lobby record alongside the other host-set rules, which is why it needs service work rather than a protocol change.

3. Countdown behaviour and guards

Implemented in VictoryConditions, which already owns defeat state. Each guard exists for a specific reason:

Guard Why
LAN and online only A defeated skirmish player restarts rather than sitting out a match. isInMultiplayerGame() is exactly GAME_LAN || GAME_INTERNET, which also excludes campaign and replay playback.
Never arms while an ally lives markAllianceVictorious() marks a defeated player victorious if a surviving ally wins. Leaving early would discard a win the player is still entitled to. Re-tested every frame, not once at defeat: with three or more alliances the local alliance can be wiped out while the match carries on between the others.
Never arms for observers cachePlayerPtrs() sets m_localPlayerDefeated for observers, so testing m_isObserver is load bearing.
Cancels once a single alliance remains Otherwise exitGame() races the normal end-of-match path into the score screen.
Excluded during replay playback TheGameInfo points at the recorded game during playback, so a replay of a match the watched player lost would otherwise exit itself partway through.
Latches after firing exitGame() only posts a deferred MSG_CLEAR_GAME_DATA; update() keeps running for several frames, and without the latch the countdown re-arms and re-announces on the way out.

Other details:

  • Frame based, so it freezes while the game is paused and follows the game speed setting rather than the wall clock.
  • Calls exitGame(), not quit()quit() would either open the quit menu or self destruct the player in a multiplayer game.
  • The preference is resolved once during map load, not at defeat time, so the file read never lands mid match.
  • Values are clamped on read, so a hand-edited Options.ini cannot produce a pathological timer.

4. Degrading against unmodified assets

The options menu control is null guarded on every use, and the save path only writes when the control is present — so a client running a patched exe against stock window assets degrades to the feature being off rather than crashing, and does not silently reset an existing preference to Off.

This is not hypothetical: existing code in the same area is not uniformly null safe (LanGameOptionsMenu.cpp dereferences comboBoxStartingCash unguarded), and exe and assets do get out of sync in the field.

A duration in Options.ini that is not one of the offered ones is shown as an extra combo entry rather than being rewritten on save, so a hand-edited value round trips.

5. Assets required

The .wnd and .csf files are game data, not source, so they are not in this repository and not in this diff. Both are overridable with loose files — FileSystem::openFile tries TheLocalFileSystem before TheArchiveFileSystem.

Window layoutWindow\Menus\OptionsMenu.wnd (note: Window\, not Data\Window\; layouts resolve as Window\%s relative to the game root). Two controls appended as the last children of ScrollParent, in the free slot on the Retaliation row:

Control Type SCREENRECT (at 800x600)
OptionsMenu.wnd:StaticTextAutoLeaveOnDefeat STATICTEXT 387 326507 350
OptionsMenu.wnd:ComboBoxAutoLeaveOnDefeat COMBOBOX 511 326631 350

Cloned from DetailLabel and ComboBoxDetail in the same file so the styling matches, with MAXDISPLAY: 6 so all six durations show without scrolling. Purely additive: 292 lines added, 0 removed, 0 changed.

In GENERALS_ONLINE builds layouts resolve GeneralsOnlineGameData\Menus\... first, then Window\Menus\.... If the updater ever ships its own OptionsMenu.wnd into that directory it will silently take priority and the control will disappear.

Strings — 10 labels appended to generals.csf. Appending to the binary table preserves every existing string byte for byte and keeps per-language support; a plain-text Data\Generals.str would replace the whole table and pin the game to one language, since g_strFile has no language slot while g_csfFile does.

Label Text
GUI:AutoLeaveOnDefeat Auto-Leave:
TOOLTIP:AutoLeaveOnDefeat LAN and online games only. After you are defeated, …
GUI:AutoLeaveOnDefeatOff Off
GUI:AutoLeaveOnDefeat30 30 Seconds
GUI:AutoLeaveOnDefeat60 1 Minute
GUI:AutoLeaveOnDefeat120 2 Minutes
GUI:AutoLeaveOnDefeat180 3 Minutes
GUI:AutoLeaveOnDefeat300 5 Minutes
GUI:AutoLeaveOnDefeatCustom %d Seconds
GUI:AutoLeaveOnDefeatCountdown Returning to the score screen in %d seconds

Label order does not matter — the engine qsorts the lookup table on load and bsearches it.

Where these belong

Game data is not carried in this repository — it lives in the complementary assets project, TheSuperHackers/GeneralsGamePatch, which already tracks 160 .wnd files under Patch104pZH/GameFilesEdited/Window/, including Window/Menus/OptionsMenu.wnd. So the layout change is a companion PR there, not part of this diff.

I checked for a conflict: that repo's OptionsMenu.wnd is byte-identical to the stock archive copy apart from line endings (it is stored LF, the archive is CRLF), so there are no existing edits for these two controls to clash with, and the additions apply cleanly on top of their version.

I have tooling for all of this — a BIG lister/extractor, an idempotent .wnd patcher that works from anchors rather than line numbers, and a CSF reader/writer with a byte-identical round-trip gate. Happy to contribute it wherever it is wanted; it is kept out of this PR to keep the diff focused.

6. Backend work required (Generals Online service)

Add one lobby field. Implement it as a structural copy of the existing MaximumCameraHeight field (update id 17) — same permission check, validation, storage and broadcast path. The relevant service code is GenOnlineService/Controllers/Lobby/LobbyController.cs (the ELobbyUpdateField dispatch) and GenOnlineService/LobbyManager.cs (storage and broadcast).

Property Value
Response JSON key AutoLeaveSeconds (PascalCase, like MaximumCameraHeight)
Request body key auto_leave_seconds (snake_case, like max_cam_height)
Type unsigned integer, fits uint16_t
Default 0 (off)
Valid range 03600 inclusive
Who may set it lobby host only

The casing asymmetry is deliberate and matches every other lobby field.

Update endpointPOST {API}/Lobby/{lobbyID}, claiming field = 20 (LOBBY_AUTO_LEAVE).

Warning

The client's copy of ELobbyUpdateField had drifted behind the service: it stops at JOINABILITY = 18, while Services/GenOnlineService/Controllers/Lobby/LobbyController.cs also defines HOST_ACTION_BULK_SLOT_UPDATE = 19. Claiming 19 would have collided with that handler. Auto-leave therefore takes 20, and the comment at the client enum records why. Worth a second look from someone who knows whether the client is missing anything else from that contract.

9 and 10 remain reserved as UNUSED/UNUSED_2 and must not be reused.

{ "field": 20, "auto_leave_seconds": 60 }

The service must reject non-host callers, reject values outside 0..3600 (reject rather than clamp, so a modified or stale client gets an error instead of silently getting a different rule), store the value, and broadcast the updated lobby.

Create endpoint — the PUT body now carries "auto_leave_seconds": 0. Accept and store; treat a missing key as 0. New lobbies default to off.

Read path — include AutoLeaveSeconds anywhere MaximumCameraHeight already appears: both the lobby list/browse response and the single-lobby detail response. Return 0, never null.

Deployment ordering — either half can ship first. The client reads the field with value("AutoLeaveSeconds", 0) rather than a throwing get_to(), so a client talking to a service that does not yet return the field simply sees 0 and leaves the feature off. That accessor choice is load bearing; please do not "fix" it to match the neighbouring lines.

Because GeneralsOnlineDevelopmentTeam/Services is in this org, this can be a companion PR rather than an external dependency.

7. Commits

Commit Scope
build: Fix Generals game engine compile errors Unrelated pre-existing breakage, see below
build: Fix Byte redefinition between zlib and the engine in StatsExporter Unrelated pre-existing breakage
feat: Add optional auto-leave on defeat countdown Preference, countdown engine, options menu
feat: Let the online lobby host enforce the auto-leave duration Lobby field, /autoleave, host override

The two build: commits are separable and can be split into their own PR if preferred. They are included because the targets do not otherwise compile:

  • Generals does not build at all. Three partially applied upstream changes left it inconsistent with itself while GeneralsMD received the complete change: an unqualified WWCommon.h include, a missing public: in GameEngine.h, and an anonymous game mode enum in a header that declares toString(GameMode). These clear 452 of 687 errors; the remaining 235 are separate gaps and are not addressed here (chiefly UDPTransport.h living only under GeneralsMD, and the body/damage module hierarchy missing virtuals its own subclasses declare override on).
  • StatsExporter.cpp fails whenever it is rebuilt, on a Byte typedef collision between the engine and vcpkg's zlib.

8. Testing

Verified:

  • Builds clean, Zero Hour, MSVC x86 Release, 0 errors and no new warnings.
  • Options menu control renders, populates, persists to Options.ini and reloads.
  • Strings resolve; CSF round trip byte identical against the stock table before appending.
  • Loose .wnd and .csf overrides load correctly over the archives.

Not yet verified:

  • The countdown has not been observed running a real match. Requires a LAN free-for-all with three or more mutually hostile sides — in a 1v1 the defeat ends the match and the countdown is cancelled by design.
  • /autoleave parses and host-gates, but cannot round trip until the service supports field 20.
  • TheInGameUI->message with a %d argument is not used elsewhere in this codebase (existing messages use %ls). If the seconds render incorrectly the fix is to pre-format the value into the string.

9. Generals parity

Zero Hour only, per CONTRIBUTING.md. The Generals replica is not included because the Generals target does not currently compile beyond the fixes in this PR, and the feature depends on NGMP, which is Zero Hour only. The local-preference half would port cleanly once that target builds.

The Generals target does not compile. Each of these is a partially applied
upstream change that left Generals inconsistent with itself, while GeneralsMD
received the complete change.

- Common/GameCommon.h includes "WWCommon.h" unqualified, which cannot resolve
  because only the WWVegas roots are on the include path. Qualifies it as
  "WWLib/WWCommon.h", matching what Core/GameEngine/Include/Common/GameCommon.h
  already does.
- Common/GameEngine.h is missing the public access specifier, so the
  constructor, destructor and virtual overrides are all private and GameMain()
  cannot call init() or execute().
- GameLogic/GameLogic.h declares the game mode enum anonymously while the same
  header declares toString(GameMode) and getGameMode() returns GameMode, and
  m_gameMode is declared Int while GameLogic.cpp assigns it to a GameMode. Names
  the enum and corrects the member type to match GeneralsMD.

Together these clear 452 of the 687 errors the target produced. The remainder
are separate gaps and are not addressed here.
…rter

StatsExporter.cpp is the only translation unit that includes both the engine
headers and <zlib.h>. BaseTypeCore.h typedefs Byte as char while zlib's
zconf.h typedefs it as unsigned char, so the file fails to compile whenever it
is rebuilt. CompressionManager.cpp includes the same header without trouble
because it never pulls in the engine types.

zconf.h skips its own Byte typedef when __MACTYPES__ is defined, which is its
documented hook for hosts that already provide the type. Only the gz* file API
is used here and none of it mentions Byte or Bytef.

Note that Bytef resolves to the engine's Byte as a result, so the byte oriented
zlib calls must not be used in this file. Use core_compression for those. The
comment at the include says so.
A defeated player in a LAN or online match is left sitting in a passive
spectating state until they quit by hand. This adds an opt-in countdown that
returns their client to the score screen on its own.

Disabled by default. The player chooses a duration in the options menu: Off,
30 seconds, 1, 2, 3 or 5 minutes.

The behaviour is purely local to the defeated player's own client. It changes
no simulation state and is observable only by the player it happens to, so it
carries no desync surface, needs no agreement between peers, and only automates
the quit that player can already perform by hand. It is therefore driven by a
local preference rather than the game options wire format, which leaves
GameInfoToAsciiString, the replay header and the skirmish save version
untouched.

Countdown behaviour:

- Runs in LAN and online games only. Skirmish is excluded because a defeated
  skirmish player restarts rather than sitting out a match. Campaign and replay
  playback are excluded by the same predicate.
- Never arms while an ally is still alive. VictoryConditions marks a defeated
  player victorious if a surviving ally goes on to win, so leaving early would
  discard a win the player is still entitled to. Re-tested every frame rather
  than once at defeat, because with three or more alliances the local alliance
  can be wiped out while the match carries on between the others.
- Never arms for observers.
- Cancels once a single alliance remains, so it cannot race the normal end of
  match path into the score screen.
- Frame based, so it freezes while the game is paused and follows the game speed
  setting rather than the wall clock.
- Calls exitGame() rather than quit(), which would either open the quit menu or
  self destruct the player in a multiplayer game.
- Latches after firing, because exitGame() only posts a deferred
  MSG_CLEAR_GAME_DATA and update() keeps running for several frames afterwards.

The preference is resolved once during map load rather than at defeat time so
the file read never lands mid match. Values are clamped on read, so a hand
edited Options.ini cannot produce a pathological timer.

The options menu control is null guarded on every use and the save path only
writes when the control is present, so a client running against unmodified
window assets degrades to the feature being off instead of crashing or silently
resetting an existing preference. A duration in Options.ini that is not one of
the offered ones is shown as an extra entry rather than being rewritten on save.

Requires new window and string assets, see the pull request description.
@JawadYzbk JawadYzbk changed the title feat: Add optional auto-leave on defeat countdown feat: Add optional auto-leave on defeat countdown with online host enforcement Sep 16, 2026
Adds a host enforced auto-leave duration to the online lobby, set with
/autoleave <seconds> in the staging room. The host value wins when non zero.
Zero means the host does not enforce one and each player's own preference
applies, which is the default for a new lobby.

The value travels in the backend lobby record alongside the other host set
rules, not in GameInfoToAsciiString, so there is no wire format change. This
matters: ParseAsciiStringToGameInfo rejects the whole options string on an
unrecognised key, so adding a tag there would make a patched host's lobby
unjoinable and invisible to unpatched clients, and would change the replay
header. None of that applies here.

Client plumbing mirrors MaximumCameraHeight, which is the closest existing
precedent for a host set numeric lobby rule:

- ELobbyUpdateField::LOBBY_AUTO_LEAVE = 20
- NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_AutoLeave()
- LobbyEntry::auto_leave_seconds, read at both lobby parse sites
- NGMPGame applies it to the GameInfo the gameplay code reads
- GameInfo carries it, deliberately outside the ascii options string and the
  slot list xfer

Note that this client's copy of ELobbyUpdateField had drifted behind the
service, stopping at JOINABILITY = 18 while the service also defines
HOST_ACTION_BULK_SLOT_UPDATE = 19. Claiming 19 here would have collided with
that handler, so auto-leave takes 20. The comment at the enum records this.

The lobby parse sites use value("AutoLeaveSeconds", 0) rather than get_to(),
which throws on a missing key. This is deliberate and load bearing: it lets the
client ship before the service does, and a client talking to a service that does
not yet return the field simply sees zero and leaves the feature off. Do not
replace it with get_to() for consistency with the neighbouring lines.

Input is a chat command rather than a staging room control because the online
options row has roughly 50px of free width against the 200px a labelled combo
box needs, so a control would require shrinking the chat box. The fork already
uses this pattern for host settings, see /maxcameraheight and /friendsonly.

This is inert until the Generals Online service stores and echoes the field.
The contract is in the pull request description.

Note that auto-leave is advisory rather than enforceable, since the behaviour is
client side. A modified client can ignore it.
@JawadYzbk
JawadYzbk force-pushed the feature/auto-kick-defeat branch from 8b84c75 to e0b4354 Compare September 16, 2026 12:39
@JawadYzbk

Copy link
Copy Markdown
Author

after investigating,
disabling allow observer is enough to kick players,
thanks for discord support to inform that.

@JawadYzbk JawadYzbk closed this Sep 16, 2026
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