openvibers/openvibe.live Commits
The Rundown: a topic made only of words for a mood or the play ('Frustration and chaos in the match', 106 takes) is no topic, a stream keeps its best 2 takes on one topic (a better one replaces the weakest), and the announcer as speech-to-text hears it ('I've been slain.') is a game line (#260)
Changes
@@@ -19,9 +19,9 @@- a **topic**, concrete ("Supermarket fridges hacked in France", never "news"), and its kind (news, games, tech, life, music, sports, money, internet, chat, other);- a **headline**, **what** they talked about and **their take**;- the desk's **quip**, a dry late-night-host line about the take or the situation;-- - a 1–5 **score** (a 1 or 2 is filler and isn't kept);++ - a 1–5 **score** for a reader who missed the stream (a 1 or 2 is filler and isn't kept; in-game reactions are a 2 at most). Play-by-play (kills, deaths, teammates, rounds as they happen) is not a segment: a game segment needs an opinion, a story, a plan, a bit or an argument, and the episode recap covers the rest;- up to two **quotes**, copied word for word, with an English translation when they spoke another language.--3. **Topics.** A segment joins the topic with the same slug, else a recent topic whose key words mostly match, else a new topic. A topic with new takes (2 or more in all) gets its page rewritten by `live.rundown.topic`: a headline, plus a summary of who said what and where they agree or clash.++3. **Topics.** A segment joins the topic with the same slug, else a recent topic whose key words mostly match, else a new topic. A topic names a subject ("League autofill", "Boxing fight night"): a kind, a word for streaming or a name made only of words for a mood or the play ("Frustration and chaos in the match", "Talking with chat and teammates") is no topic (`filters.vagueTopic`), and those names are never offered back to the AI. A stream keeps its best 2 takes on one topic, and 2 with no topic: a better one replaces the weakest, the rest go. One night of League once filled a single mood topic with 106 takes of play-by-play. A topic with new takes (2 or more in all) gets its page rewritten by `live.rundown.topic`: a headline, plus a summary of who said what and where they agree or clash.4. **Episodes.** When an ended stream is read to the end, `live.rundown.episode` writes it up from its segments: title, logline, a recap in 2–3 short paragraphs and chapters (VOD timestamps). A stream with no segments gets no episode.5. **Crossovers.** A segment records the streamers it named. Only a whole username or display name said as it is spelled counts (`server/arena/names.js`, `wholeOnly`), never a sound-alike or a nickname. Naming someone is talking, not a fight: there are no beefs, scores or winners.@@@ -35,6 +35,7 @@ const CALLS_PER_TICK = 4;const CALLS_PER_HOUR = 40;const KNOWN_TOPICS = 50;const MIN_SCORE = 3; // a 1-2 is filler ("heading out for coffee", a garbled stretch): not kept++const PER_STREAM_TOPIC = 2; // a stream keeps its best 2 takes on one topic (and 2 with no topic): more is play-by-playconst ANNOUNCE_PER_HOUR = 3;const ANNOUNCE_WITHIN_H = 6;@@@ -235,11 +236,21 @@ async function keepSegments(stream, stretch, segments, user, others = []) {}).filter(Boolean).slice(0, 2);const at = sqlTime(startedMs + (Number(s.start) || 0) * 1000);const topic = await topicFor(clean(s.topic, 120), s.kind, at);++ // One League night came back as a take per stretch under one topic. Past PER_STREAM_TOPIC, a better take replaces++ // the stream's weakest on that topic, and anything else goes.++ const score = Math.min(5, Math.max(1, Number(s.score) || 1));++ const mine = await db.all(`SELECT id, score FROM rundown_segments WHERE stream_id = ? AND ${topic ? 'topic_id = ?' : 'topic_id IS NULL'} ORDER BY score, id`,++ topic ? [stream.id, topic.id] : [stream.id]);++ if (mine.length >= PER_STREAM_TOPIC) {++ if (score <= Number(mine[0].score)) continue;++ await db.run("DELETE FROM rundown_reactions WHERE item_type = 'segment' AND item_id = ?", [mine[0].id]);++ await db.run('DELETE FROM rundown_segments WHERE id = ?', [mine[0].id]);++ }const vod = (span.find((l) => l.vod_id) || stretch.lines.find((l) => l.vod_id) || {}).vod_id || null;await db.run(`INSERT INTO rundown_segments (stream_id, user_id, topic_id, start_sec, end_sec, title, what, take, quip, score, quotes_json, mentions, vod_id, said_at)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,[stream.id, user.id, topic ? topic.id : null, Number(s.start) || 0, Number(s.end) || Number(s.start) || 0, title, what, take, quip,-- Math.min(5, Math.max(1, Number(s.score) || 1)), JSON.stringify(quotes), await mentionsIn(spanText, user.id), vod, at]);++ score, JSON.stringify(quotes), await mentionsIn(spanText, user.id), vod, at]);if (topic) await recountTopic(topic.id);kept++;}@@@ -21,7 +21,8 @@ const GAME_LINES = [/^(?:an? )?(?:enemy|ally) (?:has been slain|double kill|triple kill|quadra kill|penta kill)\.?$/i,/\byou have (?:slain an? enemy|been slain)\b/i,/\b(?:an )?(?:enemy|ally) has been slain\b/i,-- /^(?:first blood|double kill|triple kill|quadra kill|penta ?kill|killing spree|rampage|unstoppable|dominating|godlike|legendary|ace|shut ?down|victory|defeat)[.!]?$/i,++ /^(?:i'?ve|i have|you'?ve) been slain[.!]?$/i,++ /^(?:the )?(?:first blood|double kill|triple kill|quadra kill|penta ?kill|killing spree|rampage|unstoppable|dominating|godlike|legendary|ace|shut ?down|victory|defeat)[.!]?$/i,/\bwelcome to summoner'?s rift\b/i,/\b(?:thirty seconds until minions spawn|minions have spawned)\b/i,/\b(?:your|the enemy|our) team has (?:destroyed|taken|slain) (?:a |an |the )?(?:turret|inhibitor|dragon|baron|herald|nexus)\b/i,@@@ -103,10 +104,17 @@ function privateDetail(text) {const VAGUE = new Set(['news', 'games', 'game', 'tech', 'life', 'music', 'sports', 'money', 'internet', 'chat', 'other', 'gaming', 'gameplay','chatting', 'just chatting', 'stream', 'streaming', 'the stream', 'stream talk', 'misc', 'general', 'random', 'stuff', 'irl', 'talk', 'conversation','banter', 'reactions', 'updates', 'life update', 'nothing']);--/** Is this topic name too vague to be a topic? */++// Words for a mood, the play or the stream, not a subject. A topic made only of them ("Frustration and chaos in the++// match", "Talking with chat and teammates") gathered a night of play-by-play from every game: 106 and 84 takes.++const GENERIC = new Set(['frustration', 'frustrated', 'chaos', 'chaotic', 'rage', 'raging', 'tilt', 'tilted', 'salt', 'salty', 'vent',++ 'venting', 'rant', 'rants', 'ranting', 'reaction', 'reactions', 'reacting', 'hype', 'mood', 'vibes', 'energy', 'moment', 'moments',++ 'highlights', 'talking', 'chatting', 'chat', 'teammates', 'team', 'match', 'matches', 'game', 'games', 'gaming', 'gameplay', 'round',++ 'rounds', 'play', 'plays', 'stream', 'streaming', 'streamer', 'viewers', 'mid', 'calls', 'commands', 'builds', 'criticism', 'banter',++ 'jokes', 'stuff', 'things', 'life', 'random', 'night', 'session', 'fun', 'drama', 'confusion', 'mistakes']);++/** Is this topic name too vague to be a topic? A kind, a word for streaming, or only words for a mood or the play. */function vagueTopic(title) {const t = norm(title);-- return !t || VAGUE.has(t) || t.length < 4;++ return !t || VAGUE.has(t) || t.length < 4 || [...keyWords(title)].every((w) => GENERIC.has(w));}function slugify(title) {@@@ -24,6 +24,7 @@ const read = require('../server/rundown/read');assert.ok(filters.gameLine('You have slain an enemy.'), 'the League announcer');assert.ok(filters.gameLine('Victory!') && filters.gameLine('An enemy has been slain') && filters.gameLine('Welcome to Summoner\'s Rift'));assert.ok(!filters.gameLine('I got first blood on that guy and chat lost it'), 'a sentence about the game is the streamer');++assert.ok(filters.gameLine("I've been slain.") && filters.gameLine('The first blood!'), 'the announcer as speech-to-text hears it');const src = 'This is why having the internet inside of a refrigerator is a bad idea.\nThe hackers got in with the date as the password.';assert.strictEqual(filters.verifiedQuote('“having the internet inside of a refrigerator is a bad idea”', src), 'having the internet inside of a refrigerator is a bad idea');assert.strictEqual(filters.verifiedQuote('internet fridges are a terrible idea', src), null, 'a paraphrase is not a quote');@@@ -39,6 +40,8 @@ assert.ok(filters.sameTopic('League ranked matchmaking', 'Ranked matchmaking inassert.ok(!filters.sameTopic('League ranked matchmaking', 'Moving apartments'));['chat', 'Games', 'life', 'Just chatting', 'the stream', 'x'].forEach((t) => assert.ok(filters.vagueTopic(t), `vague: ${t}`));assert.ok(!filters.vagueTopic('League ranked matchmaking') && !filters.vagueTopic('Halloween'));++['Frustration and chaos in the match', 'Talking with chat and teammates', 'Commands, builds and mid-game criticism', 'Mid-game rage'].forEach((t) => assert.ok(filters.vagueTopic(t), `a mood or the play: ${t}`));++['Briar in League', 'Theme park rides', 'Boxing fight night', 'Minecraft base building', 'League autofill'].forEach((t) => assert.ok(!filters.vagueTopic(t), `a subject: ${t}`));console.log('✅ filters: game lines, verbatim quotes, accusations, topics');// ── People and streams ──@@@ -293,6 +296,33 @@ console.log('✅ API: front page, topic, reactions as a signed-out reader; old /console.log('✅ limits: lines per stretch, refused stretches skipped after three tries');}++// ── Play-by-play: a stream keeps its best 2 takes on a topic (and 2 with no topic) ──++{++ const gamer = await mk('leaguenight');++ const sl = await stream(gamer, { startedAgoMin: 500, endedAgoMin: 420, title: 'Ranked' });++ await speak(sl, gamer, filler(60, 'Autofill again'));++ const prev = answers['live.rundown.segment'];++ const seg = (start, topic, title, score) => ({ start, end: start + 5, topic, kind: 'games', title, what: `leaguenight on ${title}.`, take: null, quip: null, score, quotes: [] });++ answers['live.rundown.segment'] = (input) => input.streamer !== 'leaguenight' ? prev(input) : { others: [], segments: [++ seg(0, 'League autofill', 'Autofill A', 3), seg(10, 'League autofill', 'Autofill B', 4), seg(20, 'League autofill', 'Autofill C', 3), seg(30, 'League autofill', 'Autofill D', 5),++ ] };++ desk._test.reset();++ await desk.tick();++ const titles = async (cond) => (await db.all(`SELECT title FROM rundown_segments WHERE stream_id = ? AND ${cond} ORDER BY title`, [sl])).map((r) => r.title);++ assert.deepStrictEqual(await titles('topic_id IS NOT NULL'), ['Autofill B', 'Autofill D'], 'the best two takes on a topic stay');++ assert.strictEqual(Number((await db.get("SELECT takes FROM rundown_topics WHERE slug = 'league-autofill'")).takes), 2, 'the topic counts what stayed');++ await db.run('DELETE FROM rundown_progress WHERE stream_id = ?', [sl]);++ answers['live.rundown.segment'] = (input) => input.streamer !== 'leaguenight' ? prev(input) : { others: [], segments: [++ seg(40, 'Frustration and chaos in the match', 'Rage A', 3), seg(50, 'Mid-game rage', 'Rage B', 3), seg(60, 'Talking with chat and teammates', 'Rage C', 4),++ ] };++ desk._test.reset();++ await desk.tick();++ assert.deepStrictEqual(await titles('topic_id IS NULL'), ['Rage B', 'Rage C'], 'a mood is no topic, and 2 such takes a stream at most');++ assert.ok(!await db.get("SELECT 1 FROM rundown_topics WHERE slug LIKE 'frustration%' OR slug LIKE 'talking-with%'"));++ answers['live.rundown.segment'] = prev;++ console.log('✅ play-by-play: 2 takes a stream on a topic, moods are no topic');++}++console.log('rundown: ok');process.exit(0);})().catch((err) => { console.error(err); process.exit(1); });
@@ -19,9 +19,9 @@
@@ -19,9 +19,9 @@
- a **topic**, concrete ("Supermarket fridges hacked in France", never "news"), and its kind (news, games, tech, life, music, sports, money, internet, chat, other);
- a **topic**, concrete ("Supermarket fridges hacked in France", never "news"), and its kind (news, games, tech, life, music, sports, money, internet, chat, other);
- a **headline**, **what** they talked about and **their take**;
- a **headline**, **what** they talked about and **their take**;
- the desk's **quip**, a dry late-night-host line about the take or the situation;
- the desk's **quip**, a dry late-night-host line about the take or the situation;
- - a 1–5 **score** (a 1 or 2 is filler and isn't kept);
+ - a 1–5 **score** for a reader who missed the stream (a 1 or 2 is filler and isn't kept; in-game reactions are a 2 at most). Play-by-play (kills, deaths, teammates, rounds as they happen) is not a segment: a game segment needs an opinion, a story, a plan, a bit or an argument, and the episode recap covers the rest;
- up to two **quotes**, copied word for word, with an English translation when they spoke another language.
- up to two **quotes**, copied word for word, with an English translation when they spoke another language.
-3. **Topics.** A segment joins the topic with the same slug, else a recent topic whose key words mostly match, else a new topic. A topic with new takes (2 or more in all) gets its page rewritten by `live.rundown.topic`: a headline, plus a summary of who said what and where they agree or clash.
+3. **Topics.** A segment joins the topic with the same slug, else a recent topic whose key words mostly match, else a new topic. A topic names a subject ("League autofill", "Boxing fight night"): a kind, a word for streaming or a name made only of words for a mood or the play ("Frustration and chaos in the match", "Talking with chat and teammates") is no topic (`filters.vagueTopic`), and those names are never offered back to the AI. A stream keeps its best 2 takes on one topic, and 2 with no topic: a better one replaces the weakest, the rest go. One night of League once filled a single mood topic with 106 takes of play-by-play. A topic with new takes (2 or more in all) gets its page rewritten by `live.rundown.topic`: a headline, plus a summary of who said what and where they agree or clash.
4. **Episodes.** When an ended stream is read to the end, `live.rundown.episode` writes it up from its segments: title, logline, a recap in 2–3 short paragraphs and chapters (VOD timestamps). A stream with no segments gets no episode.
4. **Episodes.** When an ended stream is read to the end, `live.rundown.episode` writes it up from its segments: title, logline, a recap in 2–3 short paragraphs and chapters (VOD timestamps). A stream with no segments gets no episode.
5. **Crossovers.** A segment records the streamers it named. Only a whole username or display name said as it is spelled counts (`server/arena/names.js`, `wholeOnly`), never a sound-alike or a nickname. Naming someone is talking, not a fight: there are no beefs, scores or winners.
5. **Crossovers.** A segment records the streamers it named. Only a whole username or display name said as it is spelled counts (`server/arena/names.js`, `wholeOnly`), never a sound-alike or a nickname. Naming someone is talking, not a fight: there are no beefs, scores or winners.
@@ -35,6 +35,7 @@ const CALLS_PER_TICK = 4;
@@ -35,6 +35,7 @@ const CALLS_PER_TICK = 4;
const CALLS_PER_HOUR = 40;
const CALLS_PER_HOUR = 40;
const KNOWN_TOPICS = 50;
const KNOWN_TOPICS = 50;
const MIN_SCORE = 3; // a 1-2 is filler ("heading out for coffee", a garbled stretch): not kept
const MIN_SCORE = 3; // a 1-2 is filler ("heading out for coffee", a garbled stretch): not kept
+const PER_STREAM_TOPIC = 2; // a stream keeps its best 2 takes on one topic (and 2 with no topic): more is play-by-play
const ANNOUNCE_PER_HOUR = 3;
const ANNOUNCE_PER_HOUR = 3;
const ANNOUNCE_WITHIN_H = 6;
const ANNOUNCE_WITHIN_H = 6;
@@ -235,11 +236,21 @@ async function keepSegments(stream, stretch, segments, user, others = []) {
@@ -235,11 +236,21 @@ async function keepSegments(stream, stretch, segments, user, others = []) {
}).filter(Boolean).slice(0, 2);
}).filter(Boolean).slice(0, 2);
const at = sqlTime(startedMs + (Number(s.start) || 0) * 1000);
const at = sqlTime(startedMs + (Number(s.start) || 0) * 1000);
const topic = await topicFor(clean(s.topic, 120), s.kind, at);
const topic = await topicFor(clean(s.topic, 120), s.kind, at);
+ // One League night came back as a take per stretch under one topic. Past PER_STREAM_TOPIC, a better take replaces
+ // the stream's weakest on that topic, and anything else goes.
+ const score = Math.min(5, Math.max(1, Number(s.score) || 1));
+ const mine = await db.all(`SELECT id, score FROM rundown_segments WHERE stream_id = ? AND ${topic ? 'topic_id = ?' : 'topic_id IS NULL'} ORDER BY score, id`,
+ topic ? [stream.id, topic.id] : [stream.id]);
+ if (mine.length >= PER_STREAM_TOPIC) {
+ if (score <= Number(mine[0].score)) continue;
+ await db.run("DELETE FROM rundown_reactions WHERE item_type = 'segment' AND item_id = ?", [mine[0].id]);
+ await db.run('DELETE FROM rundown_segments WHERE id = ?', [mine[0].id]);
+ }
const vod = (span.find((l) => l.vod_id) || stretch.lines.find((l) => l.vod_id) || {}).vod_id || null;
const vod = (span.find((l) => l.vod_id) || stretch.lines.find((l) => l.vod_id) || {}).vod_id || null;
await db.run(`INSERT INTO rundown_segments (stream_id, user_id, topic_id, start_sec, end_sec, title, what, take, quip, score, quotes_json, mentions, vod_id, said_at)
await db.run(`INSERT INTO rundown_segments (stream_id, user_id, topic_id, start_sec, end_sec, title, what, take, quip, score, quotes_json, mentions, vod_id, said_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[stream.id, user.id, topic ? topic.id : null, Number(s.start) || 0, Number(s.end) || Number(s.start) || 0, title, what, take, quip,
[stream.id, user.id, topic ? topic.id : null, Number(s.start) || 0, Number(s.end) || Number(s.start) || 0, title, what, take, quip,
- Math.min(5, Math.max(1, Number(s.score) || 1)), JSON.stringify(quotes), await mentionsIn(spanText, user.id), vod, at]);
+ score, JSON.stringify(quotes), await mentionsIn(spanText, user.id), vod, at]);
if (topic) await recountTopic(topic.id);
if (topic) await recountTopic(topic.id);
kept++;
kept++;
}
}
@@ -21,7 +21,8 @@ const GAME_LINES = [
@@ -21,7 +21,8 @@ const GAME_LINES = [
/^(?:an? )?(?:enemy|ally) (?:has been slain|double kill|triple kill|quadra kill|penta kill)\.?$/i,
/^(?:an? )?(?:enemy|ally) (?:has been slain|double kill|triple kill|quadra kill|penta kill)\.?$/i,
/\byou have (?:slain an? enemy|been slain)\b/i,
/\byou have (?:slain an? enemy|been slain)\b/i,
/\b(?:an )?(?:enemy|ally) has been slain\b/i,
/\b(?:an )?(?:enemy|ally) has been slain\b/i,
- /^(?:first blood|double kill|triple kill|quadra kill|penta ?kill|killing spree|rampage|unstoppable|dominating|godlike|legendary|ace|shut ?down|victory|defeat)[.!]?$/i,
+ /^(?:i'?ve|i have|you'?ve) been slain[.!]?$/i,
+ /^(?:the )?(?:first blood|double kill|triple kill|quadra kill|penta ?kill|killing spree|rampage|unstoppable|dominating|godlike|legendary|ace|shut ?down|victory|defeat)[.!]?$/i,
/\bwelcome to summoner'?s rift\b/i,
/\bwelcome to summoner'?s rift\b/i,
/\b(?:thirty seconds until minions spawn|minions have spawned)\b/i,
/\b(?:thirty seconds until minions spawn|minions have spawned)\b/i,
/\b(?:your|the enemy|our) team has (?:destroyed|taken|slain) (?:a |an |the )?(?:turret|inhibitor|dragon|baron|herald|nexus)\b/i,
/\b(?:your|the enemy|our) team has (?:destroyed|taken|slain) (?:a |an |the )?(?:turret|inhibitor|dragon|baron|herald|nexus)\b/i,
@@ -103,10 +104,17 @@ function privateDetail(text) {
@@ -103,10 +104,17 @@ function privateDetail(text) {
const VAGUE = new Set(['news', 'games', 'game', 'tech', 'life', 'music', 'sports', 'money', 'internet', 'chat', 'other', 'gaming', 'gameplay',
const VAGUE = new Set(['news', 'games', 'game', 'tech', 'life', 'music', 'sports', 'money', 'internet', 'chat', 'other', 'gaming', 'gameplay',
'chatting', 'just chatting', 'stream', 'streaming', 'the stream', 'stream talk', 'misc', 'general', 'random', 'stuff', 'irl', 'talk', 'conversation',
'chatting', 'just chatting', 'stream', 'streaming', 'the stream', 'stream talk', 'misc', 'general', 'random', 'stuff', 'irl', 'talk', 'conversation',
'banter', 'reactions', 'updates', 'life update', 'nothing']);
'banter', 'reactions', 'updates', 'life update', 'nothing']);
-/** Is this topic name too vague to be a topic? */
+// Words for a mood, the play or the stream, not a subject. A topic made only of them ("Frustration and chaos in the
+// match", "Talking with chat and teammates") gathered a night of play-by-play from every game: 106 and 84 takes.
+const GENERIC = new Set(['frustration', 'frustrated', 'chaos', 'chaotic', 'rage', 'raging', 'tilt', 'tilted', 'salt', 'salty', 'vent',
+ 'venting', 'rant', 'rants', 'ranting', 'reaction', 'reactions', 'reacting', 'hype', 'mood', 'vibes', 'energy', 'moment', 'moments',
+ 'highlights', 'talking', 'chatting', 'chat', 'teammates', 'team', 'match', 'matches', 'game', 'games', 'gaming', 'gameplay', 'round',
+ 'rounds', 'play', 'plays', 'stream', 'streaming', 'streamer', 'viewers', 'mid', 'calls', 'commands', 'builds', 'criticism', 'banter',
+ 'jokes', 'stuff', 'things', 'life', 'random', 'night', 'session', 'fun', 'drama', 'confusion', 'mistakes']);
+/** Is this topic name too vague to be a topic? A kind, a word for streaming, or only words for a mood or the play. */
function vagueTopic(title) {
function vagueTopic(title) {
const t = norm(title);
const t = norm(title);
- return !t || VAGUE.has(t) || t.length < 4;
+ return !t || VAGUE.has(t) || t.length < 4 || [...keyWords(title)].every((w) => GENERIC.has(w));
}
}
function slugify(title) {
function slugify(title) {
@@ -24,6 +24,7 @@ const read = require('../server/rundown/read');
@@ -24,6 +24,7 @@ const read = require('../server/rundown/read');
assert.ok(filters.gameLine('You have slain an enemy.'), 'the League announcer');
assert.ok(filters.gameLine('You have slain an enemy.'), 'the League announcer');
assert.ok(filters.gameLine('Victory!') && filters.gameLine('An enemy has been slain') && filters.gameLine('Welcome to Summoner\'s Rift'));
assert.ok(filters.gameLine('Victory!') && filters.gameLine('An enemy has been slain') && filters.gameLine('Welcome to Summoner\'s Rift'));
assert.ok(!filters.gameLine('I got first blood on that guy and chat lost it'), 'a sentence about the game is the streamer');
assert.ok(!filters.gameLine('I got first blood on that guy and chat lost it'), 'a sentence about the game is the streamer');
+assert.ok(filters.gameLine("I've been slain.") && filters.gameLine('The first blood!'), 'the announcer as speech-to-text hears it');
const src = 'This is why having the internet inside of a refrigerator is a bad idea.\nThe hackers got in with the date as the password.';
const src = 'This is why having the internet inside of a refrigerator is a bad idea.\nThe hackers got in with the date as the password.';
assert.strictEqual(filters.verifiedQuote('“having the internet inside of a refrigerator is a bad idea”', src), 'having the internet inside of a refrigerator is a bad idea');
assert.strictEqual(filters.verifiedQuote('“having the internet inside of a refrigerator is a bad idea”', src), 'having the internet inside of a refrigerator is a bad idea');
assert.strictEqual(filters.verifiedQuote('internet fridges are a terrible idea', src), null, 'a paraphrase is not a quote');
assert.strictEqual(filters.verifiedQuote('internet fridges are a terrible idea', src), null, 'a paraphrase is not a quote');
@@ -39,6 +40,8 @@ assert.ok(filters.sameTopic('League ranked matchmaking', 'Ranked matchmaking in
@@ -39,6 +40,8 @@ assert.ok(filters.sameTopic('League ranked matchmaking', 'Ranked matchmaking in
assert.ok(!filters.sameTopic('League ranked matchmaking', 'Moving apartments'));
assert.ok(!filters.sameTopic('League ranked matchmaking', 'Moving apartments'));
['chat', 'Games', 'life', 'Just chatting', 'the stream', 'x'].forEach((t) => assert.ok(filters.vagueTopic(t), `vague: ${t}`));
['chat', 'Games', 'life', 'Just chatting', 'the stream', 'x'].forEach((t) => assert.ok(filters.vagueTopic(t), `vague: ${t}`));
assert.ok(!filters.vagueTopic('League ranked matchmaking') && !filters.vagueTopic('Halloween'));
assert.ok(!filters.vagueTopic('League ranked matchmaking') && !filters.vagueTopic('Halloween'));
+['Frustration and chaos in the match', 'Talking with chat and teammates', 'Commands, builds and mid-game criticism', 'Mid-game rage'].forEach((t) => assert.ok(filters.vagueTopic(t), `a mood or the play: ${t}`));
+['Briar in League', 'Theme park rides', 'Boxing fight night', 'Minecraft base building', 'League autofill'].forEach((t) => assert.ok(!filters.vagueTopic(t), `a subject: ${t}`));
console.log('✅ filters: game lines, verbatim quotes, accusations, topics');
console.log('✅ filters: game lines, verbatim quotes, accusations, topics');
// ── People and streams ──
// ── People and streams ──
@@ -293,6 +296,33 @@ console.log('✅ API: front page, topic, reactions as a signed-out reader; old /
@@ -293,6 +296,33 @@ console.log('✅ API: front page, topic, reactions as a signed-out reader; old /
console.log('✅ limits: lines per stretch, refused stretches skipped after three tries');
console.log('✅ limits: lines per stretch, refused stretches skipped after three tries');
}
}
+// ── Play-by-play: a stream keeps its best 2 takes on a topic (and 2 with no topic) ──
+{
+ const gamer = await mk('leaguenight');
+ const sl = await stream(gamer, { startedAgoMin: 500, endedAgoMin: 420, title: 'Ranked' });
+ await speak(sl, gamer, filler(60, 'Autofill again'));
+ const prev = answers['live.rundown.segment'];
+ const seg = (start, topic, title, score) => ({ start, end: start + 5, topic, kind: 'games', title, what: `leaguenight on ${title}.`, take: null, quip: null, score, quotes: [] });
+ answers['live.rundown.segment'] = (input) => input.streamer !== 'leaguenight' ? prev(input) : { others: [], segments: [
+ seg(0, 'League autofill', 'Autofill A', 3), seg(10, 'League autofill', 'Autofill B', 4), seg(20, 'League autofill', 'Autofill C', 3), seg(30, 'League autofill', 'Autofill D', 5),
+ ] };
+ desk._test.reset();
+ await desk.tick();
+ const titles = async (cond) => (await db.all(`SELECT title FROM rundown_segments WHERE stream_id = ? AND ${cond} ORDER BY title`, [sl])).map((r) => r.title);
+ assert.deepStrictEqual(await titles('topic_id IS NOT NULL'), ['Autofill B', 'Autofill D'], 'the best two takes on a topic stay');
+ assert.strictEqual(Number((await db.get("SELECT takes FROM rundown_topics WHERE slug = 'league-autofill'")).takes), 2, 'the topic counts what stayed');
+ await db.run('DELETE FROM rundown_progress WHERE stream_id = ?', [sl]);
+ answers['live.rundown.segment'] = (input) => input.streamer !== 'leaguenight' ? prev(input) : { others: [], segments: [
+ seg(40, 'Frustration and chaos in the match', 'Rage A', 3), seg(50, 'Mid-game rage', 'Rage B', 3), seg(60, 'Talking with chat and teammates', 'Rage C', 4),
+ ] };
+ desk._test.reset();
+ await desk.tick();
+ assert.deepStrictEqual(await titles('topic_id IS NULL'), ['Rage B', 'Rage C'], 'a mood is no topic, and 2 such takes a stream at most');
+ assert.ok(!await db.get("SELECT 1 FROM rundown_topics WHERE slug LIKE 'frustration%' OR slug LIKE 'talking-with%'"));
+ answers['live.rundown.segment'] = prev;
+ console.log('✅ play-by-play: 2 takes a stream on a topic, moods are no topic');
+}
+
console.log('rundown: ok');
console.log('rundown: ok');
process.exit(0);
process.exit(0);
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });