From ac021ec5844d530c21266759db9ab04c8ac95e47 Mon Sep 17 00:00:00 2001 From: Jean Date: Tue, 21 Jul 2026 10:40:27 +0000 Subject: [PATCH] Notify a game opponent when a departing guest's game ends --- api/src/lib.rs | 7 +++++-- lang/de.json | 3 ++- lang/es-ar.json | 3 ++- lang/es.json | 3 ++- lang/fr.json | 3 ++- lang/pt-br.json | 3 ++- lang/pt.json | 3 ++- modules/gameserv/src/lib.rs | 24 +++++++++++++++++++++++- src/engine/mod.rs | 25 ++++++++++++++++--------- 9 files changed, 56 insertions(+), 18 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index aeff949..3636cf3 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -2465,8 +2465,11 @@ pub trait Service: Send { } // A user left the network (QUIT / KILL / netsplit). Lets a service drop any // per-connection state it keyed on that departing uid before the uid is recycled - // to a new connection. Default: nothing to do. - fn on_user_quit(&mut self, _uid: &str) {} + // to a new connection, and return any notices to emit as a result (e.g. telling + // an opponent their game just ended). Default: nothing to do. + fn on_user_quit(&mut self, _uid: &str, _net: &dyn NetView, _store: &dyn Store) -> Vec { + Vec::new() + } fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, store: &mut dyn Store); } diff --git a/lang/de.json b/lang/de.json index 17e8eb3..7e1bae8 100644 --- a/lang/de.json +++ b/lang/de.json @@ -1372,5 +1372,6 @@ "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Syntax: \u0002LOGIN \u0002\nMeldet dich beim Konto von an und setzt dich auf diesen Nick, wobei jede Sitzung, die ihn belegt, freigegeben wird.", "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nÜberträgt den Gründer oder ändert eine Kanaleinstellung.", "Your account is pending. Ask an existing member to vouch for you with \u0002/msg NickServ VOUCH {account}\u0002.": "Dein Konto ist ausstehend. Bitte ein bestehendes Mitglied, mit \u0002/msg NickServ VOUCH {account}\u0002 für dich zu bürgen.", - "You vouched for \u0002{account}\u0002; their account is now active.": "Du hast für \u0002{account}\u0002 gebürgt; das Konto ist jetzt aktiv." + "You vouched for \u0002{account}\u0002; their account is now active.": "Du hast für \u0002{account}\u0002 gebürgt; das Konto ist jetzt aktiv.", + "Your opponent left. Game \u0002#{id}\u0002 is over.": "Dein Gegner hat die Verbindung getrennt. Spiel \u0002#{id}\u0002 ist vorbei." } \ No newline at end of file diff --git a/lang/es-ar.json b/lang/es-ar.json index 86b8f4d..35d1aea 100644 --- a/lang/es-ar.json +++ b/lang/es-ar.json @@ -1372,5 +1372,6 @@ "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Sintaxis: \u0002LOGIN \u0002\nInicia sesión en la cuenta de y te mueve a ese apodo, liberando cualquier sesión que ya lo esté usando.", "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Sintaxis: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfiere el fundador o cambia un ajuste del canal.", "Your account is pending. Ask an existing member to vouch for you with \u0002/msg NickServ VOUCH {account}\u0002.": "Tu cuenta está pendiente. Pedile a un miembro existente que te avale con \u0002/msg NickServ VOUCH {account}\u0002.", - "You vouched for \u0002{account}\u0002; their account is now active.": "Avalaste a \u0002{account}\u0002; su cuenta ya está activa." + "You vouched for \u0002{account}\u0002; their account is now active.": "Avalaste a \u0002{account}\u0002; su cuenta ya está activa.", + "Your opponent left. Game \u0002#{id}\u0002 is over.": "Tu oponente se fue. La partida \u0002#{id}\u0002 terminó." } \ No newline at end of file diff --git a/lang/es.json b/lang/es.json index c6e857a..e204b57 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1372,5 +1372,6 @@ "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Sintaxis: \u0002LOGIN \u0002\nInicia sesión en la cuenta de y te mueve a ese apodo, liberando cualquier sesión que ya lo ocupe.", "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Sintaxis: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfiere el fundador o cambia un ajuste del canal.", "Your account is pending. Ask an existing member to vouch for you with \u0002/msg NickServ VOUCH {account}\u0002.": "Tu cuenta está pendiente. Pide a un miembro existente que te avale con \u0002/msg NickServ VOUCH {account}\u0002.", - "You vouched for \u0002{account}\u0002; their account is now active.": "Avalaste a \u0002{account}\u0002; su cuenta ya está activa." + "You vouched for \u0002{account}\u0002; their account is now active.": "Avalaste a \u0002{account}\u0002; su cuenta ya está activa.", + "Your opponent left. Game \u0002#{id}\u0002 is over.": "Tu oponente se fue. La partida \u0002#{id}\u0002 terminó." } \ No newline at end of file diff --git a/lang/fr.json b/lang/fr.json index 0214792..9f5a2c2 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -1372,5 +1372,6 @@ "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Syntaxe : \u0002LOGIN \u0002\nVous connecte au compte de et vous place sur ce pseudo, en libérant toute session qui l'occupe déjà.", "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Syntaxe : \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfère le fondateur ou modifie un réglage du salon.", "Your account is pending. Ask an existing member to vouch for you with \u0002/msg NickServ VOUCH {account}\u0002.": "Votre compte est en attente. Demandez à un membre existant de vous parrainer avec \u0002/msg NickServ VOUCH {account}\u0002.", - "You vouched for \u0002{account}\u0002; their account is now active.": "Vous avez parrainé \u0002{account}\u0002 ; son compte est maintenant actif." + "You vouched for \u0002{account}\u0002; their account is now active.": "Vous avez parrainé \u0002{account}\u0002 ; son compte est maintenant actif.", + "Your opponent left. Game \u0002#{id}\u0002 is over.": "Ton adversaire est parti. La partie \u0002#{id}\u0002 est terminée." } \ No newline at end of file diff --git a/lang/pt-br.json b/lang/pt-br.json index a74a1aa..e1b4a3a 100644 --- a/lang/pt-br.json +++ b/lang/pt-br.json @@ -1372,5 +1372,6 @@ "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Sintaxe: \u0002LOGIN \u0002\nEntra na conta de e move você para esse apelido, liberando qualquer sessão que já o ocupe.", "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Sintaxe: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfere o fundador ou altera uma configuração do canal.", "Your account is pending. Ask an existing member to vouch for you with \u0002/msg NickServ VOUCH {account}\u0002.": "Sua conta está pendente. Peça a um membro existente para avalizar você com \u0002/msg NickServ VOUCH {account}\u0002.", - "You vouched for \u0002{account}\u0002; their account is now active.": "Você avalizou \u0002{account}\u0002; a conta agora está ativa." + "You vouched for \u0002{account}\u0002; their account is now active.": "Você avalizou \u0002{account}\u0002; a conta agora está ativa.", + "Your opponent left. Game \u0002#{id}\u0002 is over.": "Seu oponente saiu. O jogo \u0002#{id}\u0002 acabou." } \ No newline at end of file diff --git a/lang/pt.json b/lang/pt.json index aba22aa..55478d4 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -1372,5 +1372,6 @@ "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Sintaxe: \u0002LOGIN \u0002\nInicia sessão na conta de e move-o para essa alcunha, libertando qualquer sessão que já a ocupe.", "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Sintaxe: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfere o fundador ou altera uma definição do canal.", "Your account is pending. Ask an existing member to vouch for you with \u0002/msg NickServ VOUCH {account}\u0002.": "A sua conta está pendente. Peça a um membro existente que o avalize com \u0002/msg NickServ VOUCH {account}\u0002.", - "You vouched for \u0002{account}\u0002; their account is now active.": "Avalizou \u0002{account}\u0002; a conta está agora ativa." + "You vouched for \u0002{account}\u0002; their account is now active.": "Avalizou \u0002{account}\u0002; a conta está agora ativa.", + "Your opponent left. Game \u0002#{id}\u0002 is over.": "O teu adversário saiu. O jogo \u0002#{id}\u0002 acabou." } \ No newline at end of file diff --git a/modules/gameserv/src/lib.rs b/modules/gameserv/src/lib.rs index c3b0624..f02591e 100644 --- a/modules/gameserv/src/lib.rs +++ b/modules/gameserv/src/lib.rs @@ -499,8 +499,30 @@ impl Service for GameServ { (BLURB, TOPICS) } - fn on_user_quit(&mut self, uid: &str) { + fn on_user_quit(&mut self, uid: &str, net: &dyn NetView, store: &dyn Store) -> Vec { + // Tell each still-online opponent of an ACTIVE game that the departing guest + // ended it (in the opponent's language), then drop the guest's games. + let me = self.uid.clone(); + let mut out = Vec::new(); + for g in self.games.values() { + if g.status != Status::Active { + continue; + } + let opp = if g.acc_a == uid { + &g.nick_b + } else if g.acc_b == uid { + &g.nick_a + } else { + continue; + }; + if let Some(ouid) = net.uid_by_nick(opp).map(str::to_string) { + let lang = net.account_of(&ouid).and_then(|a| store.language_of(a)).unwrap_or_else(|| store.default_language()); + let text = echo_api::render(&lang, "Your opponent left. Game \x02#{id}\x02 is over.", &[("id", g.id.to_string())]); + out.push(NetAction::Notice { from: me.clone(), to: ouid, text }); + } + } self.drop_guest_games(uid); + out } fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, _store: &mut dyn Store) { diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 0066ad2..aabc09a 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -736,11 +736,16 @@ impl Engine { // Forget a user who left the network (QUIT or KILL): drop their membership, // any half-finished SASL exchange, and their pending nick-protection timer. - fn forget_user(&mut self, uid: &str) { + fn forget_user(&mut self, uid: &str) -> Vec { // Let services drop state keyed on this uid (e.g. GamesServ guest games) - // before the uid can be recycled to a new connection. - for svc in self.services.iter_mut() { - svc.on_user_quit(uid); + // before the uid can be recycled to a new connection; collect any notices + // they hand back (e.g. telling an opponent their game just ended). + let mut notices = Vec::new(); + { + let Self { services, network, db, .. } = self; + for svc in services.iter_mut() { + notices.extend(svc.on_user_quit(uid, &*network, &*db)); + } } let chans = self.network.channels_of(uid); self.network.user_quit(uid); @@ -751,6 +756,7 @@ impl Engine { self.sasl_source.remove(uid); // the captured SASL host/IP dies with the connection too self.pending_enforce.retain(|p| p.uid != uid); self.forget_chatter_everywhere(uid); + notices } // Forget a channel once it's truly gone on the ircd — no members AND no resident @@ -1607,8 +1613,8 @@ impl Engine { NetEvent::Quit { uid, reason } => { // Match before we forget them, so their identity is still resolvable. let what = if reason.is_empty() { "disconnected".to_string() } else { format!("disconnected ({reason})") }; - let out: Vec = self.notify_line('d', &uid, None, &what).into_iter().collect(); - self.forget_user(&uid); + let mut out: Vec = self.notify_line('d', &uid, None, &what).into_iter().collect(); + out.extend(self.forget_user(&uid)); out } NetEvent::UserMode { uid, modes } => { @@ -1641,8 +1647,7 @@ impl Engine { let bots = self.reconcile_bots(); return self.finish(out, bots); } - self.forget_user(&uid); - Vec::new() + self.forget_user(&uid) } NetEvent::ServerLink { sid, parent } => { self.network.server_link(&sid, &parent); @@ -1653,7 +1658,9 @@ impl Engine { // forget every user behind the departed server and its descendants. for sid in self.network.server_split(&server) { for uid in self.network.uids_on_server(&sid) { - self.forget_user(&uid); + // Drop guest games as usual, but swallow the opponent notices: + // a netsplit is a mass event and must not spray "opponent left". + let _ = self.forget_user(&uid); } } Vec::new()