From 21e90b975a4b97b263155d5f8c11491991f03469 Mon Sep 17 00:00:00 2001 From: Farhad Jay Date: Mon, 23 Mar 2026 14:21:43 -0700 Subject: [PATCH 1/3] Remove redundant duel result message from chat The mute reason was being sent as a separate chat message after a duel, resulting in a duplicate announcement. The winner message already communicates the outcome. --- lib/commands/implementations/duel.js | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/lib/commands/implementations/duel.js b/lib/commands/implementations/duel.js index 735fbe0..311a8f3 100644 --- a/lib/commands/implementations/duel.js +++ b/lib/commands/implementations/duel.js @@ -120,13 +120,7 @@ function duel(defaultMuteDuration) { services.messageRelay.sendOutputMessage( `${winner} wins the duel! ${loser} gets muted for ${muteString}.`, ); - services.punishmentStream.write( - makeMute( - loser, - muteDuration, - `${loser} lost a duel to ${winner}, started by ${rawMessage.user}.`, - ), - ); + services.punishmentStream.write(makeMute(loser, muteDuration)); }); return new CommandOutput( From d5278f4a5507ea0b4e0708544ca9a8418c65d708 Mon Sep 17 00:00:00 2001 From: Farhad Jay Date: Mon, 23 Mar 2026 14:40:15 -0700 Subject: [PATCH 2/3] Add 10-second preparation countdown before duel phrase reveal Instead of immediately showing the phrase, the duel now announces a 10-second prep window so participants can get ready. After the countdown, the phrase is revealed via sendOutputMessage and the 30-second typing race begins. --- lib/commands/implementations/duel.js | 74 +++--- .../lib/commands/implementations/duel.test.js | 233 ++++++++++++------ 2 files changed, 198 insertions(+), 109 deletions(-) diff --git a/lib/commands/implementations/duel.js b/lib/commands/implementations/duel.js index 311a8f3..45c89c7 100644 --- a/lib/commands/implementations/duel.js +++ b/lib/commands/implementations/duel.js @@ -51,6 +51,7 @@ function injectHomoglyph(phrase) { function duel(defaultMuteDuration) { let duelActive = false; let failSafeTimeout = null; + let prepTimeout = null; return (input, services, rawMessage) => { if (duelActive) { @@ -82,50 +83,61 @@ function duel(defaultMuteDuration) { } const muteString = formatDuration(moment.duration(muteDuration, 'seconds')); - const phrase = generatePhrase(); - const displayPhrase = injectHomoglyph(phrase); - - const listener = services.messageRelay.startListenerForChatMessages('duel'); - if (listener === false) { - return new CommandOutput(null, 'Something went wrong starting the duel. Try again.'); - } duelActive = true; - failSafeTimeout = setTimeout(() => { - duelActive = false; - services.messageRelay.stopRelay('duel'); - services.messageRelay.sendOutputMessage( - `DUEL between ${user1} and ${user2} has timed out. No one gets muted.`, - ); - }, 30000); - - listener.on('msg', (data) => { - const sender = data.user.toLowerCase(); - if (sender !== user1.toLowerCase() && sender !== user2.toLowerCase()) { - return; - } + prepTimeout = setTimeout(() => { + const phrase = generatePhrase(); + const displayPhrase = injectHomoglyph(phrase); - if (data.message.trim().toLowerCase() !== phrase.toLowerCase()) { + const listener = services.messageRelay.startListenerForChatMessages('duel'); + if (listener === false) { + duelActive = false; + services.messageRelay.sendOutputMessage( + 'Something went wrong starting the duel. Try again.', + ); return; } - clearTimeout(failSafeTimeout); - duelActive = false; - services.messageRelay.stopRelay('duel'); - - const winner = data.user; - const loser = sender === user1.toLowerCase() ? user2 : user1; + failSafeTimeout = setTimeout(() => { + duelActive = false; + services.messageRelay.stopRelay('duel'); + services.messageRelay.sendOutputMessage( + `DUEL between ${user1} and ${user2} has timed out. No one gets muted.`, + ); + }, 30000); + + listener.on('msg', (data) => { + const sender = data.user.toLowerCase(); + if (sender !== user1.toLowerCase() && sender !== user2.toLowerCase()) { + return; + } + + if (data.message.trim().toLowerCase() !== phrase.toLowerCase()) { + return; + } + + clearTimeout(failSafeTimeout); + duelActive = false; + services.messageRelay.stopRelay('duel'); + + const winner = data.user; + const loser = sender === user1.toLowerCase() ? user2 : user1; + + services.messageRelay.sendOutputMessage( + `${winner} wins the duel! ${loser} gets muted for ${muteString}.`, + ); + services.punishmentStream.write(makeMute(loser, muteDuration)); + }); services.messageRelay.sendOutputMessage( - `${winner} wins the duel! ${loser} gets muted for ${muteString}.`, + `DUEL! ${user1} vs ${user2} -- First to type "${displayPhrase}" wins. Loser gets muted for ${muteString}. You have 30 seconds. GO!`, ); - services.punishmentStream.write(makeMute(loser, muteDuration)); - }); + }, 10000); return new CommandOutput( null, - `DUEL! ${user1} vs ${user2} -- First to type "${displayPhrase}" wins. Loser gets muted for ${muteString}. You have 30 seconds.`, + `DUEL! ${user1} vs ${user2} -- Get ready! The phrase to type will appear in 10 seconds. Loser gets muted for ${muteString}.`, ); }; } diff --git a/tests/lib/commands/implementations/duel.test.js b/tests/lib/commands/implementations/duel.test.js index 7765206..3cc2632 100644 --- a/tests/lib/commands/implementations/duel.test.js +++ b/tests/lib/commands/implementations/duel.test.js @@ -41,14 +41,27 @@ describe('duel command', () => { }); it('announces duel with correct format', function () { - const output = this.duel.work('Alice Bob', this.mockServices, this.rawMessage); - assert.strictEqual(output.err, null); - assert.ok(output.output.includes('Alice')); - assert.ok(output.output.includes('Bob')); - assert.ok(output.output.includes('DUEL!')); - assert.ok(output.output.includes('10m')); - assert.ok(output.output.includes('30 seconds')); - assert.ok(/"[^"]+"/.test(output.output)); + const clock = sinon.useFakeTimers(); + try { + const sendSpy = sinon.spy(this.mockServices.messageRelay, 'sendOutputMessage'); + const output = this.duel.work('Alice Bob', this.mockServices, this.rawMessage); + assert.strictEqual(output.err, null); + assert.ok(output.output.includes('Alice')); + assert.ok(output.output.includes('Bob')); + assert.ok(output.output.includes('DUEL!')); + assert.ok(output.output.includes('10m')); + assert.ok(output.output.includes('10 seconds')); + + clock.tick(10000); + + assert.strictEqual(sendSpy.callCount, 1); + const challengeMsg = sendSpy.getCall(0).args[0]; + assert.ok(challengeMsg.includes('DUEL!')); + assert.ok(challengeMsg.includes('30 seconds')); + assert.ok(/"[^"]+"/.test(challengeMsg)); + } finally { + clock.restore(); + } }); it('rejects concurrent duels', function () { @@ -58,74 +71,113 @@ describe('duel command', () => { }); it('detects winner and mutes loser', function () { - const output = this.duel.work('Alice Bob', this.mockServices, this.rawMessage); - const phrase = stripHomoglyphs(/"([^"]+)"/.exec(output.output)[1]); + const clock = sinon.useFakeTimers(); + try { + const sendSpy = sinon.spy(this.mockServices.messageRelay, 'sendOutputMessage'); + this.duel.work('Alice Bob', this.mockServices, this.rawMessage); + clock.tick(10000); + + const phrase = stripHomoglyphs(/"([^"]+)"/.exec(sendSpy.getCall(0).args[0])[1]); - this.mockServices.messageRelay.relayMessageToListeners('msg', { - message: phrase, - user: 'Alice', - }); + this.mockServices.messageRelay.relayMessageToListeners('msg', { + message: phrase, + user: 'Alice', + }); - assert.strictEqual(this.mockServices.punishmentStream.write.callCount, 1); - assert.deepStrictEqual( - this.mockServices.punishmentStream.write.getCall(0).args[0], - makeMute('Bob', 600, 'Bob lost a duel to Alice, started by ModUser.'), - ); + assert.strictEqual(this.mockServices.punishmentStream.write.callCount, 1); + assert.deepStrictEqual( + this.mockServices.punishmentStream.write.getCall(0).args[0], + makeMute('Bob', 600), + ); + } finally { + clock.restore(); + } }); it('ignores messages from non-duelists', function () { - const output = this.duel.work('Alice Bob', this.mockServices, this.rawMessage); - const phrase = stripHomoglyphs(/"([^"]+)"/.exec(output.output)[1]); + const clock = sinon.useFakeTimers(); + try { + const sendSpy = sinon.spy(this.mockServices.messageRelay, 'sendOutputMessage'); + this.duel.work('Alice Bob', this.mockServices, this.rawMessage); + clock.tick(10000); - this.mockServices.messageRelay.relayMessageToListeners('msg', { - message: phrase, - user: 'Charlie', - }); + const phrase = stripHomoglyphs(/"([^"]+)"/.exec(sendSpy.getCall(0).args[0])[1]); - assert.strictEqual(this.mockServices.punishmentStream.write.callCount, 0); + this.mockServices.messageRelay.relayMessageToListeners('msg', { + message: phrase, + user: 'Charlie', + }); + + assert.strictEqual(this.mockServices.punishmentStream.write.callCount, 0); + } finally { + clock.restore(); + } }); it('matches phrase case-insensitively', function () { - const output = this.duel.work('Alice Bob', this.mockServices, this.rawMessage); - const phrase = stripHomoglyphs(/"([^"]+)"/.exec(output.output)[1]); + const clock = sinon.useFakeTimers(); + try { + const sendSpy = sinon.spy(this.mockServices.messageRelay, 'sendOutputMessage'); + this.duel.work('Alice Bob', this.mockServices, this.rawMessage); + clock.tick(10000); + + const phrase = stripHomoglyphs(/"([^"]+)"/.exec(sendSpy.getCall(0).args[0])[1]); - this.mockServices.messageRelay.relayMessageToListeners('msg', { - message: phrase.toUpperCase(), - user: 'Bob', - }); + this.mockServices.messageRelay.relayMessageToListeners('msg', { + message: phrase.toUpperCase(), + user: 'Bob', + }); - assert.strictEqual(this.mockServices.punishmentStream.write.callCount, 1); - assert.deepStrictEqual( - this.mockServices.punishmentStream.write.getCall(0).args[0], - makeMute('Alice', 600, 'Alice lost a duel to Bob, started by ModUser.'), - ); + assert.strictEqual(this.mockServices.punishmentStream.write.callCount, 1); + assert.deepStrictEqual( + this.mockServices.punishmentStream.write.getCall(0).args[0], + makeMute('Alice', 600), + ); + } finally { + clock.restore(); + } }); it('ignores incorrect messages from duelists', function () { - this.duel.work('Alice Bob', this.mockServices, this.rawMessage); + const clock = sinon.useFakeTimers(); + try { + this.duel.work('Alice Bob', this.mockServices, this.rawMessage); + clock.tick(10000); - this.mockServices.messageRelay.relayMessageToListeners('msg', { - message: 'wrong phrase entirely', - user: 'Alice', - }); + this.mockServices.messageRelay.relayMessageToListeners('msg', { + message: 'wrong phrase entirely', + user: 'Alice', + }); - assert.strictEqual(this.mockServices.punishmentStream.write.callCount, 0); + assert.strictEqual(this.mockServices.punishmentStream.write.callCount, 0); + } finally { + clock.restore(); + } }); it('uses custom duration when specified', function () { - const output = this.duel.work('5m Alice Bob', this.mockServices, this.rawMessage); - const phrase = stripHomoglyphs(/"([^"]+)"/.exec(output.output)[1]); - assert.ok(output.output.includes('5m')); + const clock = sinon.useFakeTimers(); + try { + const sendSpy = sinon.spy(this.mockServices.messageRelay, 'sendOutputMessage'); + const output = this.duel.work('5m Alice Bob', this.mockServices, this.rawMessage); + assert.ok(output.output.includes('5m')); + + clock.tick(10000); + + const phrase = stripHomoglyphs(/"([^"]+)"/.exec(sendSpy.getCall(0).args[0])[1]); - this.mockServices.messageRelay.relayMessageToListeners('msg', { - message: phrase, - user: 'Alice', - }); + this.mockServices.messageRelay.relayMessageToListeners('msg', { + message: phrase, + user: 'Alice', + }); - assert.deepStrictEqual( - this.mockServices.punishmentStream.write.getCall(0).args[0], - makeMute('Bob', 300, 'Bob lost a duel to Alice, started by ModUser.'), - ); + assert.deepStrictEqual( + this.mockServices.punishmentStream.write.getCall(0).args[0], + makeMute('Bob', 300), + ); + } finally { + clock.restore(); + } }); it('uses default duration when none specified', function () { @@ -139,11 +191,12 @@ describe('duel command', () => { const sendSpy = sinon.spy(this.mockServices.messageRelay, 'sendOutputMessage'); this.duel.work('Alice Bob', this.mockServices, this.rawMessage); - clock.tick(30000); + // 10s prep + 30s typing window + clock.tick(10000 + 30000); assert.strictEqual(this.mockServices.punishmentStream.write.callCount, 0); - assert.strictEqual(sendSpy.callCount, 1); - assert.ok(sendSpy.getCall(0).args[0].includes('timed out')); + assert.strictEqual(sendSpy.callCount, 2); // challenge msg + timeout msg + assert.ok(sendSpy.getCall(1).args[0].includes('timed out')); } finally { clock.restore(); } @@ -160,25 +213,33 @@ describe('duel command', () => { }); it('allows new duel after previous one completes', function () { - const output1 = this.duel.work('Alice Bob', this.mockServices, this.rawMessage); - const phrase1 = stripHomoglyphs(/"([^"]+)"/.exec(output1.output)[1]); + const clock = sinon.useFakeTimers(); + try { + const sendSpy = sinon.spy(this.mockServices.messageRelay, 'sendOutputMessage'); + this.duel.work('Alice Bob', this.mockServices, this.rawMessage); + clock.tick(10000); - this.mockServices.messageRelay.relayMessageToListeners('msg', { - message: phrase1, - user: 'Alice', - }); + const phrase1 = stripHomoglyphs(/"([^"]+)"/.exec(sendSpy.getCall(0).args[0])[1]); - const output2 = this.duel.work('Charlie Dave', this.mockServices, this.rawMessage); - assert.ok(output2.output.includes('DUEL!')); - assert.ok(output2.output.includes('Charlie')); - assert.ok(output2.output.includes('Dave')); + this.mockServices.messageRelay.relayMessageToListeners('msg', { + message: phrase1, + user: 'Alice', + }); + + const output2 = this.duel.work('Charlie Dave', this.mockServices, this.rawMessage); + assert.ok(output2.output.includes('DUEL!')); + assert.ok(output2.output.includes('Charlie')); + assert.ok(output2.output.includes('Dave')); + } finally { + clock.restore(); + } }); it('allows new duel after timeout', function () { const clock = sinon.useFakeTimers(); try { this.duel.work('Alice Bob', this.mockServices, this.rawMessage); - clock.tick(30000); + clock.tick(10000 + 30000); const output2 = this.duel.work('Charlie Dave', this.mockServices, this.rawMessage); assert.ok(output2.output.includes('DUEL!')); @@ -188,20 +249,36 @@ describe('duel command', () => { }); it('displayed phrase contains at least one non-ASCII character', function () { - const output = this.duel.work('Alice Bob', this.mockServices, this.rawMessage); - const displayPhrase = /"([^"]+)"/.exec(output.output)[1]; - assert.ok(/[^\x00-\x7F]/.test(displayPhrase), 'display phrase should contain a non-ASCII homoglyph'); + const clock = sinon.useFakeTimers(); + try { + const sendSpy = sinon.spy(this.mockServices.messageRelay, 'sendOutputMessage'); + this.duel.work('Alice Bob', this.mockServices, this.rawMessage); + clock.tick(10000); + + const displayPhrase = /"([^"]+)"/.exec(sendSpy.getCall(0).args[0])[1]; + assert.ok(/[^\x00-\x7F]/.test(displayPhrase), 'display phrase should contain a non-ASCII homoglyph'); + } finally { + clock.restore(); + } }); it('rejects copy-pasted phrase containing homoglyph', function () { - const output = this.duel.work('Alice Bob', this.mockServices, this.rawMessage); - const displayPhrase = /"([^"]+)"/.exec(output.output)[1]; + const clock = sinon.useFakeTimers(); + try { + const sendSpy = sinon.spy(this.mockServices.messageRelay, 'sendOutputMessage'); + this.duel.work('Alice Bob', this.mockServices, this.rawMessage); + clock.tick(10000); - this.mockServices.messageRelay.relayMessageToListeners('msg', { - message: displayPhrase, - user: 'Alice', - }); + const displayPhrase = /"([^"]+)"/.exec(sendSpy.getCall(0).args[0])[1]; - assert.strictEqual(this.mockServices.punishmentStream.write.callCount, 0); + this.mockServices.messageRelay.relayMessageToListeners('msg', { + message: displayPhrase, + user: 'Alice', + }); + + assert.strictEqual(this.mockServices.punishmentStream.write.callCount, 0); + } finally { + clock.restore(); + } }); }); From f4cd5f76bb79c8e645882125c8f1e92c2bab2c63 Mon Sep 17 00:00:00 2001 From: Farhad Jay Date: Mon, 23 Mar 2026 14:49:32 -0700 Subject: [PATCH 3/3] Fix lint errors: remove unused prepTimeout and rawMessage --- lib/commands/implementations/duel.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/commands/implementations/duel.js b/lib/commands/implementations/duel.js index 45c89c7..e386545 100644 --- a/lib/commands/implementations/duel.js +++ b/lib/commands/implementations/duel.js @@ -51,9 +51,7 @@ function injectHomoglyph(phrase) { function duel(defaultMuteDuration) { let duelActive = false; let failSafeTimeout = null; - let prepTimeout = null; - - return (input, services, rawMessage) => { + return (input, services) => { if (duelActive) { return new CommandOutput( null, @@ -86,7 +84,7 @@ function duel(defaultMuteDuration) { duelActive = true; - prepTimeout = setTimeout(() => { + setTimeout(() => { const phrase = generatePhrase(); const displayPhrase = injectHomoglyph(phrase);