- Added hunt/feed duck mechanics (80% hunt, 20% feed) - Implemented persistent scoring system - Added channel control commands (\!stopducks/\!startducks) - Enhanced duck hunt with wrong action penalties - Organized bot structure with botmain.js as main file - Added comprehensive documentation (README.md) - Included 17 plugins with various games and utilities 🦆 Duck Hunt Features: - Hunt ducks with \!shoot/\!bang (80% of spawns) - Feed ducks with \!feed (20% of spawns) - Persistent scores saved to JSON - Channel-specific controls for #bakedbeans - Reaction time tracking and special achievements 🎮 Other Games: - Casino games (slots, coinflip, hi-lo, scratch cards) - Multiplayer games (pigs, zombie dice, quiplash) - Text generation (babble, conspiracy, drunk historian) - Interactive features (story writing, emojify, combos) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
499 lines
No EOL
20 KiB
JavaScript
499 lines
No EOL
20 KiB
JavaScript
// plugins/story.js - Collaborative storytelling plugin
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
module.exports = {
|
|
init(bot) {
|
|
console.log('📖 Story plugin initialized');
|
|
this.bot = bot;
|
|
|
|
// Story data structure
|
|
this.stories = new Map(); // channel -> { title, sentences: [{author, text, timestamp}], active, lastContribution, maxLength }
|
|
|
|
// Story settings
|
|
this.settings = {
|
|
maxSentenceLength: 200,
|
|
maxStoryLength: 20, // Maximum sentences per story
|
|
cooldownTime: 60000, // 1 minute between contributions from same user
|
|
maxStoriesPerChannel: 3, // Maximum saved stories per channel
|
|
inactivityTimeout: 300000 // 5 minutes of inactivity ends story
|
|
};
|
|
|
|
// User cooldowns
|
|
this.userCooldowns = new Map(); // "channel:nick" -> timestamp
|
|
|
|
// File to save stories
|
|
this.storiesFile = path.join(__dirname, 'stories.json');
|
|
|
|
// Load existing stories
|
|
this.loadStories();
|
|
|
|
// Story starters for inspiration
|
|
this.storyStarters = [
|
|
"In a world where gravity worked backwards, Sarah discovered that her morning coffee was the least of her problems.",
|
|
"The last library on Earth had just received its most unusual visitor.",
|
|
"Detective Martinez never expected to find a confession letter written in crayon at a crime scene.",
|
|
"The GPS kept saying 'turn left at the rainbow,' but there was no rainbow in sight.",
|
|
"When the doorbell rang at 3 AM, nobody expected to find a penguin holding a pizza delivery bag.",
|
|
"The antique music box in grandmother's attic began playing a song that hadn't been written yet.",
|
|
"Every time it rained, the mailbox would fill with letters addressed to people who didn't exist.",
|
|
"The new teacher seemed normal until students noticed she never cast a shadow.",
|
|
"The fortune cookie contained a message that simply read: 'The dragons know where you hid the keys.'",
|
|
"Professor Chen's time machine worked perfectly, except it only traveled to last Tuesday.",
|
|
"The vending machine in the subway station started dispensing items that wouldn't be invented for another century.",
|
|
"When the power went out citywide, only one house remained mysteriously illuminated.",
|
|
"The street musician's violin could make people temporarily forget their own names.",
|
|
"Every night at exactly 11:47 PM, the statue in the town square would sneeze.",
|
|
"The new apartment came with everything included, except the previous tenant refused to leave the mirror."
|
|
];
|
|
},
|
|
|
|
cleanup(bot) {
|
|
console.log('📖 Story plugin cleaned up');
|
|
this.saveStories();
|
|
},
|
|
|
|
// Load stories from file
|
|
loadStories() {
|
|
try {
|
|
if (fs.existsSync(this.storiesFile)) {
|
|
const data = fs.readFileSync(this.storiesFile, 'utf8');
|
|
const storiesObject = JSON.parse(data);
|
|
|
|
// Convert back to Map and reconstruct story objects
|
|
this.stories = new Map();
|
|
for (const [channel, storyData] of Object.entries(storiesObject)) {
|
|
this.stories.set(channel, {
|
|
title: storyData.title || 'Untitled Story',
|
|
sentences: storyData.sentences || [],
|
|
active: false, // Always start inactive when loading
|
|
lastContribution: storyData.lastContribution || Date.now(),
|
|
maxLength: storyData.maxLength || this.settings.maxStoryLength
|
|
});
|
|
}
|
|
|
|
console.log(`📚 Loaded ${this.stories.size} saved stories`);
|
|
} else {
|
|
console.log('📚 No existing stories file found, starting fresh');
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Error loading stories:', error);
|
|
this.stories = new Map();
|
|
}
|
|
},
|
|
|
|
// Save stories to file
|
|
saveStories() {
|
|
try {
|
|
// Only save completed stories (not active ones)
|
|
const completedStories = {};
|
|
for (const [channel, story] of this.stories.entries()) {
|
|
if (!story.active && story.sentences.length > 0) {
|
|
completedStories[channel] = {
|
|
title: story.title,
|
|
sentences: story.sentences,
|
|
lastContribution: story.lastContribution,
|
|
maxLength: story.maxLength
|
|
};
|
|
}
|
|
}
|
|
|
|
const data = JSON.stringify(completedStories, null, 2);
|
|
fs.writeFileSync(this.storiesFile, data, 'utf8');
|
|
console.log(`💾 Saved ${Object.keys(completedStories).length} completed stories`);
|
|
} catch (error) {
|
|
console.error('❌ Error saving stories:', error);
|
|
}
|
|
},
|
|
|
|
// Check if user is on cooldown
|
|
isOnCooldown(channel, nick) {
|
|
const key = `${channel}:${nick}`;
|
|
const lastContribution = this.userCooldowns.get(key);
|
|
|
|
if (!lastContribution) return false;
|
|
|
|
const timeSince = Date.now() - lastContribution;
|
|
return timeSince < this.settings.cooldownTime;
|
|
},
|
|
|
|
// Set user cooldown
|
|
setCooldown(channel, nick) {
|
|
const key = `${channel}:${nick}`;
|
|
this.userCooldowns.set(key, Date.now());
|
|
},
|
|
|
|
// Get remaining cooldown time
|
|
getCooldownRemaining(channel, nick) {
|
|
const key = `${channel}:${nick}`;
|
|
const lastContribution = this.userCooldowns.get(key);
|
|
|
|
if (!lastContribution) return 0;
|
|
|
|
const timeSince = Date.now() - lastContribution;
|
|
const remaining = this.settings.cooldownTime - timeSince;
|
|
return Math.max(0, Math.ceil(remaining / 1000));
|
|
},
|
|
|
|
// Get current story for channel
|
|
getCurrentStory(channel) {
|
|
return this.stories.get(channel);
|
|
},
|
|
|
|
// Start a new story
|
|
startStory(channel, title = null, customStarter = null) {
|
|
const storyTitle = title || `Story ${Date.now()}`;
|
|
|
|
let firstSentence;
|
|
if (customStarter) {
|
|
firstSentence = customStarter;
|
|
} else {
|
|
firstSentence = this.storyStarters[Math.floor(Math.random() * this.storyStarters.length)];
|
|
}
|
|
|
|
const story = {
|
|
title: storyTitle,
|
|
sentences: [{
|
|
author: 'StoryBot',
|
|
text: firstSentence,
|
|
timestamp: Date.now()
|
|
}],
|
|
active: true,
|
|
lastContribution: Date.now(),
|
|
maxLength: this.settings.maxStoryLength
|
|
};
|
|
|
|
this.stories.set(channel, story);
|
|
return story;
|
|
},
|
|
|
|
// Add sentence to current story
|
|
addSentence(channel, author, text) {
|
|
const story = this.getCurrentStory(channel);
|
|
if (!story || !story.active) return false;
|
|
|
|
// Check if story is at max length
|
|
if (story.sentences.length >= story.maxLength) {
|
|
this.endStory(channel);
|
|
return false;
|
|
}
|
|
|
|
// Clean up the sentence
|
|
const cleanText = text.trim();
|
|
if (cleanText.length === 0) return false;
|
|
if (cleanText.length > this.settings.maxSentenceLength) return false;
|
|
|
|
// Add the sentence
|
|
story.sentences.push({
|
|
author: author,
|
|
text: cleanText,
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
story.lastContribution = Date.now();
|
|
|
|
// Check if story should end (reached max length)
|
|
if (story.sentences.length >= story.maxLength) {
|
|
this.endStory(channel);
|
|
}
|
|
|
|
return true;
|
|
},
|
|
|
|
// End the current story
|
|
endStory(channel) {
|
|
const story = this.getCurrentStory(channel);
|
|
if (!story) return false;
|
|
|
|
story.active = false;
|
|
this.saveStories();
|
|
return true;
|
|
},
|
|
|
|
// Get story as formatted text
|
|
formatStory(story, includeAuthors = false, maxSentences = null) {
|
|
if (!story || story.sentences.length === 0) return "No story found.";
|
|
|
|
const sentences = maxSentences ? story.sentences.slice(0, maxSentences) : story.sentences;
|
|
|
|
let formattedStory = `📖 "${story.title}"\n\n`;
|
|
|
|
sentences.forEach((sentence, index) => {
|
|
if (includeAuthors && sentence.author !== 'StoryBot') {
|
|
formattedStory += `${sentence.text} [${sentence.author}] `;
|
|
} else {
|
|
formattedStory += `${sentence.text} `;
|
|
}
|
|
});
|
|
|
|
if (story.active) {
|
|
formattedStory += `\n\n📝 Story in progress (${sentences.length}/${story.maxLength} sentences)`;
|
|
} else {
|
|
formattedStory += `\n\n✅ Story completed with ${sentences.length} sentences`;
|
|
}
|
|
|
|
return formattedStory.trim();
|
|
},
|
|
|
|
// Check for inactive stories and end them
|
|
checkInactiveStories() {
|
|
for (const [channel, story] of this.stories.entries()) {
|
|
if (story.active) {
|
|
const timeSinceLastContribution = Date.now() - story.lastContribution;
|
|
if (timeSinceLastContribution > this.settings.inactivityTimeout) {
|
|
this.endStory(channel);
|
|
this.bot.say(channel, '📖 Story ended due to inactivity. Use !story to start a new one!');
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
commands: [
|
|
{
|
|
name: 'story',
|
|
description: 'Start a new collaborative story or add to the current one',
|
|
execute: function(context, bot) {
|
|
const plugin = module.exports;
|
|
const target = context.replyTo;
|
|
const from = context.nick;
|
|
const args = context.args;
|
|
const channel = context.channel || target;
|
|
|
|
// Check for inactive stories
|
|
plugin.checkInactiveStories();
|
|
|
|
const currentStory = plugin.getCurrentStory(channel);
|
|
|
|
// If no arguments, show current story status
|
|
if (args.length === 0) {
|
|
if (currentStory && currentStory.active) {
|
|
const preview = plugin.formatStory(currentStory, false, 3);
|
|
bot.say(target, preview);
|
|
bot.say(target, `💬 Add to the story with: !story <your sentence>`);
|
|
} else {
|
|
bot.say(target, '📖 No active story. Start one with: !story <your opening sentence>');
|
|
bot.say(target, '💡 Or use: !storynew for a random starter');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const sentence = args.join(' ');
|
|
|
|
// If there's an active story, try to add to it
|
|
if (currentStory && currentStory.active) {
|
|
// Check cooldown
|
|
if (plugin.isOnCooldown(channel, from)) {
|
|
const remaining = plugin.getCooldownRemaining(channel, from);
|
|
bot.say(target, `${from}: Please wait ${remaining} seconds before contributing again.`);
|
|
return;
|
|
}
|
|
|
|
// Check sentence length
|
|
if (sentence.length > plugin.settings.maxSentenceLength) {
|
|
bot.say(target, `${from}: Sentence too long! Max ${plugin.settings.maxSentenceLength} characters.`);
|
|
return;
|
|
}
|
|
|
|
// Add the sentence
|
|
if (plugin.addSentence(channel, from, sentence)) {
|
|
plugin.setCooldown(channel, from);
|
|
|
|
// Show last few sentences for context
|
|
const recentSentences = currentStory.sentences.slice(-2);
|
|
let context = recentSentences.map(s => s.text).join(' ');
|
|
|
|
bot.say(target, `📝 ${from} added: "${sentence}"`);
|
|
|
|
// Check if story is complete
|
|
if (!currentStory.active) {
|
|
bot.say(target, `🎉 Story "${currentStory.title}" is complete! Use !storyread to see the full story.`);
|
|
} else {
|
|
bot.say(target, `📖 Story continues... (${currentStory.sentences.length}/${currentStory.maxLength})`);
|
|
}
|
|
} else {
|
|
bot.say(target, `${from}: Couldn't add sentence. Story may be complete or sentence invalid.`);
|
|
}
|
|
} else {
|
|
// Start new story with user's sentence
|
|
const story = plugin.startStory(channel, `Story by ${from}`, sentence);
|
|
plugin.setCooldown(channel, from);
|
|
|
|
bot.say(target, `📖 ${from} started a new story: "${sentence}"`);
|
|
bot.say(target, `💬 Others can continue with: !story <next sentence>`);
|
|
}
|
|
}
|
|
},
|
|
|
|
{
|
|
name: 'storynew',
|
|
description: 'Start a new story with a random starter',
|
|
execute: function(context, bot) {
|
|
const plugin = module.exports;
|
|
const target = context.replyTo;
|
|
const from = context.nick;
|
|
const channel = context.channel || target;
|
|
|
|
// Check for inactive stories
|
|
plugin.checkInactiveStories();
|
|
|
|
const currentStory = plugin.getCurrentStory(channel);
|
|
|
|
if (currentStory && currentStory.active) {
|
|
bot.say(target, `${from}: There's already an active story! Use !storyend to finish it first.`);
|
|
return;
|
|
}
|
|
|
|
const story = plugin.startStory(channel);
|
|
|
|
bot.say(target, `📖 New story started: "${story.sentences[0].text}"`);
|
|
bot.say(target, `💬 Continue the story with: !story <your sentence>`);
|
|
}
|
|
},
|
|
|
|
{
|
|
name: 'storyread',
|
|
description: 'Read the current or last completed story',
|
|
execute: function(context, bot) {
|
|
const plugin = module.exports;
|
|
const target = context.replyTo;
|
|
const channel = context.channel || target;
|
|
|
|
const story = plugin.getCurrentStory(channel);
|
|
|
|
if (!story) {
|
|
bot.say(target, 'No story found for this channel. Start one with !story or !storynew');
|
|
return;
|
|
}
|
|
|
|
const formattedStory = plugin.formatStory(story, true);
|
|
|
|
// Split long stories into multiple messages
|
|
const maxLength = 400;
|
|
if (formattedStory.length <= maxLength) {
|
|
bot.say(target, formattedStory);
|
|
} else {
|
|
const lines = formattedStory.split('\n');
|
|
let currentMessage = '';
|
|
|
|
for (const line of lines) {
|
|
if (currentMessage.length + line.length + 1 <= maxLength) {
|
|
currentMessage += (currentMessage ? '\n' : '') + line;
|
|
} else {
|
|
if (currentMessage) {
|
|
bot.say(target, currentMessage);
|
|
currentMessage = line;
|
|
} else {
|
|
// Single line too long, split it
|
|
bot.say(target, line.substring(0, maxLength));
|
|
currentMessage = line.substring(maxLength);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (currentMessage) {
|
|
bot.say(target, currentMessage);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
{
|
|
name: 'storyend',
|
|
description: 'End the current story',
|
|
execute: function(context, bot) {
|
|
const plugin = module.exports;
|
|
const target = context.replyTo;
|
|
const from = context.nick;
|
|
const channel = context.channel || target;
|
|
|
|
const story = plugin.getCurrentStory(channel);
|
|
|
|
if (!story || !story.active) {
|
|
bot.say(target, `${from}: No active story to end.`);
|
|
return;
|
|
}
|
|
|
|
plugin.endStory(channel);
|
|
bot.say(target, `📖 ${from} ended the story "${story.title}" with ${story.sentences.length} sentences.`);
|
|
bot.say(target, `📚 Use !storyread to read the completed story, or !storynew to start fresh.`);
|
|
}
|
|
},
|
|
|
|
{
|
|
name: 'storystatus',
|
|
description: 'Show current story status and stats',
|
|
execute: function(context, bot) {
|
|
const plugin = module.exports;
|
|
const target = context.replyTo;
|
|
const from = context.nick;
|
|
const channel = context.channel || target;
|
|
|
|
plugin.checkInactiveStories();
|
|
|
|
const story = plugin.getCurrentStory(channel);
|
|
|
|
if (!story) {
|
|
bot.say(target, 'No story in this channel. Use !story or !storynew to start one!');
|
|
return;
|
|
}
|
|
|
|
let statusMsg = `📊 Story "${story.title}": ${story.sentences.length}/${story.maxLength} sentences`;
|
|
|
|
if (story.active) {
|
|
statusMsg += ' (Active)';
|
|
|
|
// Show contributors
|
|
const contributors = [...new Set(story.sentences.filter(s => s.author !== 'StoryBot').map(s => s.author))];
|
|
if (contributors.length > 0) {
|
|
statusMsg += ` | Contributors: ${contributors.join(', ')}`;
|
|
}
|
|
|
|
// Show cooldown info
|
|
if (plugin.isOnCooldown(channel, from)) {
|
|
const remaining = plugin.getCooldownRemaining(channel, from);
|
|
statusMsg += ` | Your cooldown: ${remaining}s`;
|
|
}
|
|
} else {
|
|
statusMsg += ' (Completed)';
|
|
}
|
|
|
|
bot.say(target, statusMsg);
|
|
}
|
|
},
|
|
|
|
{
|
|
name: 'storytitle',
|
|
description: 'Set a title for the current story',
|
|
execute: function(context, bot) {
|
|
const plugin = module.exports;
|
|
const target = context.replyTo;
|
|
const from = context.nick;
|
|
const args = context.args;
|
|
const channel = context.channel || target;
|
|
|
|
if (args.length === 0) {
|
|
bot.say(target, `${from}: Usage: !storytitle <new title>`);
|
|
return;
|
|
}
|
|
|
|
const story = plugin.getCurrentStory(channel);
|
|
|
|
if (!story) {
|
|
bot.say(target, `${from}: No story to rename. Start one first!`);
|
|
return;
|
|
}
|
|
|
|
const newTitle = args.join(' ');
|
|
if (newTitle.length > 50) {
|
|
bot.say(target, `${from}: Title too long! Max 50 characters.`);
|
|
return;
|
|
}
|
|
|
|
const oldTitle = story.title;
|
|
story.title = newTitle;
|
|
|
|
bot.say(target, `📖 ${from} renamed the story from "${oldTitle}" to "${newTitle}"`);
|
|
}
|
|
}
|
|
]
|
|
}; |