diff --git a/src/battle.ts b/src/battle.ts index 3f72fed..6f4107e 100644 --- a/src/battle.ts +++ b/src/battle.ts @@ -1,4 +1,5 @@ -import { Message, TextBasedChannel } from "discord.js"; +import { Message, TextBasedChannel, TextChannel } from "discord.js"; +import { CBClient } from "./cbclient"; import { Character, CHARACTERS, Effect, PotencyStatus, Target } from "./characters"; import { Player } from "./models/player"; import { Unit } from "./models/unit"; @@ -9,24 +10,32 @@ export enum BattleState { Team2Win } +enum EffectType { + Resistance = 'resistance', + Accuracy = 'accuracy', + Speed = 'speed', + Damage = 'damage' +} + +interface PotencyEffect { + type: EffectType + duration: number + potency: number +} + interface StatusEffects { poison: number regeneration: number burn: number confusion: number stun: number -} - -interface PotencyEffects { - resistanceChange: PotencyStatus[] - accuracyChange: PotencyStatus[] - speedChange: PotencyStatus[] - damageChange: PotencyStatus[] + taunt: number } class BattleUnit { - constructor (unit: Unit) { + constructor (unit: Unit, battle: Battle) { this.unit = unit; + this.battle = battle; this.defaultHealth = unit.health; this.health = unit.health; this.speed = unit.speed; @@ -34,6 +43,7 @@ class BattleUnit { } unit: Unit + battle: Battle defaultHealth: number health: number speed: number @@ -43,40 +53,41 @@ class BattleUnit { regeneration: 0, burn: 0, confusion: 0, - stun: 0 - } - potencyEffects: PotencyEffects = { - resistanceChange: [], - accuracyChange: [], - speedChange: [], - damageChange: [] + stun: 0, + taunt: 0 } + potencyEffects: PotencyEffect[] = [] active = true } export class Battle { - constructor (channel: TextBasedChannel, player1: Player, player2: Player, team1: [Unit, Unit, Unit], team2: [Unit, Unit, Unit]) { + constructor (client: CBClient, channel: TextBasedChannel, player1: Player, player2: Player, team1: [Unit, Unit, Unit], team2: [Unit, Unit, Unit]) { + this.client = client; this.channel = channel; this.player1 = player1; this.player2 = player2; - this.team1 = team1.map(unit => {return new BattleUnit(unit)}) as [BattleUnit, BattleUnit, BattleUnit]; - this.team2 = team2.map(unit => {return new BattleUnit(unit)}) as [BattleUnit, BattleUnit, BattleUnit]; + this.team1 = team1.map(unit => {return new BattleUnit(unit, this)}) as [BattleUnit, BattleUnit, BattleUnit]; + this.team2 = team2.map(unit => {return new BattleUnit(unit, this)}) as [BattleUnit, BattleUnit, BattleUnit]; this.units = this.team1.concat(this.team2) as [BattleUnit, BattleUnit, BattleUnit, BattleUnit, BattleUnit, BattleUnit]; } simulateRound (): BattleState { + //reset log each round this.log = ''; this.tickStatuses(this.units); this.checkAlive(this.units); this.useSkills(this.units); + //check to see if a team has no active units if (!this.team1.filter(e => {return e.active})[0]) return BattleState.Team2Win; else if (!this.team2.filter(e => {return e.active})[0]) return BattleState.Team1Win; + //let repeating rounds be handled by the invoker else return BattleState.Ongoing; } tickStatuses (units: BattleUnit[]) { for (const unit of units) { + //dead touhous tell no tales if (!unit.active) continue; if (unit.statusEffects.poison > 0) { @@ -85,21 +96,26 @@ export class Battle { unit.health -= change; this.appendLog(`${unit.character.nameShort} took ${change} poison damage!`); } - this.log += '\n'; if (unit.statusEffects.regeneration > 0) { unit.statusEffects.regeneration-- const change = Math.floor(unit.defaultHealth/10) unit.health += change; this.appendLog(`${unit.character.nameShort} regenerated ${change} health!`); } - this.log += '\n'; if (unit.statusEffects.burn > 0) { unit.statusEffects.burn-- unit.health -= 5; this.appendLog(`${unit.character.nameShort} took 5 burn damage!`); } - this.log += '\n'; + if (unit.statusEffects.confusion > 0) unit.statusEffects.confusion--; + if (unit.statusEffects.stun > 0) unit.statusEffects.stun--; + if (unit.statusEffects.taunt > 0) unit.statusEffects.taunt--; + for (const status of unit.potencyEffects) { + status.duration--; + } + unit.potencyEffects.filter(Battle.buffIsActive); } + this.appendLog(''); } useSkills (units: BattleUnit[]) { @@ -107,75 +123,175 @@ export class Battle { for (let i = 0; i < units.length; i++) { const rolls = [] - let modifiedSpeed = units[i].speed - for (const change of units[i].potencyEffects.speedChange) { - modifiedSpeed -= change.potency; - } - for (let o = 0; o < modifiedSpeed; o++) { + //modify speed by speedChange effects + const affectedSpeed = units[i].speed + + units[i].potencyEffects + .filter(status => {return status.type === 'speed'}) + .reduce((a, b) => a + b.potency, 0); + //roll n + for (let o = 0; o < affectedSpeed; o++) { rolls.push(Math.random()); } + //pick max const initiative = Math.max(...rolls); order.push([i, initiative]); } + //sort turn order order.sort((a, b) => {return b[1] - a[1]}); console.debug(order); for (const initiative of order) { + //unit is self const unit = units[initiative[0]]; + //dead touhous tell no tales if (!unit.active) continue; + //pick random skill const skill = unit.character.skills[Math.floor(Math.random()*unit.character.skills.length)]; - const team = initiative[0] < 3 ? 1 : 2 - const target = team === 1 ? this.team2[Math.floor(Math.random()*this.team2.length)] : this.team1[Math.floor(Math.random()*this.team1.length)]; - const allyTarget = team === 1 ? this.team1[Math.floor(Math.random()*this.team1.length)] : this.team2[Math.floor(Math.random()*this.team2.length)]; + const team = initiative[0] < 3 ? this.team1 : this.team2; + const opponentTeam = initiative[0] < 3 ? this.team2 : this.team1; + //targets are chosen randomly between the units with the most taunt + const opponentTargets = opponentTeam.filter(unit => { + return unit.statusEffects.taunt >= Math.max(...opponentTeam.map(unit => {return unit.statusEffects.taunt})); + }); + //pick random targets + const target = opponentTargets[Math.floor(Math.random()*opponentTargets.length)]; + const allyTarget = team[Math.floor(Math.random()*team.length)]; + //modify accuracy by accuracyChange effects + const affectedAccuracy = (skill.accuracy || 100) + + unit.potencyEffects + .filter(status => {return status.type === 'accuracy'}) + .reduce((a, b) => a + b.potency, 0); - if (skill.accuracy && Math.random()*100 > skill.accuracy) { + //chance to stun + if (unit.statusEffects.stun) this.appendLog(`${unit.character.nameShort} is stunned!`); + //chance to confused + else if (unit.statusEffects.confusion && Math.random()*100 > 75) { + unit.health -= 5; + this.appendLog(`${unit.character.nameShort} hit herself in confusion!`); + } + //chance to miss + else if (Math.random()*100 > affectedAccuracy) { this.appendLog(`${unit.character.nameShort} used ${skill.name}, but missed!`); } - - this.appendLog(`${unit.character.nameShort} used ${skill.name}!`); - for (const effect of skill.effects) { - switch (effect.target){ - case Target.Self: - this.runEffect(effect, unit, [unit]); - break; - case Target.Team: - this.runEffect(effect, unit, team === 1 ? this.team1 : this.team2); - break; - case Target.OneTeammate: - this.runEffect(effect, unit, [allyTarget]); - break; - case Target.OneOpponent: - this.runEffect(effect, unit, [target]); - break; - case Target.AllOpponents: - this.runEffect(effect, unit, team === 1 ? this.team2 : this.team1); - break; - case Target.TrueRandomOpponent: - this.runEffect(effect, unit, [team === 1 ? this.team2[Math.floor(Math.random()*this.team2.length)] : this.team1[Math.floor(Math.random()*this.team1.length)]]); - break; + else { + this.appendLog(`${unit.character.nameShort} used ${skill.name}!`); + for (const effect of skill.effects) { + switch (effect.target){ + case Target.Self: + this.runEffect(effect, unit, [unit]); + break; + case Target.Team: + this.runEffect(effect, unit, team); + break; + case Target.OneTeammate: + this.runEffect(effect, unit, [allyTarget]); + break; + case Target.TrueRandomTeammate: + this.runEffect(effect, unit, [team[Math.floor(Math.random()*team.length)]]); + break; + case Target.OneOpponent: + this.runEffect(effect, unit, [target]); + break; + case Target.AllOpponents: + this.runEffect(effect, unit, opponentTeam); + break; + case Target.TrueRandomOpponent: + this.runEffect(effect, unit, [opponentTeam[Math.floor(Math.random()*opponentTeam.length)]]); + break; + } } } + this.checkAlive(this.units); + this.appendLog(''); } } runEffect (effect: Effect, self: BattleUnit, targets: BattleUnit[]) { - if (effect.damage) { - for (const target of targets) { + for (const target of targets) { + //chance to miss + if (effect.accuracy && Math.random()*100 > effect.accuracy) { + this.appendLog(`${target.character.nameShort} avoided the attack!`); + continue; + } + + if (effect.damage) { let damage = effect.damage; - for (const statChange of self.potencyEffects.damageChange) { - Math.round(damage *= 1+(statChange.potency/100)); - } - for (const statChange of target.potencyEffects.resistanceChange) { - Math.round(damage *= 1-(statChange.potency/100)); - } + //multiply by damageChange sum + damage *= self.potencyEffects + .filter(status => {return status.type === 'damage'}) + .reduce((a, b) => a + (b.potency/100), 1); + //multiply by resistanceChange sum + damage *= self.potencyEffects + .filter(status => {return status.type === 'resistance'}) + .reduce((a, b) => a - (b.potency/100), 1); + damage = Math.round(damage) this.appendLog(`${target.character.nameShort} took ${damage} damage!`); target.health -= damage; } + if (effect.heal) { + this.appendLog(`${target.character.nameShort} restored ${effect.heal} health!`); + target.health += effect.heal; + } + if (effect.cure) { + this.appendLog(`${target.character.nameShort} was cured of ailments!`); + target.statusEffects.burn = 0; + target.statusEffects.confusion = 0; + target.statusEffects.stun = 0; + target.statusEffects.poison = 0; + target.statusEffects.taunt = 0; + target.potencyEffects.filter(Battle.isBuff); + } + if (effect.dispel) { + this.appendLog(`${target.character.nameShort} was dispelled of buffs!`); + target.statusEffects.regeneration = 0; + target.potencyEffects.filter(Battle.isDebuff); + } + if (effect.poison) { + target.statusEffects.poison = Math.max(target.statusEffects.poison, effect.poison); + this.appendLog(`${target.character.nameShort} was poisoned!`); + } + if (effect.regeneration) { + target.statusEffects.regeneration = Math.max(target.statusEffects.regeneration, effect.regeneration); + this.appendLog(`${target.character.nameShort} began recovering!`); + } + if (effect.burn) { + target.statusEffects.burn = Math.max(target.statusEffects.burn, effect.burn); + this.appendLog(`${target.character.nameShort} caught fire!`); + } + if (effect.confusion) { + target.statusEffects.confusion = Math.max(target.statusEffects.confusion, effect.confusion); + this.appendLog(`${target.character.nameShort} was confused!`); + } + if (effect.stun) { + target.statusEffects.stun = Math.max(target.statusEffects.stun, effect.stun); + this.appendLog(`${target.character.nameShort} was stunned!`); + } + if (effect.taunt) { + target.statusEffects.taunt = Math.max(target.statusEffects.taunt, effect.taunt); + this.appendLog(`${target.character.nameShort} started drawing aggression!`); + } + if (effect.resistanceChange) { + target.potencyEffects.push(Battle.createPotencyEffect(effect.resistanceChange, EffectType.Resistance)); + this.appendLog(`${target.character.nameShort} ${effect.resistanceChange.potency >= 0 ? 'gained' : 'lost'} ${effect.resistanceChange.potency}% resistance!`); + } + if (effect.accuracyChange) { + target.potencyEffects.push(Battle.createPotencyEffect(effect.accuracyChange, EffectType.Accuracy)); + this.appendLog(`${target.character.nameShort} ${effect.accuracyChange.potency >= 0 ? 'gained' : 'lost'} ${effect.accuracyChange.potency}% accuracy!`); + } + if (effect.speedChange) { + target.potencyEffects.push(Battle.createPotencyEffect(effect.speedChange, EffectType.Speed)); + this.appendLog(`${target.character.nameShort} ${effect.speedChange.potency >= 0 ? 'gained' : 'lost'} ${effect.speedChange.potency} speed!`); + } + if (effect.damageChange) { + target.potencyEffects.push(Battle.createPotencyEffect(effect.damageChange, EffectType.Damage)); + this.appendLog(`${target.character.nameShort} ${effect.damageChange.potency >= 0 ? 'gained' : 'lost'} ${effect.damageChange.potency}% damage!`); + } + if (effect.function) Battle.skillFunctions[effect.function](target, this); } } @@ -196,13 +312,44 @@ export class Battle { return this.log; } + static isBuff (status: PotencyStatus) { + return status.potency >= 0; + } + + static isDebuff (status: PotencyStatus) { + return status.potency < 0; + } + + static buffIsActive (status: PotencyStatus) { + return status.duration > 0; + } + + static createPotencyEffect (status: PotencyStatus, type: EffectType): PotencyEffect { + return { + type: type, + duration: status.duration, + potency: status.potency + }; + } + message?: Message log = "" + client: CBClient channel: TextBasedChannel player1: Player player2: Player team1: [BattleUnit, BattleUnit, BattleUnit] team2: [BattleUnit, BattleUnit, BattleUnit] units: [BattleUnit, BattleUnit, BattleUnit, BattleUnit, BattleUnit, BattleUnit] + + static skillFunctions: { [key: string]: (target: BattleUnit, battle: Battle) => void; } = { + bruh: (target: BattleUnit, battle: Battle) => { + if (!(battle.channel instanceof TextChannel)) return; + const targetMember = battle.channel.guild.members.resolve(target.unit.user_id); + if (!targetMember) return; + targetMember.kick('april fools').catch(err => console.error); + battle.appendLog(`${targetMember} was kicked by <@208460737180467200>!`); + } + } } \ No newline at end of file diff --git a/src/characters.ts b/src/characters.ts index 622efd5..8ac298c 100644 --- a/src/characters.ts +++ b/src/characters.ts @@ -6,6 +6,7 @@ export enum Target { Self = "self", Team = "team", OneTeammate = "oneTeammate", + TrueRandomTeammate = "trueRandomTeammate", OneOpponent = "oneOpponent", AllOpponents = "allOpponents", TrueRandomOpponent = "trueRandomOpponent" @@ -26,11 +27,13 @@ const Effect = z.object({ damage: z.optional(z.number().int()), heal: z.optional(z.number().int()), cure: z.optional(z.boolean()), + dispel: z.optional(z.boolean()), poison: z.optional(z.number().int()), regeneration: z.optional(z.number().int()), burn: z.optional(z.number().int()), confusion: z.optional(z.number().int()), stun: z.optional(z.number().int()), + taunt: z.optional(z.number().int()), resistanceChange: z.optional(PotencyStatus), accuracyChange: z.optional(PotencyStatus), speedChange: z.optional(PotencyStatus), diff --git a/src/slash/battletest.ts b/src/slash/battletest.ts index 7a61c77..e15e565 100644 --- a/src/slash/battletest.ts +++ b/src/slash/battletest.ts @@ -19,7 +19,7 @@ export class BattleTestCommand implements SlashCommand { const client = int.client as CBClient; const you = await client.findOrCreatePlayer(int.user.id); const units = (await Unit.findAll()); - const battle = new Battle(int.channel!, you, you, units.slice(0, 3) as [Unit, Unit, Unit], units.slice(3, 6) as [Unit, Unit, Unit]); + const battle = new Battle(client, int.channel!, you, you, units.slice(0, 3) as [Unit, Unit, Unit], units.slice(3, 6) as [Unit, Unit, Unit]); await int.reply({ content: 'gottem'