@kernel.chat/kbot 3.95.0 → 3.97.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,911 @@
1
+ // kbot Stream Commands — Chat command registry + viewer interaction system
2
+ //
3
+ // Tools: commands_list, commands_stats, commands_inventory, commands_leaderboard
4
+ //
5
+ // Handles all !-prefixed chat commands during livestreams:
6
+ // Fun: !duel, !gift, !trade, !roll, !8ball, !slots, !rps, !fortune, !quote
7
+ // World: !weather, !biome, !music, !theme, !time
8
+ // Game: !challenge, !bet, !boss, !raid, !draw
9
+ // Items: !inventory, !equip, !shop, !buy
10
+ // Social: !hug, !highfive, !wave, !dance, !stats, !leaderboard, !rank
11
+ // Admin: !timeout, !shoutout, !poll, !endpoll, !giveaway, !enter
12
+ //
13
+ // Persists state to ~/.kbot/stream-commands-state.json
14
+ import { registerTool } from './index.js';
15
+ import { homedir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
18
+ const KBOT_DIR = join(homedir(), '.kbot');
19
+ const STATE_FILE = join(KBOT_DIR, 'stream-commands-state.json');
20
+ const LEVEL_ORDER = ['viewer', 'regular', 'vip', 'moderator'];
21
+ // ─── Item Catalog ─────────────────────────────────────────────
22
+ const ITEM_CATALOG = {
23
+ crown: { id: 'crown', name: 'Crown', type: 'hat', description: 'A golden crown', cost: 100 },
24
+ wizard: { id: 'wizard', name: 'Wizard Hat', type: 'hat', description: 'Pointy and magical', cost: 80 },
25
+ pirate: { id: 'pirate', name: 'Pirate Hat', type: 'hat', description: 'Yarr!', cost: 60 },
26
+ bunny: { id: 'bunny', name: 'Bunny Ears', type: 'hat', description: 'Floppy and cute', cost: 50 },
27
+ top_hat: { id: 'top_hat', name: 'Top Hat', type: 'hat', description: 'Classy and distinguished', cost: 90 },
28
+ santa: { id: 'santa', name: 'Santa Hat', type: 'hat', description: 'Ho ho ho!', cost: 70 },
29
+ first_chat: { id: 'first_chat', name: 'First Chat', type: 'badge', description: 'Sent first message', cost: 0 },
30
+ msg_100: { id: 'msg_100', name: '100 Messages', type: 'badge', description: 'Chatted 100 times', cost: 0 },
31
+ raider: { id: 'raider', name: 'Raider', type: 'badge', description: 'Participated in a raid', cost: 0 },
32
+ duelist: { id: 'duelist', name: 'Duelist', type: 'badge', description: 'Won a duel', cost: 0 },
33
+ winner: { id: 'winner', name: 'Winner', type: 'badge', description: 'Won a challenge', cost: 0 },
34
+ paintbrush: { id: 'paintbrush', name: 'Paintbrush', type: 'tool', description: 'Draw on the canvas', cost: 40 },
35
+ wrench: { id: 'wrench', name: 'Wrench', type: 'tool', description: 'Fix things', cost: 35 },
36
+ telescope: { id: 'telescope', name: 'Telescope', type: 'tool', description: 'See farther', cost: 45 },
37
+ cat_pet: { id: 'cat_pet', name: 'Cat', type: 'pet', description: 'A pixel cat companion', cost: 120 },
38
+ dog_pet: { id: 'dog_pet', name: 'Dog', type: 'pet', description: 'A pixel dog companion', cost: 120 },
39
+ bird_pet: { id: 'bird_pet', name: 'Bird', type: 'pet', description: 'A pixel bird companion', cost: 100 },
40
+ };
41
+ // ─── Data Pools ───────────────────────────────────────────────
42
+ const EIGHT_BALL = [
43
+ 'It is certain.', 'Without a doubt.', 'Yes, definitely.', 'Most likely.',
44
+ 'Outlook good.', 'Signs point to yes.', 'Reply hazy, try again.', 'Ask again later.',
45
+ 'Don\'t count on it.', 'My sources say no.', 'Outlook not so good.', 'Very doubtful.',
46
+ ];
47
+ const SLOT_EMOJIS = ['7', '$', '*', '#', '+', '%', '!', '&'];
48
+ const FORTUNES = [
49
+ 'A bug you wrote today will save you tomorrow.', 'Your next commit will be your best yet.',
50
+ 'The linter sees all, forgives nothing.', 'Today is a good day to refactor.',
51
+ 'Push with confidence. The CI will catch you.', 'Beware of off-by-one errors. Or was it two?',
52
+ ];
53
+ const QUOTES = [
54
+ '"Any fool can write code that a computer can understand. Good programmers write code that humans can understand." - Fowler',
55
+ '"First, solve the problem. Then, write the code." - Johnson',
56
+ '"Code is like humor. When you have to explain it, it\'s bad." - House',
57
+ '"Make it work, make it right, make it fast." - Beck',
58
+ '"Talk is cheap. Show me the code." - Torvalds',
59
+ '"Debugging is twice as hard as writing the code in the first place." - Kernighan',
60
+ ];
61
+ const TRIVIA = [
62
+ { q: 'What does HTML stand for?', a: 'hypertext markup language' },
63
+ { q: 'What year was JavaScript created?', a: '1995' },
64
+ { q: 'What does CPU stand for?', a: 'central processing unit' },
65
+ { q: 'What language is the Linux kernel written in?', a: 'c' },
66
+ { q: 'What does API stand for?', a: 'application programming interface' },
67
+ { q: 'Who created TypeScript?', a: 'microsoft' },
68
+ { q: 'What does CSS stand for?', a: 'cascading style sheets' },
69
+ { q: 'What port does HTTPS use?', a: '443' },
70
+ ];
71
+ const MATH_OPS = ['+', '-', '*'];
72
+ // ─── StreamCommands Class ─────────────────────────────────────
73
+ export class StreamCommands {
74
+ commands = new Map();
75
+ viewers = {};
76
+ globalCooldowns = new Map(); // command -> last used timestamp
77
+ userCooldowns = new Map(); // user -> (command -> timestamp)
78
+ currentPoll = null;
79
+ currentGiveaway = null;
80
+ currentBoss = null;
81
+ currentChallenge = null;
82
+ worldVotes = new Map();
83
+ currentFrame = 0;
84
+ pendingDuels = new Map(); // target -> info
85
+ pendingTrades = new Map();
86
+ raidActive = false;
87
+ raidTarget = '';
88
+ raidParticipants = new Set();
89
+ constructor() {
90
+ this.loadState();
91
+ this.registerBuiltinCommands();
92
+ }
93
+ // ─── Public API ───────────────────────────────────────────
94
+ registerCommand(cmd) {
95
+ this.commands.set(cmd.name.toLowerCase(), cmd);
96
+ }
97
+ handleMessage(username, message, platform, isMod = false) {
98
+ const user = username.toLowerCase();
99
+ this.ensureViewer(user);
100
+ this.viewers[user].messageCount++;
101
+ this.viewers[user].xp += 1; // chat msg = 1 XP
102
+ // Badge checks
103
+ if (this.viewers[user].messageCount === 1)
104
+ this.grantItem(user, 'first_chat');
105
+ if (this.viewers[user].messageCount === 100)
106
+ this.grantItem(user, 'msg_100');
107
+ // Timeout check
108
+ if (this.viewers[user].timedOutUntil && Date.now() < this.viewers[user].timedOutUntil) {
109
+ return null;
110
+ }
111
+ if (!message.startsWith('!'))
112
+ return null;
113
+ const parts = message.slice(1).trim().split(/\s+/);
114
+ const cmdName = parts[0]?.toLowerCase();
115
+ if (!cmdName)
116
+ return null;
117
+ const args = parts.slice(1);
118
+ // Handle special: !enter for giveaways
119
+ if (cmdName === 'enter' && this.currentGiveaway) {
120
+ if (!this.currentGiveaway.entrants.includes(user)) {
121
+ this.currentGiveaway.entrants.push(user);
122
+ }
123
+ return { command: 'enter', response: `${username} entered the giveaway!`, username, xpAwarded: 0 };
124
+ }
125
+ // Handle challenge answers
126
+ if (this.currentChallenge && cmdName === 'answer') {
127
+ const answer = args.join(' ').toLowerCase().trim();
128
+ if (answer === this.currentChallenge.answer) {
129
+ this.viewers[user].xp += 15;
130
+ this.grantItem(user, 'winner');
131
+ const resp = `${username} got it right! +15 XP`;
132
+ this.currentChallenge = null;
133
+ return { command: 'answer', response: resp, username, xpAwarded: 15 };
134
+ }
135
+ return { command: 'answer', response: `${username}, that's not right. Keep trying!`, username, xpAwarded: 0 };
136
+ }
137
+ // Handle duel accept
138
+ if (cmdName === 'accept') {
139
+ const duel = this.pendingDuels.get(user);
140
+ if (duel) {
141
+ this.pendingDuels.delete(user);
142
+ const winner = Math.random() < 0.5 ? user : duel.challenger;
143
+ const loser = winner === user ? duel.challenger : user;
144
+ this.viewers[winner].xp += 10;
145
+ this.grantItem(winner, 'duelist');
146
+ return { command: 'accept', response: `Duel! ${winner} defeats ${loser}! +10 XP to the winner!`, username, xpAwarded: winner === user ? 10 : 0 };
147
+ }
148
+ }
149
+ const cmd = this.commands.get(cmdName);
150
+ if (!cmd)
151
+ return null;
152
+ const level = this.getLevel(user, isMod);
153
+ if (LEVEL_ORDER.indexOf(level) < LEVEL_ORDER.indexOf(cmd.minLevel)) {
154
+ return { command: cmdName, response: `${username}, you need ${cmd.minLevel} level for !${cmdName}`, username, xpAwarded: 0 };
155
+ }
156
+ // Global cooldown
157
+ const now = Date.now();
158
+ const lastGlobal = this.globalCooldowns.get(cmdName) ?? 0;
159
+ if (now - lastGlobal < cmd.cooldown) {
160
+ const remaining = Math.ceil((cmd.cooldown - (now - lastGlobal)) / 1000);
161
+ return { command: cmdName, response: `!${cmdName} is on cooldown (${remaining}s)`, username, xpAwarded: 0 };
162
+ }
163
+ // Per-user cooldown (2x the global cooldown)
164
+ if (!this.userCooldowns.has(user))
165
+ this.userCooldowns.set(user, new Map());
166
+ const userCds = this.userCooldowns.get(user);
167
+ const lastUser = userCds.get(cmdName) ?? 0;
168
+ if (now - lastUser < cmd.cooldown * 2) {
169
+ return { command: cmdName, response: `${username}, wait a bit before using !${cmdName} again`, username, xpAwarded: 0 };
170
+ }
171
+ const ctx = { username: user, args, platform, isMod, level };
172
+ const response = cmd.handler(ctx);
173
+ this.globalCooldowns.set(cmdName, now);
174
+ userCds.set(cmdName, now);
175
+ this.viewers[user].xp += 2; // command use = 2 XP
176
+ return { command: cmdName, response, username, xpAwarded: 2 };
177
+ }
178
+ getCommands() {
179
+ return Array.from(this.commands.values()).map(c => ({
180
+ name: c.name, description: c.description, cooldown: c.cooldown, minLevel: c.minLevel,
181
+ }));
182
+ }
183
+ getInventory(username) {
184
+ const v = this.viewers[username.toLowerCase()];
185
+ if (!v)
186
+ return [];
187
+ return v.inventory
188
+ .map(id => ITEM_CATALOG[id])
189
+ .filter((x) => !!x)
190
+ .map(({ cost, ...item }) => item);
191
+ }
192
+ getLeaderboard(limit = 10) {
193
+ return Object.entries(this.viewers)
194
+ .sort(([, a], [, b]) => b.xp - a.xp)
195
+ .slice(0, limit)
196
+ .map(([name, data], i) => ({
197
+ username: name, xp: data.xp, rank: i + 1, messageCount: data.messageCount,
198
+ }));
199
+ }
200
+ getViewerStats(username) {
201
+ const user = username.toLowerCase();
202
+ this.ensureViewer(user);
203
+ const v = this.viewers[user];
204
+ const sorted = Object.entries(this.viewers).sort(([, a], [, b]) => b.xp - a.xp);
205
+ const rank = sorted.findIndex(([n]) => n === user) + 1;
206
+ return {
207
+ username: user, xp: v.xp, messageCount: v.messageCount, rank,
208
+ level: this.getLevel(user, false),
209
+ inventory: this.getInventory(user),
210
+ equipped: { ...v.equipped },
211
+ joinedAt: v.joinedAt,
212
+ };
213
+ }
214
+ tick(frame) {
215
+ this.currentFrame = frame;
216
+ for (const [t, i] of this.pendingDuels) {
217
+ if (frame - i.frame > 360)
218
+ this.pendingDuels.delete(t);
219
+ }
220
+ for (const [t, i] of this.pendingTrades) {
221
+ if (frame - i.frame > 360)
222
+ this.pendingTrades.delete(t);
223
+ }
224
+ for (const [t, p] of this.worldVotes) {
225
+ if (frame - p.startFrame > 720)
226
+ this.worldVotes.delete(t);
227
+ }
228
+ if (this.currentChallenge && frame - this.currentChallenge.startFrame > 270)
229
+ this.currentChallenge = null;
230
+ if (this.currentBoss && frame - this.currentBoss.startFrame > 1080)
231
+ this.currentBoss = null;
232
+ if (frame % 1800 === 0)
233
+ this.saveState();
234
+ }
235
+ render(ctx, width, height) {
236
+ const px = width - 280;
237
+ let y = 10;
238
+ if (this.currentPoll) {
239
+ const p = this.currentPoll, totals = this.tallyVotes(p);
240
+ const tv = Object.values(totals).reduce((a, b) => a + b, 0) || 1;
241
+ ctx.fillStyle = 'rgba(0,0,0,0.7)';
242
+ ctx.fillRect(px, y, 270, 20 + p.options.length * 18);
243
+ ctx.fillStyle = '#58a6ff';
244
+ ctx.font = 'bold 13px monospace';
245
+ ctx.fillText(`POLL: ${p.question}`, px + 8, y + 15);
246
+ p.options.forEach((opt, i) => {
247
+ const c = totals[opt] ?? 0, pct = Math.round((c / tv) * 100);
248
+ ctx.fillStyle = '#1a3a1a';
249
+ ctx.fillRect(px + 8, y + 22 + i * 18, 200, 12);
250
+ ctx.fillStyle = '#3fb950';
251
+ ctx.fillRect(px + 8, y + 22 + i * 18, Math.round((c / tv) * 200), 12);
252
+ ctx.fillStyle = '#c9d1d9';
253
+ ctx.font = '11px monospace';
254
+ ctx.fillText(`${opt}: ${c} (${pct}%)`, px + 8, y + 32 + i * 18);
255
+ });
256
+ y += 26 + p.options.length * 18;
257
+ }
258
+ if (this.currentGiveaway) {
259
+ ctx.fillStyle = 'rgba(0,0,0,0.7)';
260
+ ctx.fillRect(px, y, 270, 40);
261
+ ctx.fillStyle = '#f0c040';
262
+ ctx.font = 'bold 13px monospace';
263
+ ctx.fillText(`GIVEAWAY: ${this.currentGiveaway.prize}`, px + 8, y + 15);
264
+ ctx.fillStyle = '#c9d1d9';
265
+ ctx.font = '11px monospace';
266
+ ctx.fillText(`Type !enter — ${this.currentGiveaway.entrants.length} entrants`, px + 8, y + 32);
267
+ y += 46;
268
+ }
269
+ if (this.currentBoss) {
270
+ const hp = this.currentBoss.hp / this.currentBoss.maxHp;
271
+ ctx.fillStyle = 'rgba(0,0,0,0.7)';
272
+ ctx.fillRect(px, y, 270, 38);
273
+ ctx.fillStyle = '#f85149';
274
+ ctx.font = 'bold 13px monospace';
275
+ ctx.fillText(`BOSS: ${this.currentBoss.hp}/${this.currentBoss.maxHp} HP`, px + 8, y + 15);
276
+ ctx.fillStyle = '#2a1a1a';
277
+ ctx.fillRect(px + 8, y + 22, 254, 10);
278
+ ctx.fillStyle = hp > 0.5 ? '#f85149' : hp > 0.2 ? '#f0c040' : '#3fb950';
279
+ ctx.fillRect(px + 8, y + 22, Math.round(254 * hp), 10);
280
+ y += 44;
281
+ }
282
+ if (this.currentChallenge) {
283
+ ctx.fillStyle = 'rgba(0,0,0,0.7)';
284
+ ctx.fillRect(px, y, 270, 36);
285
+ ctx.fillStyle = '#bc8cff';
286
+ ctx.font = 'bold 13px monospace';
287
+ ctx.fillText(`CHALLENGE: ${this.currentChallenge.type.toUpperCase()}`, px + 8, y + 15);
288
+ ctx.fillStyle = '#c9d1d9';
289
+ ctx.font = '11px monospace';
290
+ ctx.fillText(this.currentChallenge.question, px + 8, y + 30);
291
+ }
292
+ }
293
+ saveState() {
294
+ if (!existsSync(KBOT_DIR))
295
+ mkdirSync(KBOT_DIR, { recursive: true });
296
+ const state = { viewers: this.viewers };
297
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
298
+ }
299
+ loadState() {
300
+ if (!existsSync(STATE_FILE))
301
+ return;
302
+ try {
303
+ const raw = readFileSync(STATE_FILE, 'utf-8');
304
+ const state = JSON.parse(raw);
305
+ this.viewers = state.viewers ?? {};
306
+ }
307
+ catch { /* corrupt file, start fresh */ }
308
+ }
309
+ // ─── Internals ────────────────────────────────────────────
310
+ ensureViewer(user) {
311
+ if (!this.viewers[user]) {
312
+ this.viewers[user] = {
313
+ xp: 0, messageCount: 0, inventory: [], equipped: {},
314
+ joinedAt: new Date().toISOString(), lastFortune: '',
315
+ };
316
+ }
317
+ }
318
+ getLevel(user, isMod) {
319
+ if (isMod)
320
+ return 'moderator';
321
+ const v = this.viewers[user];
322
+ if (!v)
323
+ return 'viewer';
324
+ if (v.xp >= 500)
325
+ return 'vip';
326
+ if (v.messageCount >= 10)
327
+ return 'regular';
328
+ return 'viewer';
329
+ }
330
+ grantItem(user, itemId) {
331
+ this.ensureViewer(user);
332
+ if (!this.viewers[user].inventory.includes(itemId)) {
333
+ this.viewers[user].inventory.push(itemId);
334
+ }
335
+ }
336
+ tallyVotes(poll) {
337
+ const counts = {};
338
+ for (const opt of poll.options)
339
+ counts[opt] = 0;
340
+ for (const vote of Object.values(poll.votes)) {
341
+ if (counts[vote] !== undefined)
342
+ counts[vote]++;
343
+ }
344
+ return counts;
345
+ }
346
+ pick(arr) {
347
+ return arr[Math.floor(Math.random() * arr.length)];
348
+ }
349
+ tallyWorldVote(topic) {
350
+ const pool = this.worldVotes.get(topic);
351
+ if (!pool || Object.keys(pool.votes).length === 0)
352
+ return null;
353
+ const counts = {};
354
+ for (const v of Object.values(pool.votes))
355
+ counts[v] = (counts[v] ?? 0) + 1;
356
+ let best = '', bestCount = 0;
357
+ for (const [k, c] of Object.entries(counts)) {
358
+ if (c > bestCount) {
359
+ best = k;
360
+ bestCount = c;
361
+ }
362
+ }
363
+ return best;
364
+ }
365
+ castWorldVote(topic, user, choice) {
366
+ if (!this.worldVotes.has(topic)) {
367
+ this.worldVotes.set(topic, { topic, votes: {}, startFrame: this.currentFrame });
368
+ }
369
+ const pool = this.worldVotes.get(topic);
370
+ pool.votes[user] = choice.toLowerCase();
371
+ const total = Object.keys(pool.votes).length;
372
+ const leader = this.tallyWorldVote(topic);
373
+ return `${user} voted "${choice}" for ${topic}! (${total} votes, leading: ${leader})`;
374
+ }
375
+ // ─── Register All Built-in Commands ────────────────────────
376
+ registerBuiltinCommands() {
377
+ // ── Fun Commands ──────────────────────────────────────────
378
+ this.registerCommand({
379
+ name: 'duel', description: 'Challenge someone to a duel (coin flip)', cooldown: 10000, minLevel: 'regular',
380
+ handler: (ctx) => {
381
+ const target = ctx.args[0]?.replace('@', '').toLowerCase();
382
+ if (!target || target === ctx.username)
383
+ return `${ctx.username}, you need to challenge someone else!`;
384
+ this.pendingDuels.set(target, { challenger: ctx.username, frame: this.currentFrame });
385
+ return `${ctx.username} challenges ${target} to a duel! ${target}, type !accept within 60s!`;
386
+ },
387
+ });
388
+ this.registerCommand({
389
+ name: 'gift', description: 'Give an item to another viewer', cooldown: 5000, minLevel: 'regular',
390
+ handler: (ctx) => {
391
+ const target = ctx.args[0]?.replace('@', '').toLowerCase();
392
+ const itemId = ctx.args[1]?.toLowerCase();
393
+ if (!target || !itemId)
394
+ return 'Usage: !gift @user item_name';
395
+ const v = this.viewers[ctx.username];
396
+ if (!v || !v.inventory.includes(itemId))
397
+ return `${ctx.username}, you don't have ${itemId}!`;
398
+ v.inventory = v.inventory.filter(i => i !== itemId);
399
+ this.ensureViewer(target);
400
+ this.grantItem(target, itemId);
401
+ return `${ctx.username} gifted ${itemId} to ${target}!`;
402
+ },
403
+ });
404
+ this.registerCommand({
405
+ name: 'trade', description: 'Propose a trade with another viewer', cooldown: 10000, minLevel: 'regular',
406
+ handler: (ctx) => {
407
+ const target = ctx.args[0]?.replace('@', '').toLowerCase();
408
+ if (!target)
409
+ return 'Usage: !trade @user';
410
+ this.pendingTrades.set(target, { from: ctx.username, frame: this.currentFrame });
411
+ return `${ctx.username} wants to trade with ${target}! (feature coming soon)`;
412
+ },
413
+ });
414
+ this.registerCommand({
415
+ name: 'roll', description: 'Roll dice (default 1d20)', cooldown: 3000, minLevel: 'viewer',
416
+ handler: (ctx) => {
417
+ const spec = ctx.args[0] || '1d20';
418
+ const match = spec.match(/^(\d+)d(\d+)$/i);
419
+ if (!match)
420
+ return `${ctx.username}, use format NdN (e.g., 2d6)`;
421
+ const count = Math.min(parseInt(match[1], 10), 20);
422
+ const sides = Math.min(parseInt(match[2], 10), 100);
423
+ if (count < 1 || sides < 2)
424
+ return `${ctx.username}, invalid dice!`;
425
+ const rolls = Array.from({ length: count }, () => Math.floor(Math.random() * sides) + 1);
426
+ const total = rolls.reduce((a, b) => a + b, 0);
427
+ return `${ctx.username} rolled ${spec}: [${rolls.join(', ')}] = ${total}`;
428
+ },
429
+ });
430
+ this.registerCommand({
431
+ name: '8ball', description: 'Ask the magic 8-ball', cooldown: 5000, minLevel: 'viewer',
432
+ handler: (ctx) => {
433
+ if (ctx.args.length === 0)
434
+ return `${ctx.username}, ask a question! Usage: !8ball [question]`;
435
+ return `${ctx.username}: ${this.pick(EIGHT_BALL)}`;
436
+ },
437
+ });
438
+ this.registerCommand({
439
+ name: 'slots', description: 'Spin the slot machine', cooldown: 8000, minLevel: 'viewer',
440
+ handler: (ctx) => {
441
+ const a = this.pick(SLOT_EMOJIS);
442
+ const b = this.pick(SLOT_EMOJIS);
443
+ const c = this.pick(SLOT_EMOJIS);
444
+ if (a === b && b === c) {
445
+ this.viewers[ctx.username].xp += 50;
446
+ return `[ ${a} | ${b} | ${c} ] JACKPOT! ${ctx.username} wins 50 XP!`;
447
+ }
448
+ if (a === b || b === c || a === c) {
449
+ this.viewers[ctx.username].xp += 10;
450
+ return `[ ${a} | ${b} | ${c} ] Two match! ${ctx.username} wins 10 XP!`;
451
+ }
452
+ return `[ ${a} | ${b} | ${c} ] No match. Better luck next time, ${ctx.username}!`;
453
+ },
454
+ });
455
+ this.registerCommand({
456
+ name: 'rps', description: 'Play rock-paper-scissors against kbot', cooldown: 5000, minLevel: 'viewer',
457
+ handler: (ctx) => {
458
+ const choices = ['rock', 'paper', 'scissors'];
459
+ const player = ctx.args[0]?.toLowerCase();
460
+ if (!player || !choices.includes(player))
461
+ return 'Usage: !rps rock|paper|scissors';
462
+ const bot = this.pick([...choices]);
463
+ if (player === bot)
464
+ return `${ctx.username}: ${player} vs ${bot} — It's a tie!`;
465
+ const wins = { rock: 'scissors', paper: 'rock', scissors: 'paper' };
466
+ if (wins[player] === bot) {
467
+ this.viewers[ctx.username].xp += 5;
468
+ return `${ctx.username}: ${player} vs ${bot} — You win! +5 XP`;
469
+ }
470
+ return `${ctx.username}: ${player} vs ${bot} — kbot wins!`;
471
+ },
472
+ });
473
+ this.registerCommand({
474
+ name: 'fortune', description: 'Get your daily fortune cookie', cooldown: 10000, minLevel: 'viewer',
475
+ handler: (ctx) => {
476
+ const today = new Date().toISOString().slice(0, 10);
477
+ if (this.viewers[ctx.username]?.lastFortune === today) {
478
+ return `${ctx.username}, you already got your fortune today! Come back tomorrow.`;
479
+ }
480
+ this.viewers[ctx.username].lastFortune = today;
481
+ return `${ctx.username}'s fortune: ${this.pick(FORTUNES)}`;
482
+ },
483
+ });
484
+ this.registerCommand({
485
+ name: 'quote', description: 'Get a random programming quote', cooldown: 8000, minLevel: 'viewer',
486
+ handler: () => this.pick(QUOTES),
487
+ });
488
+ // ── World Commands ────────────────────────────────────────
489
+ this.registerCommand({
490
+ name: 'weather', description: 'Vote for weather change', cooldown: 5000, minLevel: 'viewer',
491
+ handler: (ctx) => {
492
+ const types = ['rain', 'snow', 'clear', 'storm', 'fog', 'wind'];
493
+ const choice = ctx.args[0]?.toLowerCase();
494
+ if (!choice || !types.includes(choice))
495
+ return `Usage: !weather ${types.join('|')}`;
496
+ return this.castWorldVote('weather', ctx.username, choice);
497
+ },
498
+ });
499
+ this.registerCommand({
500
+ name: 'biome', description: 'Vote for biome change', cooldown: 5000, minLevel: 'viewer',
501
+ handler: (ctx) => {
502
+ const biomes = ['forest', 'desert', 'ocean', 'mountain', 'cave', 'space', 'city'];
503
+ const choice = ctx.args[0]?.toLowerCase();
504
+ if (!choice || !biomes.includes(choice))
505
+ return `Usage: !biome ${biomes.join('|')}`;
506
+ return this.castWorldVote('biome', ctx.username, choice);
507
+ },
508
+ });
509
+ this.registerCommand({
510
+ name: 'music', description: 'Vote for music mood', cooldown: 5000, minLevel: 'viewer',
511
+ handler: (ctx) => {
512
+ const moods = ['chill', 'hype', 'dark', 'epic', 'jazz', 'lofi', 'synthwave'];
513
+ const choice = ctx.args[0]?.toLowerCase();
514
+ if (!choice || !moods.includes(choice))
515
+ return `Usage: !music ${moods.join('|')}`;
516
+ return this.castWorldVote('music', ctx.username, choice);
517
+ },
518
+ });
519
+ this.registerCommand({
520
+ name: 'theme', description: 'Vote for visual theme', cooldown: 5000, minLevel: 'viewer',
521
+ handler: (ctx) => {
522
+ const themes = ['dark', 'retro', 'neon', 'forest'];
523
+ const choice = ctx.args[0]?.toLowerCase();
524
+ if (!choice || !themes.includes(choice))
525
+ return `Usage: !theme ${themes.join('|')}`;
526
+ return this.castWorldVote('theme', ctx.username, choice);
527
+ },
528
+ });
529
+ this.registerCommand({
530
+ name: 'time', description: 'Vote for time of day', cooldown: 5000, minLevel: 'viewer',
531
+ handler: (ctx) => {
532
+ const times = ['day', 'night', 'dawn', 'dusk'];
533
+ const choice = ctx.args[0]?.toLowerCase();
534
+ if (!choice || !times.includes(choice))
535
+ return `Usage: !time ${times.join('|')}`;
536
+ return this.castWorldVote('time', ctx.username, choice);
537
+ },
538
+ });
539
+ // ── Game Commands ─────────────────────────────────────────
540
+ this.registerCommand({
541
+ name: 'challenge', description: 'Start a chat challenge', cooldown: 30000, minLevel: 'regular',
542
+ handler: (ctx) => {
543
+ if (this.currentChallenge)
544
+ return 'A challenge is already active! Type !answer [your answer]';
545
+ const type = (ctx.args[0]?.toLowerCase() || 'trivia');
546
+ if (type === 'trivia') {
547
+ const q = this.pick(TRIVIA);
548
+ this.currentChallenge = { type, question: q.q, answer: q.a, startFrame: this.currentFrame, bets: {} };
549
+ return `TRIVIA: ${q.q} — Type !answer [your answer]`;
550
+ }
551
+ if (type === 'math') {
552
+ const a = Math.floor(Math.random() * 50) + 1;
553
+ const b = Math.floor(Math.random() * 50) + 1;
554
+ const op = this.pick([...MATH_OPS]);
555
+ const result = op === '+' ? a + b : op === '-' ? a - b : a * b;
556
+ this.currentChallenge = { type, question: `${a} ${op} ${b} = ?`, answer: String(result), startFrame: this.currentFrame, bets: {} };
557
+ return `MATH: What is ${a} ${op} ${b}? Type !answer [number]`;
558
+ }
559
+ // typing — generate a random word sequence
560
+ const words = ['kbot', 'stream', 'code', 'hack', 'build', 'ship', 'test', 'debug'];
561
+ const phrase = Array.from({ length: 4 }, () => this.pick(words)).join(' ');
562
+ this.currentChallenge = { type: 'typing', question: `Type: "${phrase}"`, answer: phrase, startFrame: this.currentFrame, bets: {} };
563
+ return `TYPING RACE: Type exactly: "${phrase}" — Use !answer [phrase]`;
564
+ },
565
+ });
566
+ this.registerCommand({
567
+ name: 'bet', description: 'Bet XP on an outcome', cooldown: 5000, minLevel: 'regular',
568
+ handler: (ctx) => {
569
+ const amount = parseInt(ctx.args[0], 10);
570
+ const choice = ctx.args.slice(1).join(' ').toLowerCase();
571
+ if (!amount || amount < 1 || !choice)
572
+ return 'Usage: !bet [amount] [choice]';
573
+ if (this.viewers[ctx.username].xp < amount)
574
+ return `${ctx.username}, you only have ${this.viewers[ctx.username].xp} XP!`;
575
+ if (!this.currentChallenge && !this.currentBoss)
576
+ return 'Nothing to bet on right now!';
577
+ this.viewers[ctx.username].xp -= amount;
578
+ if (this.currentChallenge) {
579
+ this.currentChallenge.bets[ctx.username] = { amount, choice };
580
+ }
581
+ return `${ctx.username} bet ${amount} XP on "${choice}"`;
582
+ },
583
+ });
584
+ this.registerCommand({
585
+ name: 'boss', description: 'Summon a collaborative boss fight', cooldown: 60000, minLevel: 'regular',
586
+ handler: (ctx) => {
587
+ if (this.currentBoss) {
588
+ // Attack the boss
589
+ const dmg = Math.floor(Math.random() * 20) + 5;
590
+ this.currentBoss.hp = Math.max(0, this.currentBoss.hp - dmg);
591
+ this.currentBoss.contributors[ctx.username] = (this.currentBoss.contributors[ctx.username] ?? 0) + dmg;
592
+ if (this.currentBoss.hp <= 0) {
593
+ const rewards = [];
594
+ for (const [user, contrib] of Object.entries(this.currentBoss.contributors)) {
595
+ this.ensureViewer(user);
596
+ const bonus = Math.min(Math.floor(contrib / 5) * 5, 50);
597
+ this.viewers[user].xp += bonus;
598
+ rewards.push(`${user}: +${bonus} XP`);
599
+ }
600
+ this.currentBoss = null;
601
+ return `BOSS DEFEATED! Rewards: ${rewards.join(', ')}`;
602
+ }
603
+ return `${ctx.username} hit the boss for ${dmg} damage! HP: ${this.currentBoss.hp}/${this.currentBoss.maxHp}`;
604
+ }
605
+ const hp = 200 + Math.floor(Math.random() * 300);
606
+ this.currentBoss = { hp, maxHp: hp, contributors: {}, startFrame: this.currentFrame };
607
+ return `A wild BOSS appeared with ${hp} HP! Type !boss to attack!`;
608
+ },
609
+ });
610
+ this.registerCommand({
611
+ name: 'raid', description: 'Start a viewer raid event', cooldown: 120000, minLevel: 'regular',
612
+ handler: (ctx) => {
613
+ const target = ctx.args[0];
614
+ if (!target)
615
+ return 'Usage: !raid [target]';
616
+ if (this.raidActive) {
617
+ this.raidParticipants.add(ctx.username);
618
+ return `${ctx.username} joined the raid! (${this.raidParticipants.size} raiders)`;
619
+ }
620
+ this.raidActive = true;
621
+ this.raidTarget = target;
622
+ this.raidParticipants = new Set([ctx.username]);
623
+ this.grantItem(ctx.username, 'raider');
624
+ // Auto-end raid after 60s
625
+ setTimeout(() => {
626
+ for (const user of this.raidParticipants) {
627
+ this.ensureViewer(user);
628
+ this.viewers[user].xp += 5;
629
+ this.grantItem(user, 'raider');
630
+ }
631
+ this.raidActive = false;
632
+ this.raidParticipants.clear();
633
+ }, 60_000);
634
+ return `RAID on ${target}! Type !raid ${target} to join! (60s)`;
635
+ },
636
+ });
637
+ this.registerCommand({
638
+ name: 'draw', description: 'Draw on the stream canvas', cooldown: 3000, minLevel: 'viewer',
639
+ handler: (ctx) => {
640
+ const text = ctx.args.join(' ');
641
+ if (!text)
642
+ return 'Usage: !draw [pixel art description]';
643
+ if (!this.viewers[ctx.username]?.inventory.includes('paintbrush')) {
644
+ return `${ctx.username}, you need a paintbrush! Buy one at the !shop`;
645
+ }
646
+ return `${ctx.username} draws: ${text.slice(0, 30)} (displayed on stream)`;
647
+ },
648
+ });
649
+ // ── Inventory Commands ────────────────────────────────────
650
+ this.registerCommand({
651
+ name: 'inventory', description: 'Show your items', cooldown: 5000, minLevel: 'viewer',
652
+ handler: (ctx) => {
653
+ const items = this.getInventory(ctx.username);
654
+ if (items.length === 0)
655
+ return `${ctx.username}, your inventory is empty! Try the !shop`;
656
+ return `${ctx.username}'s items: ${items.map(i => `${i.name}(${i.type})`).join(', ')}`;
657
+ },
658
+ });
659
+ this.registerCommand({
660
+ name: 'equip', description: 'Equip a hat or pet', cooldown: 3000, minLevel: 'viewer',
661
+ handler: (ctx) => {
662
+ const itemId = ctx.args[0]?.toLowerCase();
663
+ if (!itemId)
664
+ return 'Usage: !equip [item_name]';
665
+ const v = this.viewers[ctx.username];
666
+ if (!v?.inventory.includes(itemId))
667
+ return `${ctx.username}, you don't have ${itemId}!`;
668
+ const item = ITEM_CATALOG[itemId];
669
+ if (!item)
670
+ return `Unknown item: ${itemId}`;
671
+ if (item.type === 'hat') {
672
+ v.equipped.hat = itemId;
673
+ return `${ctx.username} equipped ${item.name}!`;
674
+ }
675
+ if (item.type === 'pet') {
676
+ v.equipped.pet = itemId;
677
+ return `${ctx.username}'s new companion: ${item.name}!`;
678
+ }
679
+ return `${ctx.username}, you can only equip hats and pets!`;
680
+ },
681
+ });
682
+ this.registerCommand({
683
+ name: 'shop', description: 'Browse items for sale', cooldown: 5000, minLevel: 'viewer',
684
+ handler: (ctx) => {
685
+ const buyable = Object.values(ITEM_CATALOG).filter(i => i.cost > 0);
686
+ const lines = buyable.map(i => `${i.id}(${i.cost}XP)`);
687
+ return `Shop: ${lines.join(', ')} — Use !buy [item]`;
688
+ },
689
+ });
690
+ this.registerCommand({
691
+ name: 'buy', description: 'Purchase an item with XP', cooldown: 3000, minLevel: 'viewer',
692
+ handler: (ctx) => {
693
+ const itemId = ctx.args[0]?.toLowerCase();
694
+ if (!itemId)
695
+ return 'Usage: !buy [item_name]';
696
+ const item = ITEM_CATALOG[itemId];
697
+ if (!item || item.cost <= 0)
698
+ return `"${itemId}" is not for sale!`;
699
+ const v = this.viewers[ctx.username];
700
+ if (v.inventory.includes(itemId))
701
+ return `${ctx.username}, you already have ${item.name}!`;
702
+ if (v.xp < item.cost)
703
+ return `${ctx.username}, you need ${item.cost} XP but have ${v.xp}!`;
704
+ v.xp -= item.cost;
705
+ this.grantItem(ctx.username, itemId);
706
+ return `${ctx.username} bought ${item.name} for ${item.cost} XP! (${v.xp} XP remaining)`;
707
+ },
708
+ });
709
+ // ── Social Commands ───────────────────────────────────────
710
+ this.registerCommand({
711
+ name: 'hug', description: 'Hug another viewer', cooldown: 5000, minLevel: 'viewer',
712
+ handler: (ctx) => {
713
+ const target = ctx.args[0]?.replace('@', '') || 'chat';
714
+ return `${ctx.username} hugs ${target}!`;
715
+ },
716
+ });
717
+ this.registerCommand({
718
+ name: 'highfive', description: 'High five another viewer', cooldown: 5000, minLevel: 'viewer',
719
+ handler: (ctx) => {
720
+ const target = ctx.args[0]?.replace('@', '') || 'kbot';
721
+ return `${ctx.username} high-fives ${target}!`;
722
+ },
723
+ });
724
+ this.registerCommand({
725
+ name: 'wave', description: 'Wave at chat', cooldown: 3000, minLevel: 'viewer',
726
+ handler: (ctx) => `${ctx.username} waves at everyone!`,
727
+ });
728
+ this.registerCommand({
729
+ name: 'dance', description: 'Make kbot dance', cooldown: 10000, minLevel: 'viewer',
730
+ handler: (ctx) => `${ctx.username} made kbot dance!`,
731
+ });
732
+ this.registerCommand({
733
+ name: 'stats', description: 'Show your viewer stats', cooldown: 5000, minLevel: 'viewer',
734
+ handler: (ctx) => {
735
+ const s = this.getViewerStats(ctx.username);
736
+ return `${s.username} — XP: ${s.xp} | Msgs: ${s.messageCount} | Rank: #${s.rank} | Level: ${s.level} | Items: ${s.inventory.length}`;
737
+ },
738
+ });
739
+ this.registerCommand({
740
+ name: 'leaderboard', description: 'Top 10 XP earners', cooldown: 10000, minLevel: 'viewer',
741
+ handler: () => {
742
+ const lb = this.getLeaderboard(10);
743
+ if (lb.length === 0)
744
+ return 'Leaderboard is empty!';
745
+ return lb.map(e => `#${e.rank} ${e.username}: ${e.xp} XP`).join(' | ');
746
+ },
747
+ });
748
+ this.registerCommand({
749
+ name: 'rank', description: 'Show your current rank', cooldown: 5000, minLevel: 'viewer',
750
+ handler: (ctx) => {
751
+ const s = this.getViewerStats(ctx.username);
752
+ return `${ctx.username} is rank #${s.rank} with ${s.xp} XP (${s.level})`;
753
+ },
754
+ });
755
+ // ── Admin Commands (moderator only) ───────────────────────
756
+ this.registerCommand({
757
+ name: 'timeout', description: 'Timeout a user', cooldown: 1000, minLevel: 'moderator',
758
+ handler: (ctx) => {
759
+ const target = ctx.args[0]?.replace('@', '').toLowerCase();
760
+ const seconds = parseInt(ctx.args[1], 10) || 60;
761
+ if (!target)
762
+ return 'Usage: !timeout @user [seconds]';
763
+ this.ensureViewer(target);
764
+ this.viewers[target].timedOutUntil = Date.now() + seconds * 1000;
765
+ return `${target} timed out for ${seconds}s by ${ctx.username}`;
766
+ },
767
+ });
768
+ this.registerCommand({
769
+ name: 'shoutout', description: 'Shoutout overlay for a user', cooldown: 10000, minLevel: 'moderator',
770
+ handler: (ctx) => {
771
+ const target = ctx.args[0]?.replace('@', '');
772
+ if (!target)
773
+ return 'Usage: !shoutout @user';
774
+ return `SHOUTOUT to ${target}! Go check them out!`;
775
+ },
776
+ });
777
+ this.registerCommand({
778
+ name: 'poll', description: 'Start a poll', cooldown: 30000, minLevel: 'moderator',
779
+ handler: (ctx) => {
780
+ if (this.currentPoll)
781
+ return 'A poll is already active! Use !endpoll first.';
782
+ if (ctx.args.length < 3)
783
+ return 'Usage: !poll [question] [option1] [option2] ...';
784
+ const question = ctx.args[0];
785
+ const options = ctx.args.slice(1);
786
+ this.currentPoll = { question, options, votes: {}, startFrame: this.currentFrame };
787
+ return `POLL: ${question} — Vote: ${options.map((o, i) => `!vote ${o}`).join(' or ')}`;
788
+ },
789
+ });
790
+ this.registerCommand({
791
+ name: 'endpoll', description: 'End the current poll', cooldown: 1000, minLevel: 'moderator',
792
+ handler: () => {
793
+ if (!this.currentPoll)
794
+ return 'No active poll!';
795
+ const totals = this.tallyVotes(this.currentPoll);
796
+ const results = Object.entries(totals).sort(([, a], [, b]) => b - a);
797
+ const winner = results[0];
798
+ const summary = results.map(([opt, count]) => `${opt}: ${count}`).join(', ');
799
+ this.currentPoll = null;
800
+ return `POLL ENDED! Winner: ${winner?.[0]} (${winner?.[1]} votes) — ${summary}`;
801
+ },
802
+ });
803
+ this.registerCommand({
804
+ name: 'vote', description: 'Vote in the current poll', cooldown: 1000, minLevel: 'viewer',
805
+ handler: (ctx) => {
806
+ if (!this.currentPoll)
807
+ return 'No active poll!';
808
+ const choice = ctx.args[0]?.toLowerCase();
809
+ if (!choice)
810
+ return 'Usage: !vote [option]';
811
+ const match = this.currentPoll.options.find(o => o.toLowerCase() === choice);
812
+ if (!match)
813
+ return `Invalid option. Choices: ${this.currentPoll.options.join(', ')}`;
814
+ this.currentPoll.votes[ctx.username] = match;
815
+ return `${ctx.username} voted for ${match}!`;
816
+ },
817
+ });
818
+ this.registerCommand({
819
+ name: 'giveaway', description: 'Start a giveaway', cooldown: 60000, minLevel: 'moderator',
820
+ handler: (ctx) => {
821
+ if (this.currentGiveaway) {
822
+ // Pick a winner
823
+ if (this.currentGiveaway.entrants.length === 0) {
824
+ this.currentGiveaway = null;
825
+ return 'Giveaway ended with no entrants!';
826
+ }
827
+ const winner = this.pick(this.currentGiveaway.entrants);
828
+ const prize = this.currentGiveaway.prize;
829
+ this.currentGiveaway = null;
830
+ this.ensureViewer(winner);
831
+ this.viewers[winner].xp += 25;
832
+ return `GIVEAWAY WINNER: ${winner} wins "${prize}"! +25 XP`;
833
+ }
834
+ const prize = ctx.args.join(' ') || 'mystery prize';
835
+ this.currentGiveaway = { prize, entrants: [], startFrame: this.currentFrame };
836
+ return `GIVEAWAY: "${prize}" — Type !enter to join!`;
837
+ },
838
+ });
839
+ }
840
+ }
841
+ // ─── Singleton ──────────────────────────────────────────────
842
+ let instance = null;
843
+ export function getStreamCommands() {
844
+ if (!instance)
845
+ instance = new StreamCommands();
846
+ return instance;
847
+ }
848
+ // ─── Tool Registration ──────────────────────────────────────
849
+ export function registerStreamCommandsTools() {
850
+ registerTool({
851
+ name: 'commands_list',
852
+ description: 'List all available stream chat commands with descriptions, cooldowns, and required viewer levels.',
853
+ parameters: {
854
+ level: { type: 'string', description: 'Filter by minimum level: viewer, regular, vip, moderator' },
855
+ },
856
+ tier: 'free',
857
+ execute: async (args) => {
858
+ const cmds = getStreamCommands().getCommands();
859
+ const level = args.level?.toLowerCase();
860
+ const filtered = level
861
+ ? cmds.filter(c => c.minLevel === level)
862
+ : cmds;
863
+ const lines = filtered.map(c => `!${c.name} — ${c.description} (cooldown: ${c.cooldown / 1000}s, level: ${c.minLevel})`);
864
+ return `Stream Commands (${filtered.length}):\n${lines.join('\n')}`;
865
+ },
866
+ });
867
+ registerTool({
868
+ name: 'commands_stats',
869
+ description: 'Get viewer stats for a specific user: XP, messages, rank, level, inventory, equipped items.',
870
+ parameters: {
871
+ username: { type: 'string', description: 'Viewer username to look up', required: true },
872
+ },
873
+ tier: 'free',
874
+ execute: async (args) => {
875
+ const username = args.username;
876
+ const stats = getStreamCommands().getViewerStats(username);
877
+ return JSON.stringify(stats, null, 2);
878
+ },
879
+ });
880
+ registerTool({
881
+ name: 'commands_inventory',
882
+ description: 'Get the inventory (items, hats, badges, pets, tools) for a specific viewer.',
883
+ parameters: {
884
+ username: { type: 'string', description: 'Viewer username', required: true },
885
+ },
886
+ tier: 'free',
887
+ execute: async (args) => {
888
+ const username = args.username;
889
+ const items = getStreamCommands().getInventory(username);
890
+ if (items.length === 0)
891
+ return `${username} has no items.`;
892
+ return items.map(i => `${i.name} (${i.type}): ${i.description}`).join('\n');
893
+ },
894
+ });
895
+ registerTool({
896
+ name: 'commands_leaderboard',
897
+ description: 'Get the XP leaderboard for stream viewers. Returns top N viewers sorted by XP.',
898
+ parameters: {
899
+ limit: { type: 'string', description: 'Number of entries to return (default: 10)' },
900
+ },
901
+ tier: 'free',
902
+ execute: async (args) => {
903
+ const limit = parseInt(args.limit, 10) || 10;
904
+ const lb = getStreamCommands().getLeaderboard(limit);
905
+ if (lb.length === 0)
906
+ return 'Leaderboard is empty — no viewers tracked yet.';
907
+ return lb.map(e => `#${e.rank} ${e.username}: ${e.xp} XP (${e.messageCount} msgs)`).join('\n');
908
+ },
909
+ });
910
+ } // end registerStreamCommandsTools
911
+ //# sourceMappingURL=stream-commands.js.map