fefdf
Author: rika_song

You are viewing version 647342. There is a newer version of this app. See the latest version of this app.

Description Source Code Launch Bot Current Users

Short Description:

dfsfa

Full Description

/* Barrier Battle App ___ _ ___ __ __ __
* / _ )___ _________(_)__ ____/ _ )___ _/ /_/ /_/ /__
* Let your viewers break down barriers from around your goals. / _ / _ `/ __/ __/ / -_) __/ _ / _ `/ __/ __/ / -_)
* - by Unaa. /____/\_,_/_/ /_/ /_/\__/_/ /____/\_,_/\__/\__/_/\_*/
"use strict";
if (typeof cb === "undefined") { // IDE sanity.
cb = {};
}

// LIVE
const PANEL_BACKGROUND_IMAGE_1_GOAL = "323d26f3-d892-4efe-9abc-5073a2170566";
const PANEL_BACKGROUND_IMAGE_2_GOAL = "e532b196-72e8-46eb-833f-fe13e8e8c2b4";
const PANEL_BACKGROUND_IMAGE_3_GOAL = "0448ee0f-25ec-4890-aff8-848388ca7051";

class Game
{
constructor()
{
// Initialize services when the game starts.
Users.init();
GoalState.init();
Chat.init();
Panel.init();

// Register chat commands to enable listening for these commands and also filter any chat messages where any of
// these commands have been executed to keep the chat clean. The user executing the command will still be able
// to see the message in chat.
Chat.registerChatCommand("class");
Chat.registerChatCommand("classinfo");
Chat.registerChatCommand("scores");

// Send a welcome message to a user that joined the room after the game was started.
Chat.on("user-joined", (user) => {
this.sendWelcomeMessage(user);
});

// Send a message if a user re-joined the room after leaving during the game.
Chat.on("user-rejoined", (user) => {
if (user.userClass) {
Chat.sendMessageToUser([
`Welcome back, ${user.name}! You are currently participating in a game of BARRIER BATTLE and are`,
`playing as a ${user.userClass.name}. Get smashin'!`
].join(" "), user.name);
} else {
this.sendWelcomeMessage(user);
}
});

// Proxy the command to the user instance that executed it.
Chat.on("command", (user, cmd, args) => user.onCommand(cmd, args));

// Send intro message to all users that are watching right now.
this.sendWelcomeMessage();
}

sendWelcomeMessage(user)
{
let send = (message, color, bgColor, weight) => {
if (user) {
Chat.sendMessageToUser(message, user.name, color, bgColor, weight);
} else {
Chat.sendMessage(message, color, bgColor, weight);
}
};

if (user) {
send(`Welcome ${user.name}! We are currently playing BARRIER BATTLE!`, `#ffffff`, `#412C7C`, `bolder`);
} else {
send(`----------------[ Lets play BARRIER BATTLE! ]----------------`, `#ffffff`, `#412C7C`, `bolder`);
}

Object.keys(Settings.classes).forEach((c) => {
send(`type "/class ${c}" to play as a ${c}, or "/classinfo ${c}" to see detailed information about this class.`, "#574b73", undefined, "bolder");
});

send("", "#fff", "#fff");
GoalState.goals.forEach((goal) => {
send(` - Tip ${goal.trigger} tokens to attack the barrier around "${goal.title}".`, "#9b82ba", undefined, "normal");
});
send("", "#fff", "#fff");
send(`If you start tipping for a goal without choosing a class, one will be randomly assigned to you instead.`, "#A33", "#eee", "bold");
send("", "#fff", "#fff");
send(`Type "/scores" to show the score board.`, "#78a", undefined);
}

/**
* Returns the intro message for the app.
*
* @returns {string}
*/
getIntroMessage()
{
let lines = [];

lines.push(`Each goal is protected by a barrier! You can help bring down those barriers by tipping the right amount of tokens.`);
GoalState.goals.forEach((goal) => {
lines.push([
` - Tip ${goal.trigger} tokens to attack the barrier around "${goal.title}".`
].join(" "));
});

lines.push("");
lines.push(`In order to help bring the barriers down, you have to assign a class to yourself.`);
lines.push(`Type "/class" to show the list of available classes to choose from. Make sure you pick one before tipping.`);
lines.push("");
lines.push("Some classes may have the ability to sometimes heal a goal that was not targeted by a small amount to keep the competition going.");
lines.push("You can check out the detailed class description of each class before choosing one.");

Object.keys(Settings.classes).forEach((className) => {
lines.push(` - Type "/class ${className}" to assume to role of ${className}. Type "/classinfo ${className}" for detailed statistics about this class.`);
});

if (!Settings.allowClassSwitch) {
lines.push("");
lines.push("Beware that you may not switch to a different class after you've chosen one.");
}

return lines.join("\n");
}
}

/**
* Enable binding of event listeners to services that emit events.
*/
class EventEmitter
{
static get _events()
{
if (!EventEmitter.__events) {
EventEmitter.__events = new Map();
}
return EventEmitter.__events;
}

constructor()
{
this.events = new Map();
}

/**
* Executes the given callback when the specified event is triggered.
*
* @param {string} name
* @param {function} callback
*/
on(name, callback)
{
if (!this.events.has(name)) {
this.events.set(name, []);
}
this.events.get(name).push(callback);
}

/**
* [STATIC] Executes the given callback when the specified event is triggered.
*
* @param {string} name
* @param {function} callback
*/
static on(name, callback)
{
if (!EventEmitter._events.has(name)) {
EventEmitter._events.set(name, []);
}
EventEmitter._events.get(name).push(callback);
}

/**
* Emits the given event. Any additional specified arguments will be passed on to the executed listeners.
*
* @param {string} name
* @param {*} args
*/
emit(name, ...args)
{
if (!this.events.has(name)) {
return;
}
this.events.get(name).forEach((callback) => {
callback(...args);
});
}

/**
* [STATIC] Emits the given event. Any additional specified arguments will be passed on to the executed listeners.
*
* @param {string} name
* @param {*} args
*/
static emit(name, ...args)
{
if (!EventEmitter._events.has(name)) {
return;
}
EventEmitter._events.get(name).forEach((callback) => {
callback(...args);
});
}
}

/**
* Service class that holds the goal state and defines gameplay logic.
*/
class GoalState extends EventEmitter
{
static init()
{
GoalState.goals = [];
let index = 0,
now = new Date().getTime() / 1000;

Settings.goals.forEach((goal) => {
GoalState.goals.push({
title: goal.title,
trigger: goal.trigger,
health: goal.health,
maxHealth: goal.health,
id: index,
reviveTokens: goal.reviveTokens,
healInterval: goal.healInterval,
healPerInterval: goal.healPerInterval,
lastHealTick: now
});
index++;
});

Users.on("user-tip", (user, amount) => {
GoalState.handleTip(user, amount);
});

GoalState.tickGoalHPI();
}

/**
* Shows a score board detailing which goal has had most attacks and lists
* all users that attacked that barrier, in order of highest tippers to the lowest.
*/
static showScoreBoard()
{
let users = Users.getParticipants();
let goals = [];

GoalState.goals.forEach((goal) => {
let goalScore = {
title: goal.title,
score: 0,
attackers: []
};

// Collect all users that have participated in this goal.
users.forEach((user) => {
if (user.tipScore.has(goal.title)) {
goalScore.attackers.push({
user: user,
tips: user.tipScore.get(goal.title),
damage: user.damageScore.get(goal.title) || 0
});

// Add the final score to the goal.
goalScore.score += (user.damageScore.get(goal.title) || 0);
}
});

// Sort the attacker list based on most damage done.
goalScore.attackers.sort((u1, u2) => u2.damage - u1.damage);

goals.push(goalScore);
});

// Sort the goals based on damage done.
goals.sort((a, b) => b.score - a.score);

// Print the score board.
Chat.sendMessage(`-----------[ BARRIER BATTLE SCORE BOARD ]-----------`, '#ffffff', '#48318b', 'normal');
goals.forEach((goal) => {
Chat.sendMessage(`[${goal.score}] :: ${goal.title}`, '#59429c', undefined, 'bolder');
goal.attackers.forEach((attacker) => {
Chat.sendMessage([
` ----- ${attacker.user.userClass.name} ${attacker.user.name} dealt ${attacker.damage} damage with`,
`${attacker.tips} tips.`
].join(' '), '#9b5fbf', undefined, 'normal');
});
});
Chat.sendMessage(`------------------------------------------------------------------`, '#ffffff', '#48318b', 'normal');
}

/**
* Handles heal-per-interval for goals that have this enabled.
*/
static tickGoalHPI()
{
let needsUpdate = false,
now = new Date().getTime() / 1000;

this.goals.forEach((goal) => {
// Make sure we need to tick this goal's health up.
if (goal.health < 1 || goal.healInterval < 1 || goal.healPerInterval < 1) {
return;
}

if (goal.lastHealTick < now && goal.health < goal.maxHealth) {
goal.health = Math.min(goal.maxHealth, goal.health + goal.healPerInterval);
goal.lastHealTick = now + goal.healInterval;
needsUpdate = true;
}
});

if (needsUpdate) {
Panel.update();
}

setTimeout(() => GoalState.tickGoalHPI(), 1000);
}

/**
* Handle tip logic.
*
* @param {User} user
* @param {number} amount
*/
static handleTip(user, amount)
{
// Check if we can revive a goal barrier.
let goalWasRevived = false;
GoalState.goals.forEach((g) => {
if (g.health === 0 && amount === g.reviveTokens) {
Chat.sendMessage(`The barrier around "${g.title}" was revived by ${user.name}! BRING IT DOWN!`);
g.health = Settings.goals[g.id].health;
Panel.update();
goalWasRevived = true;
}
});
if (goalWasRevived) {
return;
}

// Find the goal that should be triggered by the given amount of tokens.
let goal = GoalState.getTriggeredGoal(amount);
if (!goal) {
return;
}

// Make sure the player has selected a class. If not, select and assign one at random.
if (!user.userClass) {
user.assignRandomClass();
Chat.sendMessageToUser([
`Thank you for your tip ${user.name}! Since you did not choose a class to play, the`,
`${user.userClass.name} has been randomly assigned to you.`,
Settings.allowClassSwitch ? `You can switch classes by typing "/class".` : ``
].join(" "), user.name);
}

// Store last health so we know what it was before the current user attacks it.
let lastHealth = goal.health;

// Let the user attack the selected goal.
user.attack(goal, amount);

// Send a message notifying users can revive the defeated goal.
if (lastHealth !== goal.health && goal.health === 0) {
if (goal.reviveTokens > 0) {
Chat.sendMessage(`The barrier around ${goal.title} was already destroyed! However, it may be revived by tipping ${goal.reviveTokens} tokens to play another round!`);
} else {
Chat.sendMessage(`The barrier around ${goal.title} was already destroyed! Try another one or wait for the performer to start a new round.`);
}
}

// Let all viewers update the panel.
Panel.update();
}

/**
* Returns the goal that was triggered by the given amount of tokens.
*
* @param {number} amount
*/
static getTriggeredGoal(amount)
{
return GoalState.goals.find((g) => g.trigger === amount);
}
}

/**
* Service class for handling app settings.
*/
class Settings
{
static get DEFAULT_GOAL_NAMES() { return ["A very sexy dance", "Seductive striptease", "Sexy cameltoe tease"]; }

static get DEFAULT_GOAL_TRIGGERS() { return [15, 16, 17]; }

static get DEFAULT_CLASS_NAMES() { return ["ranger", "wizard", "hacker"]; }

static get DEFAULT_CLASS_PRIZES() { return ["Boobies flash", "Kisses", "Booty slap"]; }

static get DEFAULT_CLASS_ATTACK_SKILLS() { return ["shoots an oak tree with some leaves still attached to it", "waves his magic wand with an utmost majestic motion", "grabs a laptop and starts hitting the keys really hard"]; }

static get DEFAULT_CLASS_MIN_DMG() { return [15, 25, 20]; }

static get DEFAULT_CLASS_MAX_DMG() { return [25, 50, 35]; }

static get DEFAULT_CLASS_CRIT_CHANCE() { return [20, 1, 15]; }

static get DEFAULT_CLASS_HEAL_CHANCE() { return [8, 0, 5]; }

static get DEFAULT_CLASS_CRIT_MUL() { return [2, 1, 3]; }

static get DEFAULT_CLASS_SHARE_BOOST_CHANCE() { return [25, 25, 25]; }

static get DEFAULT_CLASS_MIN_HEAL() { return [5, 5, 5]; }

static get DEFAULT_CLASS_MAX_HEAL() { return [10, 10, 10]; }

static get allowClassSwitch() { return cb.settings.allowClassSwitch === "Yes"; }

/**
* Returns an array of configured goals.
*
* @returns {Array}
*/
static get goals()
{
let goals = [];

for (let i = 1; i < 4; i++) {
if (!cb.settings["goalTitle" + i]) {
continue;
}

goals.push({
title: cb.settings["goalTitle" + i],
trigger: cb.settings["goalTrigger" + i],
health: cb.settings["goalHealth" + i],
reviveTokens: cb.settings["goalReviveTokens" + i],
healInterval: cb.settings["goalHealInterval" + i],
healPerInterval: cb.settings["goalHealPerInterval" + i]
});
}

return goals;
}

/**
* Returns a dictionary of user classes indexed by name.
*
* @returns {{UserClass}}
*/
static get classes()
{
if (typeof Settings.userClasses !== "undefined") {
return Settings.userClasses;
}

Settings.userClasses = {};

for (let i = 1; i < 4; i++) {
let name = cb.settings["className" + i];
cb.log("GOT CLASS: " + name);

Settings.userClasses[name] = new UserClass({
name: name,
minDamage: cb.settings["classMinDamage" + i],
maxDamage: cb.settings["classMaxDamage" + i],
critChance: cb.settings["classCritChance" + i],
critMultiplier: cb.settings["classCritMultiplier" + i],
critPrize: cb.settings["classCritPrize" + i],
healChance: cb.settings["classHealChance" + i],
minHeal: cb.settings["classMinHeal" + i],
maxHeal: cb.settings["classMaxHeal" + i],
shareBoostChance: cb.settings["classBoostShareChance" + i],
attackSkill: cb.settings["classAttackSkill" + i]
});
}

return Settings.userClasses;
}

/**
* Returns true if the class with the given name exists.
*
* @param {string} className
* @returns {boolean}
*/
static classExists(className)
{
return typeof Settings.userClasses[className] !== "undefined";
}

/**
* Returns the settings form fields to configure the app.
*
* @returns {*[]}
*/
static renderForm()
{
return [
...Settings._renderGoalForm(1, true),
...Settings._renderGoalForm(2),
...Settings._renderGoalForm(3),
...Settings._renderBoostForm(),
...Settings._renderClassForm(1),
...Settings._renderClassForm(2),
...Settings._renderClassForm(3)
];
}

static _renderGoalForm(goalId, required = false)
{
return [
{
name: "goalTitle" + goalId,
label: "Goal #" + goalId + " prize",
type: "str",
required: required,
minLength: 1,
maxLength: 32,
defaultValue: Settings.DEFAULT_GOAL_NAMES[goalId - 1]
}, {
name: "goalTrigger" + goalId,
label: "Goal #" + goalId + " tip trigger",
type: "int",
required: required,
minValue: 1,
maxValue: 100,
defaultValue: Settings.DEFAULT_GOAL_TRIGGERS[goalId - 1]
}, {
name: "goalHealth" + goalId,
label: "Goal #" + goalId + " total health",
type: "int",
required: required,
minValue: 1,
maxValue: 9999,
defaultValue: 1000
}, {
name: "goalReviveTokens" + goalId,
label: "Goal #" + goalId + " tokens required to start over after defeat (0 = disabled)",
type: "int",
required: required,
minValue: 0,
maxValue: 9999,
defaultValue: 1000
}, {
name: "goalHealInterval" + goalId,
label: "Goal #" + goalId + " auto heal interval in seconds (set to 0 to disable)",
type: "int",
required: required,
minValue: 0,
maxValue: 1000,
defaultValue: 0
}, {
name: "goalHealPerInterval" + goalId,
label: "Goal #" + goalId + " auto heal points per interval (force viewers to keep the tips flowing)",
type: "int",
required: required,
minValue: 0,
maxValue: 1000,
defaultValue: 1
}
];
}

static _renderBoostForm()
{
return [
{
name: "boostTokens",
label: "Tokens needed for time-based damage boost",
type: "int",
minValue: 1,
maxValue: 1000,
defaultValue: 100
}, {
name: "boostMultiplier",
label: "Boost damage multiplier",
type: "int",
minValue: 1,
maxValue: 10,
defaultValue: 2
}, {
name: "boostTimer",
label: "Boost timer (in seconds)",
type: "int",
minValue: 10,
maxValue: 120,
defaultValue: 30
}, {
name: "allowClassSwitch",
label: "Allow switching classes mid-game",
type: "choice",
choice1: "No",
choice2: "Yes",
defaultValue: "No"
}
];
}

static _renderClassForm(classId)
{
return [
{
name: "className" + classId,
label: "Class #" + classId + " name (1 word, keep it short)",
type: "str",
minLength: 1,
maxLength: 32,
defaultValue: Settings.DEFAULT_CLASS_NAMES[classId - 1]
}, {
name: "classCritPrize" + classId,
label: "Class #" + classId + " critical hit prize",
type: "str",
minLength: 1,
maxLength: 128,
defaultValue: Settings.DEFAULT_CLASS_PRIZES[classId - 1]
}, {
name: "classAttackSkill" + classId,
label: "Class #" + classId + " attack text",
type: "str",
minLength: 1,
maxLength: 128,
defaultValue: Settings.DEFAULT_CLASS_ATTACK_SKILLS[classId - 1]
}, {
name: "classMinDamage" + classId,
label: "Class #" + classId + " min damage points",
type: "int",
minValue: 1,
maxValue: 100,
defaultValue: Settings.DEFAULT_CLASS_MIN_DMG[classId - 1]
}, {
name: "classMaxDamage" + classId,
label: "Class #" + classId + " max damage points",
type: "int",
minValue: 1,
maxValue: 100,
defaultValue: Settings.DEFAULT_CLASS_MAX_DMG[classId - 1]
}, {
name: "classCritChance" + classId,
label: "Class #" + classId + " chance to critically hit (percent)",
type: "int",
minValue: 0,
maxValue: 100,
defaultValue: Settings.DEFAULT_CLASS_CRIT_CHANCE[classId - 1]
}, {
name: "classCritMultiplier" + classId,
label: "Class #" + classId + " critical hit multiplier",
type: "int",
minValue: 1,
maxValue: 100,
defaultValue: Settings.DEFAULT_CLASS_CRIT_MUL[classId - 1]
}, {
name: "classHealChance" + classId,
label: "Class #" + classId + " chance to heal a goal (percent - 0~50)",
type: "int",
minValue: 0,
maxValue: 50,
defaultValue: Settings.DEFAULT_CLASS_HEAL_CHANCE[classId - 1]
}, {
name: "classMinHeal" + classId,
label: "Class #" + classId + " min heal points",
type: "int",
minValue: 0,
maxValue: 50,
defaultValue: Settings.DEFAULT_CLASS_MIN_HEAL[classId - 1]
}, {
name: "classMaxHeal" + classId,
label: "Class #" + classId + " max heal points",
type: "int",
minValue: 0,
maxValue: 50,
defaultValue: Settings.DEFAULT_CLASS_MAX_HEAL[classId - 1]
}, {
name: "classBoostShareChance" + classId,
label: "Class #" + classId + " chance to share boost with another participant (percent)",
type: "int",
minValue: 1,
maxValue: 100,
defaultValue: Settings.DEFAULT_CLASS_SHARE_BOOST_CHANCE[classId - 1]
}
];
}
}

/**
* Service class for rendering the panel.
*/
class Panel
{
static init()
{
cb.onDrawPanel(() => Panel.getPanelData());
Panel.update();
}

static update()
{
cb.drawPanel();
}

/**
* Returns the data to draw the panel below the cam frame.
*
* @returns {{}}
*/
static getPanelData()
{
if (GoalState.goals.length === 1) {
return Panel.renderOneGoalPanel();
}
if (GoalState.goals.length === 2) {
return Panel.renderTwoGoalsPanel();
}
if (GoalState.goals.length === 3) {
return Panel.renderThreeGoalsPanel();
}
}

/**
* Renders the panel that shows one configured goal.
*
* @returns {{template: string, layers: *[]}}
*/
static renderOneGoalPanel()
{
let layers = [...Panel.renderGoal(GoalState.goals[0], 26)];

return {
"template": "image_template",
"layers": [
{"type": "image", "fileID": PANEL_BACKGROUND_IMAGE_1_GOAL},
...layers
]
};
}

/**
* Renders the panel that shows two configured goals.
*
* @returns {{template: string, layers: *[]}}
*/
static renderTwoGoalsPanel()
{
let layers = [
...Panel.renderGoal(GoalState.goals[0], 14),
...Panel.renderGoal(GoalState.goals[1], 37)
];

return {
"template": "image_template",
"layers": [
{"type": "image", "fileID": PANEL_BACKGROUND_IMAGE_2_GOAL},
...layers
]
};
}

/**
* Renders the panel that shows three configured goals.
*
* @returns {{template: string, layers: *[]}}
*/
static renderThreeGoalsPanel()
{
let layers = [
...Panel.renderGoal(GoalState.goals[0], 4),
...Panel.renderGoal(GoalState.goals[1], 26),
...Panel.renderGoal(GoalState.goals[2], 48)
];

return {
"template": "image_template",
"layers": [
{"type": "image", "fileID": PANEL_BACKGROUND_IMAGE_3_GOAL},
...layers
]
};
}

/**
* Returns the layers for the given goal.
*
* @param {Object<*>} goal
* @param {number} top
* @returns {*[]}
*/
static renderGoal(goal, top)
{
return [
{
"type": "text",
"top": top,
"left": 5,
"text": goal.title + " (" + goal.trigger + " tks)",
"font-size": 12,
"font-family": "Monotype Corsiva, Comic Sans MS, cursive",
"color": goal.health > 0 ? "#e2beff" : "#ffffff",
"max-width": 190
}, {
"type": "text",
"text": goal.health > 0 ? (goal.health + " hp") : "DONE!",
"top": top,
"left": 205,
"font-family": "Monotype Corsiva, Comic Sans MS, cursive",
"font-size": 12,
"max-width": 66,
"color": goal.health > 0 ? "#e2beff" : "#ffffff"
}
];
}
}

/**
* Service class for the Chat API.
*/
class Chat extends EventEmitter
{
static init()
{
Chat.commandNames = [];
cb.onMessage((msg) => Chat._handleMessage(msg));
}

/**
* Registers a chat command.
*
* This allows events to be emitted for the given command and will also hide any chat messages from users executing
* these commands to keep the chat clean.
*
* @param command
*/
static registerChatCommand(command)
{
if (!Chat.commandNames) {
Chat.commandNames = [];
}

Chat.commandNames.push(command);
}

/**
* Sends a message to the given recipient. If the recipient is omitted, the message is broadcast to all users in
* the general chat.
*
* @param {string} message
* @param {string} color
* @param {string} backgroundColor
* @param {string} weight
* @param {string} recipient
*/
static sendMessage(message, color = "#57219C", backgroundColor = undefined, weight = "bold", recipient = undefined)
{
cb.sendNotice(message, recipient, backgroundColor, color, weight);
}

/**
* Alias for sendMessage but with easier access to the recipient argument.
*
* @param {string} message
* @param {string} color
* @param {string} backgroundColor
* @param {string} weight
* @param {string} recipient
*/
static sendMessageToUser(message, recipient, color = "#4721CC", backgroundColor = undefined, weight = "bold")
{
Chat.sendMessage(message, color, backgroundColor, weight, recipient);
}

/**
* Handles incoming chat messages.
*
* @param {*} msg
* @private
*/
static _handleMessage(msg)
{
let user = Users.get(msg.user, msg);

if (msg.m.startsWith("/")) {
msg["background"] = "#FFF";
msg["color"] = "#aaa";
if (Chat._handleCommand(user, msg.m)) {
msg["X-Spam"] = true;
}
}

if (user.userClass) {
msg.user = user.userClass.name + " " + msg.user;
}

return msg;
}

/**
* Emits the "command" event if the user executed a command that was properly formatted.
*
* @param {User} user
* @param {string} message
* @private
*/
static _handleCommand(user, message)
{
// Grab the command from the chat in the format of '/command [optional args]'.
let m = /^\/(\w+)\s?(.+)?$/.exec(message);

// Make sure the chat command is properly formatted.
if (!m || m.length !== 3) {
return false;
}

let command = m[1],
args = m[2] || "";

if (Chat.commandNames.indexOf(command) !== -1) {
Chat.emit("command", user, command, args);
return true;
}

return false;
}
}

/**
* Service class for managing users.
*/
class Users extends EventEmitter
{
static init()
{
Users.users = new Map();

cb.onEnter((u) => {
if (Users.users.has(u.user)) {
// Update the user.
Users.users.get(u.user).update(u);
Users.emit("user-rejoined", Users.users.get(u.user));
} else {
let user = new User(u);
Users.users.set(u.user, user);
Users.emit("user-joined", user);
}
});

cb.onLeave((u) => {
Users.emit("user-left", Users.users.get(u.user));
// Users.users.delete(u.user); // @FIXME: Make this configurable?
});

cb.onTip((tip) => {
let amount = tip.amount,
user = Users.get(tip.from_user, {
user: tip.from_user,
gender: tip.from_user_gender,
has_tokens: tip.from_user_has_tokens,
in_fanclub: tip.from_user_in_fanclub,
is_mod: tip.from_user_is_mod
});

user.onTip(amount);
this.emit("user-tip", user, amount);
});
}

/**
* Returns a User instance from the player pool.
*
* @param {string} username
* @param {object} userData
* @returns {User}
*/
static get(username, userData)
{
if (Users.users.has(username) === false) {
Users.users.set(username, new User(userData));
}

return Users.users.get(username);
}

/**
* Returns all participants that have assigned a class and have tipped at least once.
*/
static getParticipants()
{
let p = [];
Users.users.forEach((u) => {
if (u.userClass && u.damageScore.size > 0 && u.tipScore.size > 0) {
p.push(u);
}
});

return p;
}

/**
* Picks a random user that is not the given user.
*
* @param {User} forUser
*/
static getRandomUserForUser(forUser)
{
let u = Array.from(this.users.keys()),
i = 0;

if (u.length === 1) {
return;
}

let user;
do {
user = u[Math.floor(Math.random() * u.length) - 1];
cb.log('RAND: ' + user);
i++;
} while (i < 10 && !(user && user !== forUser.name && this.users.get(user).userClass));

if (!user || user === forUser.name || ! this.users.get(user).userClass) {
return;
}

return Users.users.get(user);
}
}

class User
{
constructor(u)
{
cb.log("New user added to the pool: " + u.user + " (" + u.gender + ")");

this.isTicking = false;
this.userClass = undefined;
this.boostTimer = 0;
this.tipScore = new Map();
this.damageScore = new Map();

this.update(u);
}

/**
* Updates the user data.
*
* @param {object} data
*/
update(data)
{
this.name = data.user;
this.gender = data.gender;
this.hasTokens = data.has_tokens;
this.isFanclubMember = data.in_fanclub;
this.isModerator = data.is_mod;
}

/**
* Assign a random class to this user if it did not choose one yet.
*/
assignRandomClass()
{
// Don't overwrite if the user already made a choice.
if (this.userClass) {
return;
}

let names = Object.keys(Settings.classes),
className = names[Math.floor(Math.random() * names.length)];

this.userClass = Settings.classes[className];
Chat.sendMessage(`${this.name} has assumed the role of ${this.userClass.name}!`);
}

/**
* Executed every second to keep track of timers.
*/
tick()
{
let now = new Date().getTime() / 1000;

if (this.isBoosted && this.boostTimer <= now) {
Chat.sendMessage([
`${this.name}'s boost just ran out, but ${this.gender === "m" ? "he" : "she"} can reapply one by tipping`,
`${cb.settings.boostTokens} tokens. Get down those barriers!`
].join(" "));
this.isTicking = false;
return;
}

this.isBoosted = this.boostTimer > now;
setTimeout(() => this.tick(), 1000);
}

/**
* Attacks the given goal.
*
* @param {*} goal
* @param {number} tipAmount
*/
attack(goal, tipAmount)
{
if (!this.userClass) {
return;
}

if (!this.tipScore.has(goal.title)) {
this.tipScore.set(goal.title, 0);
}
this.tipScore.set(goal.title, this.tipScore.get(goal.title) + tipAmount);

// If the goal was already achieved, let the user know that it can be revived (if applicable).
if (goal.health === 0) {
if (goal.reviveTokens > 0) {
Chat.sendMessageToUser(
`The barrier around "${goal.title}" is already down. You can revive it by tipping ${goal.reviveTokens} tokens.`,
this.name
);
}
return;
}

let damage = Math.round(this.userClass.minDamage + (Math.random() * (this.userClass.maxDamage - this.userClass.minDamage)));
let isCritical = (Math.random() * 100 <= this.userClass.critChance);
let message = [`${this.userClass.name} ${this.name} ${this.userClass.attackSkill}`];
let heShe = this.gender === "m" ? "he" : (this.gender === "f" ? "she" : "has");

// Modify damage based on boost status & critical hit.
damage = this.isBoosted ? damage * cb.settings.boostMultiplier : damage;
damage = isCritical ? damage * this.userClass.critMultiplier : damage;

// Apply damage to goal.
goal.health = Math.max(0, goal.health - damage);

// Compose the rest of the chat message.
if (goal.health > 0) {
if (isCritical) {
message.push(`and deals a critical strike of ${damage} damage points on the barrier around "${goal.title}"!`);
message.push(`With that ${heShe} earned ${this.userClass.critPrize}!`);
} else {
message.push(`and deals ${damage} damage points on the barrier around "${goal.title}"!`);
}

Chat.sendMessage(message.join(" "), "#7741AC", undefined, "normal");
} else {
if (isCritical) {
message = [
`${this.name.toUpperCase()} ${this.userClass.attackSkill.toUpperCase()} AND DEALT A DEVASTATING`,
`FINAL BLOW ON THE BARRIER OF "${goal.title}" WITH A CRITICAL STRIKE OF ${damage} DAMAGE POINTS!`
];
} else {
message = [
`${this.name.toUpperCase()} ${this.userClass.attackSkill.toUpperCase()} AND DEALT THE FINAL BLOW`,
`ON THE BARRIER OF "${goal.title}" WITH ${damage} DAMAGE POINTS!`
];
}

Chat.sendMessage(message.join(" "), "#ffffff", "#57219c", "bolder");
}

if (!this.damageScore.has(goal.title)) {
this.damageScore.set(goal.title, 0);
}
this.damageScore.set(goal.title, this.damageScore.get(goal.title) + damage);

// Check if we can heal another goal barrier.
let doHeal = GoalState.goals.length > 1 && (Math.random() * 100) <= this.userClass.healChance;
if (!doHeal) {
return;
}

let healPoints = Math.round(this.userClass.minHeal + (Math.random() * (this.userClass.maxHeal - this.userClass.minHeal)));
let hGoal, maxAttempts = 0;

do {
hGoal = GoalState.goals[Math.round(Math.random() * GoalState.goals.length)];
maxAttempts++;
} while (maxAttempts < 10 && (!hGoal || hGoal === goal || hGoal.health === 0));

// Check if we found a qualified goal that we can heal up a bit.
if (!(hGoal && hGoal !== goal && hGoal.health > 0)) {
return;
}

// Don't attempt to heal if it's already at full health.
if (hGoal.health === Settings.goals[hGoal.id].health) {
return;
}

hGoal.health = Math.min(Settings.goals[hGoal.id].health, hGoal.health + healPoints);
Chat.sendMessage(
`${this.name} has healed the barrier around "${hGoal.title}" by ${healPoints} points.`,
"#218C22", undefined, "normal"
);
}

/**
* Applies a boost to this user.
*/
applyBoost(giftedFrom)
{
let now = new Date().getTime() / 1000;
let hisHer = this.gender === "m" ? "his" : (this.gender === "f" ? "her" : "the");
let su,
shareWith;

// Roll the dice - but only if this boost was not gifted from somebody else. Can't have a chain reaction going
// on by tipping for one boost...let along having a chance of recursion happening :')
if (!giftedFrom && ((Math.random() * 100) < this.userClass.shareBoostChance)) {
shareWith = Users.getRandomUserForUser(this);
}

if (this.boostTimer > now) {
this.boostTimer = this.boostTimer + cb.settings.boostTimer;
// Notify a 'gifted from' message if this boost was a gift.
if (giftedFrom) {
return Chat.sendMessage(`${this.name} got ${hisHer} boost timer extended by a gift from ${giftedFrom.name}!`);
}

// Notify on chat that this user extended the boost timer.
Chat.sendMessage([
`${this.name} has extended ${hisHer} boost timer with ${cb.settings.boostTimer} seconds`,
shareWith ? ` and gave a free boost to ${shareWith.name} as well!` : `!`
].join(""));

if (shareWith) {
shareWith.applyBoost(this);
}
return;
}

this.boostTimer = now + cb.settings.boostTimer;

if (!this.isTicking) {
this.isTicking = true;
this.tick();
}

if (giftedFrom) {
Chat.sendMessage(`${this.name} activated a boost that was gifted by ${giftedFrom.name}! Let's rumble!`);
} else {
Chat.sendMessage([
`${this.name} has applied a boost for ${cb.settings.boostTimer} seconds`,
shareWith ? ` and gave a free boost to ${shareWith.name} as well!` : `!`
].join(""));
}

if (shareWith) {
shareWith.applyBoost(this);
}
}

/**
* Executed when this user tipped some tokens.
*
* @param {number} amount
*/
onTip(amount)
{
if (amount === cb.settings.boostTokens) {
if (! this.userClass) {
this.assignRandomClass();
}
this.applyBoost();
}
}

/**
* Executed when this user executes a command in chat.
*
* @param {string} command
* @param {string} args
*/
onCommand(command, args)
{
switch (command) {
case "scores":
GoalState.showScoreBoard();
break;
// Send class information about the given class to this user.
case "classinfo":
if (!args || args === "") {
return Chat.sendMessageToUser([
`Please specify a class name to see the detailed statistics of. You can choose from the following: `,
Object.keys(Settings.classes).join(", ")
].join(" "), this.name);
}
if (!Settings.classExists(args)) {
return Chat.sendMessageToUser([
`I'm sorry ${this.name}, but that class does not exist. Type "/class" to see a list of`,
`available classes to choose from.`
].join(" "), this.name);
}
return Chat.sendMessageToUser(Settings.classes[args].getDetailsMessage(), this.name);

// Select the class for this user.
case "class":
// If no arguments were given, show the list of available classes.
if (!args || args.length === 0) {
let lines = [];
Object.keys(Settings.classes).forEach((className) => {
lines.push([
`Type "/class ${className}" to assume to role of ${className}. Type "/classinfo`,
`${className}" for detailed statistics about this class.`
].join(" "));
});
Chat.sendMessageToUser(lines.join("\n"), this.name);
return;
}

// Don't allow switching to another class mid-game if the broadcaster does not permit it.
if (!Settings.allowClassSwitch && typeof this.userClass !== "undefined") {
return Chat.sendMessageToUser(
`I'm sorry ${this.name}, but you are only allowed to choose a class once per game.`,
this.name
);
}

// Make sure the selected class exists.
if (!Settings.classExists(args)) {
return Chat.sendMessageToUser([
`I'm sorry ${this.name}, but that class does not exist. Type "/class" to see a list of`,
`available classes to choose from.`
].join(" "), this.name);
}

this.userClass = Settings.classes[args];
Chat.sendMessage(`${this.name} has assumed the role of ${this.userClass.name}!`);
break;
}
}
}

/**
* Represents the selected class for a user.
*/
class UserClass
{
constructor(options)
{
this.name = options.name;
this.minDamage = options.minDamage;
this.maxDamage = options.maxDamage;
this.critChance = options.critChance;
this.critMultiplier = options.critMultiplier;
this.critPrize = options.critPrize;
this.healChance = options.healChance;
this.minHeal = options.minHeal;
this.maxHeal = options.maxHeal;
this.shareBoostChance = options.shareBoostChance;
this.attackSkill = options.attackSkill || "attacks";
}

/**
* Returns the detailed statistics message for this class.
*
* @returns {string}
*/
getDetailsMessage()
{
let message = [
`The ${this.name} class ${this.attackSkill} and may deal between ${this.minDamage} and ${this.maxDamage}`,
`points of damage.\n`
];

if (this.critChance > 0 && this.critMultiplier > 1) {
message.push(...[
`There is a ${this.critChance}% chance that an attack will result in a critical hit. If this happens`,
`the damage done may range between ${this.minDamage * this.critMultiplier} and `,
`${this.maxDamage * this.critMultiplier}. You will be rewarded with the prize: "${this.critPrize}".`
]);
}

if (this.healChance > 0) {
message.push(...[
`\n`,
`This class has a ${this.healChance}% chance to heal a different goal than the one that was targeted`,
`last by ${this.minHeal}~${this.maxHeal} points.`
]);
}

message.push(...[
`\nYou may apply a boost to yourself by tipping ${cb.settings.boostTokens} tokens to increase your base damage`,
`to ${this.minDamage * cb.settings.boostMultiplier}~${this.maxDamage * cb.settings.boostMultiplier} points.`,
`A boost will last for ${cb.settings.boostTimer} seconds.`
]);
if (this.shareBoostChance > 0) {
message.push(...[
`There is also a ${this.shareBoostChance}% chance of you sharing your boost with another player!`
]);
}

return message.join(" ");
}
}

// ------------------------------------------------------------------------------------------------------------------ //

// Render the settings form for the app.
cb.settings_choices = Settings.renderForm();

// Run the game.
new Game();

© Copyright Chaturbate 2011- 2026. All Rights Reserved.