import type { ChallengeType } from './room-battle'; /** * A bundle of: - a ID * - a battle format * - a valid team for that format * - misc other preferences for the battle * * To start a battle, you need one of these for every player. */ export class BattleReady { readonly userid: ID; readonly formatid: string; readonly settings: User['battleSettings']; readonly rating: number; readonly challengeType: ChallengeType; readonly time: number; constructor( userid: ID, formatid: string, settings: User['battleSettings'], rating = 0, challengeType: ChallengeType = 'challenge' ) { this.userid = userid; this.formatid = formatid; this.settings = settings; this.rating = rating; this.challengeType = challengeType; this.time = Date.now(); } } export abstract class AbstractChallenge { from: ID; to: ID; ready: BattleReady | null; format: string; acceptCommand: string | null; message: string; acceptButton: string; rejectButton: string; roomid: RoomID; constructor(from: ID, to: ID, ready: BattleReady | string, options: { acceptCommand?: string, rejectCommand?: string, roomid?: RoomID, message?: string, acceptButton?: string, rejectButton?: string, } = {}) { this.from = from; this.to = to; this.ready = typeof ready === 'string' ? null : ready; this.format = typeof ready === 'string' ? ready : ready.formatid; this.acceptCommand = options.acceptCommand || null; this.message = options.message || ''; this.roomid = options.roomid || ''; this.acceptButton = options.acceptButton || ''; this.rejectButton = options.rejectButton || ''; } destroy(accepted?: boolean) {} } /** * As a regular battle challenge, acceptCommand will be null, but you * can set acceptCommand to use this for custom requests wanting a * team for something. */ export class BattleChallenge extends AbstractChallenge { declare ready: BattleReady; declare acceptCommand: string | null; } export class GameChallenge extends AbstractChallenge { declare ready: null; declare acceptCommand: string; } /** * Invites for `/importinputlog` (`ready: null`) or 4-player battles * (`ready: BattleReady`) */ export class BattleInvite extends AbstractChallenge { declare acceptCommand: string; destroy(accepted?: boolean) { if (accepted) return; const room = Rooms.get(this.roomid); if (!room) return; // room expired? const battle = room.battle!; let invitesFull = true; for (const player of battle.players) { if (!player.invite && !player.id) invitesFull = false; if (player.invite === this.to) player.invite = ''; } if (invitesFull) battle.sendInviteForm(true); } } /** * The defining difference between a BattleChallenge and a GameChallenge is * that a BattleChallenge has a Ready (and is for a RoomBattle format) and * a GameChallenge doesn't (and is for a RoomGame). * * But remember that both can have a custom acceptCommand. */ export type Challenge = BattleChallenge | GameChallenge; /** * Lists outgoing and incoming challenges for each user ID. */ export class Challenges extends Map { getOrCreate(userid: ID): Challenge[] { let challenges = this.get(userid); if (challenges) return challenges; challenges = []; this.set(userid, challenges); return challenges; } /** Throws Chat.ErrorMessage if a challenge between these users is already in the table */ add(challenge: Challenge): true { const oldChallenge = this.search(challenge.to, challenge.from); if (oldChallenge) { throw new Chat.ErrorMessage(`There is already a challenge (${challenge.format}) between ${challenge.to} and ${challenge.from}!`); } const to = this.getOrCreate(challenge.to); const from = this.getOrCreate(challenge.from); to.push(challenge); from.push(challenge); this.update(challenge.to, challenge.from); return true; } /** Returns false if the challenge isn't in the table */ remove(challenge: Challenge, accepted?: boolean): boolean { const to = this.getOrCreate(challenge.to); const from = this.getOrCreate(challenge.from); const toIndex = to.indexOf(challenge); let success = false; if (toIndex >= 0) { to.splice(toIndex, 1); if (!to.length) this.delete(challenge.to); success = true; } const fromIndex = from.indexOf(challenge); if (fromIndex >= 0) { from.splice(fromIndex, 1); if (!from.length) this.delete(challenge.from); } if (success) { this.update(challenge.to, challenge.from); challenge.destroy(accepted); } return success; } search(userid1: ID, userid2: ID): Challenge | null { const challenges = this.get(userid1); if (!challenges) return null; for (const challenge of challenges) { if ( (challenge.to === userid1 && challenge.from === userid2) || (challenge.to === userid2 && challenge.from === userid1) ) { return challenge; } } return null; } searchByRoom(userid: ID, roomid: RoomID) { const challenges = this.get(userid); if (!challenges) return null; for (const challenge of challenges) { if (challenge.roomid === roomid) return challenge; } return null; } /** * Try to accept a custom challenge, throwing `Chat.ErrorMessage` on failure, * and returning the user the challenge was from on a success. */ resolveAcceptCommand(context: Chat.CommandContext) { const targetid = context.target as ID; const chall = this.search(context.user.id, targetid); if (!chall || chall.to !== context.user.id || chall.acceptCommand !== context.message) { throw new Chat.ErrorMessage(`Challenge not found. You are using the wrong command. Challenges should be accepted with /accept`); } return chall; } accept(context: Chat.CommandContext) { const chall = this.resolveAcceptCommand(context); this.remove(chall, true); const fromUser = Users.get(chall.from); if (!fromUser) throw new Chat.ErrorMessage(`User "${chall.from}" is not available right now.`); return fromUser; } clearFor(userid: ID, reason?: string): number { const user = Users.get(userid); const userIdentity = user ? user.getIdentity() : ` ${userid}`; const challenges = this.get(userid); if (!challenges) return 0; for (const challenge of challenges) { const otherid = challenge.to === userid ? challenge.from : challenge.to; const otherUser = Users.get(otherid); const otherIdentity = otherUser ? otherUser.getIdentity() : ` ${otherid}`; const otherChallenges = this.get(otherid)!; const otherIndex = otherChallenges.indexOf(challenge); if (otherIndex >= 0) otherChallenges.splice(otherIndex, 1); if (otherChallenges.length === 0) this.delete(otherid); if (!user && !otherUser) continue; const header = `|pm|${userIdentity}|${otherIdentity}|`; let message = `${header}/challenge`; if (reason) message = `${header}/text Challenge cancelled because ${reason}.\n${message}`; user?.send(message); otherUser?.send(message); } this.delete(userid); return challenges.length; } getUpdate(challenge: Challenge | null) { if (!challenge) return `/challenge`; const teambuilderFormat = challenge.ready ? challenge.ready.formatid : ''; return `/challenge ${challenge.format}|${teambuilderFormat}|${challenge.message}|${challenge.acceptButton}|${challenge.rejectButton}`; } update(userid1: ID, userid2: ID) { const challenge = this.search(userid1, userid2); userid1 = challenge ? challenge.from : userid1; userid2 = challenge ? challenge.to : userid2; this.send(userid1, userid2, this.getUpdate(challenge)); } send(userid1: ID, userid2: ID, message: string) { const user1 = Users.get(userid1); const user2 = Users.get(userid2); const user1Identity = user1 ? user1.getIdentity() : ` ${userid1}`; const user2Identity = user2 ? user2.getIdentity() : ` ${userid2}`; const fullMessage = `|pm|${user1Identity}|${user2Identity}|${message}`; user1?.send(fullMessage); user2?.send(fullMessage); } updateFor(connection: Connection | User) { const user = connection.user; const challenges = this.get(user.id); if (!challenges) return; const userIdentity = user.getIdentity(); let messages = ''; for (const challenge of challenges) { let fromIdentity, toIdentity; if (challenge.from === user.id) { fromIdentity = userIdentity; const toUser = Users.get(challenge.to); toIdentity = toUser ? toUser.getIdentity() : ` ${challenge.to}`; } else { const fromUser = Users.get(challenge.from); fromIdentity = fromUser ? fromUser.getIdentity() : ` ${challenge.from}`; toIdentity = userIdentity; } messages += `|pm|${fromIdentity}|${toIdentity}|${this.getUpdate(challenge)}\n`; } connection.send(messages); } } export const challenges = new Challenges();