fix(client): refuse bare dialog continue while a choice is pending

The engine refuses RESUME_PAUSEBUTTON while if_addresumebutton options are
registered (a choice must be answered with IF_BUTTON). Both clients still
sent the bare RESUME and latched resumedPauseButton - and since the refused
resume produces no new interface, nothing ever reset the latch: every later
continue click returned client_rejected and the dialog was pinned until
close. This is the 'toll dialogs only advance with sendClickDialog(0) once,
then never again' failure fleets kept hitting (bug reports 2026-08-06 and
2026-08-10).

Choice pages are exactly the ones publishing OK-typed (buttonType 1)
options, so clickDialogOption now refuses a bare continue - and a
CONTINUE-typed click - while any OK option is visible, without sending or
latching. IF_BUTTON answers are unaffected.

Verified live against the local engine (lite client): bare
sendClickDialog(0) on the tutorial guide's choice page now returns
client_rejected immediately and the choice remains answerable by text;
plain continue pages still advance via their published index-1 option.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude Fable 5 committed 2026-08-11 11:13:45 -04:00
1 parent c4c22b395c
commit db83d0425a
2 files changed
+22 -4

No files matched your search

+11 -2
View File
@@ -2421,8 +2421,17 @@ export class Client extends GameShell {
return false;
}
// A pending choice (OK-typed options, registered server-side via
// if_addresumebutton) must be answered with IF_BUTTON on one of the
// options - the engine refuses a bare RESUME while a choice is
// pending. Sending one anyway latched resumedPauseButton with no new
// interface arriving to reset it, pinning the dialog against every
// later continue click. Refuse locally instead of desyncing.
const choicePending = (): boolean =>
this.getDialogOptions().some(o => o.buttonType === ButtonType.BUTTON_OK);
if (optionIndex === 0) {
if (!this.resumedPauseButton && this.chatModalId !== -1) {
if (!this.resumedPauseButton && this.chatModalId !== -1 && !choicePending()) {
this.writePacketOpcode(ClientProt.RESUME_PAUSEBUTTON);
this.out.p2(this.chatModalId);
this.resumedPauseButton = true;
@@ -2437,7 +2446,7 @@ export class Client extends GameShell {
const option = dialogOptions[optionIndex - 1];
if (option.buttonType === ButtonType.BUTTON_CONTINUE) {
if (!this.resumedPauseButton) {
if (!this.resumedPauseButton && !choicePending()) {
this.writePacketOpcode(ClientProt.RESUME_PAUSEBUTTON);
this.out.p2(option.componentId);
this.resumedPauseButton = true;
+11 -2
View File
@@ -418,11 +418,20 @@ export function clickDialogOption(c: LiteClient, optionIndex = 0): boolean {
return false;
}
// A pending choice (OK-typed options, registered server-side via
// if_addresumebutton) must be answered with IF_BUTTON on one of the
// options - the engine refuses a bare RESUME while a choice is pending.
// Sending one anyway latched resumedPauseButton with no new interface
// arriving to reset it, pinning the dialog against every later continue
// click. Refuse locally instead of desyncing.
const choicePending = (): boolean =>
getDialogOptions(c).some(o => o.buttonType === ButtonType.BUTTON_OK);
if (optionIndex === 0) {
// Bare "continue". The resumedPauseButton guard mirrors the engine's
// ResumePauseButtonHandler fix: sending a second RESUME while one is
// pending re-picks the previous choice.
if (!c.resumedPauseButton && c.chatModalId !== -1) {
if (!c.resumedPauseButton && c.chatModalId !== -1 && !choicePending()) {
c.writeOpcode(ClientProt.RESUME_PAUSEBUTTON);
c.out.p2(c.chatModalId);
c.resumedPauseButton = true;
@@ -442,7 +451,7 @@ export function clickDialogOption(c: LiteClient, optionIndex = 0): boolean {
const option = options[optionIndex - 1];
if (option.buttonType === ButtonType.BUTTON_CONTINUE) {
if (c.resumedPauseButton) {
if (c.resumedPauseButton || choicePending()) {
return false;
}
c.writeOpcode(ClientProt.RESUME_PAUSEBUTTON);