gotchi-battler-game-logic 1.0.0

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,1354 @@
1
+ const seedrandom = require('seedrandom')
2
+
3
+ const { GameError } = require('../../utils/errors')
4
+
5
+ let {
6
+ PASSIVES,
7
+ BUFF_MULT_EFFECTS,
8
+ BUFF_FLAT_EFFECTS,
9
+ DEBUFF_MULT_EFFECTS,
10
+ DEBUFF_FLAT_EFFECTS,
11
+ DEBUFFS,
12
+ BUFFS,
13
+ MULTS
14
+ } = require('./constants')
15
+
16
+ // Get only alive gotchis in a team
17
+ const getAlive = (team, row) => {
18
+ if (row) {
19
+ return team.formation[row].filter(x => x).filter(x => x.health > 0)
20
+ }
21
+
22
+ return [...team.formation.front, ...team.formation.back].filter(x => x).filter(x => x.health > 0)
23
+ }
24
+
25
+ /**
26
+ * Get the formation position of a gotchi
27
+ * @param {Object} team1 An in-game team object
28
+ * @param {Object} team2 An in-game team object
29
+ * @param {Number} gotchiId The id of the gotchi
30
+ * @returns {Object} position The formation position of the gotchi
31
+ * @returns {Number} position.team The team the gotchi is on
32
+ * @returns {String} position.row The row the gotchi is on
33
+ * @returns {Number} position.position The position of the gotchi in the row
34
+ * @returns {null} position null if the gotchi is not found
35
+ **/
36
+ const getFormationPosition = (team1, team2, gotchiId) => {
37
+ const team1FrontIndex = team1.formation.front.findIndex(x => x && x.id === gotchiId)
38
+
39
+ if (team1FrontIndex !== -1) return {
40
+ team: 1,
41
+ row: 'front',
42
+ position: team1FrontIndex,
43
+ name: team1.formation.front[team1FrontIndex].name
44
+ }
45
+
46
+ const team1BackIndex = team1.formation.back.findIndex(x => x && x.id === gotchiId)
47
+
48
+ if (team1BackIndex !== -1) return {
49
+ team: 1,
50
+ row: 'back',
51
+ position: team1BackIndex,
52
+ name: team1.formation.back[team1BackIndex].name
53
+ }
54
+
55
+ const team2FrontIndex = team2.formation.front.findIndex(x => x && x.id === gotchiId)
56
+
57
+ if (team2FrontIndex !== -1) return {
58
+ team: 2,
59
+ row: 'front',
60
+ position: team2FrontIndex,
61
+ name: team2.formation.front[team2FrontIndex].name
62
+ }
63
+
64
+ const team2BackIndex = team2.formation.back.findIndex(x => x && x.id === gotchiId)
65
+
66
+ if (team2BackIndex !== -1) return {
67
+ team: 2,
68
+ row: 'back',
69
+ position: team2BackIndex,
70
+ name: team2.formation.back[team2BackIndex].name
71
+ }
72
+
73
+ return null
74
+ }
75
+
76
+ /**
77
+ * Get the leader gotchi of a team
78
+ * @param {Object} team An in-game team object
79
+ * @returns {Object} gotchi The leader gotchi
80
+ * @returns {Number} leader.id The id of the gotchi
81
+ * @returns {String} leader.special The special object of the gotchi
82
+ * @returns {String} leader.special.class The class of the special
83
+ **/
84
+ const getLeaderGotchi = (team) => {
85
+ const leader = [...team.formation.front, ...team.formation.back].find(x => x && x.id === team.leader)
86
+
87
+ if (!leader) throw new Error('Leader not found')
88
+
89
+ return leader
90
+ }
91
+
92
+ /**
93
+ * Get the next gotchi to act
94
+ * @param {Object} team1 An in-game team object
95
+ * @param {Object} team2 An in-game team object
96
+ * @param {Function} rng The random number generator
97
+ * @returns {Object} position The formation position of the gotchi
98
+ **/
99
+ const getNextToAct = (team1, team2, rng) => {
100
+ const aliveGotchis = [...getAlive(team1), ...getAlive(team2)]
101
+
102
+ aliveGotchis.sort((a, b) => a.actionDelay - b.actionDelay)
103
+
104
+ let toAct = aliveGotchis.filter(gotchi => gotchi.actionDelay === aliveGotchis[0].actionDelay)
105
+
106
+ // If only one gotchi can act then return it
107
+ if (toAct.length === 1) return getFormationPosition(team1, team2, toAct[0].id)
108
+
109
+ // Lowest speeds win tiebreaker
110
+ toAct.sort((a, b) => a.speed - b.speed)
111
+ toAct = toAct.filter(gotchi => gotchi.speed === toAct[0].speed)
112
+
113
+ // If only one gotchi can act then return it
114
+
115
+ if (toAct.length === 1) return getFormationPosition(team1, team2, toAct[0].id)
116
+
117
+ // If still tied then randomly choose
118
+ const randomIndex = Math.floor(rng() * toAct.length)
119
+
120
+ if (!toAct[randomIndex]) throw new Error(`No gotchi found at index ${randomIndex}`)
121
+
122
+ toAct = toAct[randomIndex]
123
+ return getFormationPosition(team1, team2, toAct.id)
124
+ }
125
+
126
+ const getTarget = (defendingTeam, rng) => {
127
+ // Check for taunt gotchis
128
+ const taunt = [...getAlive(defendingTeam, 'front'), ...getAlive(defendingTeam, 'back')].filter(gotchi => gotchi.statuses && gotchi.statuses.includes("taunt"))
129
+
130
+ if (taunt.length) {
131
+ if (taunt.length === 1) return taunt[0]
132
+
133
+ // If multiple taunt gotchis then randomly choose one
134
+ return taunt[Math.floor(rng() * taunt.length)]
135
+ }
136
+
137
+ // Target gotchis in the front row first
138
+ const frontRow = getAlive(defendingTeam, 'front')
139
+
140
+ if (frontRow.length) {
141
+ return frontRow[Math.floor(rng() * frontRow.length)]
142
+ }
143
+
144
+ // If no gotchis in front row then target back row
145
+ const backRow = getAlive(defendingTeam, 'back')
146
+
147
+ if (backRow.length) {
148
+ return backRow[Math.floor(rng() * backRow.length)]
149
+ }
150
+
151
+ throw new Error('No gotchis to target')
152
+ }
153
+
154
+ const applySpeedPenalty = (gotchi, penalty) => {
155
+ const speedPenalty = (gotchi.speed - 100) * penalty
156
+
157
+ return {
158
+ ...gotchi,
159
+ magic: gotchi.magic - speedPenalty,
160
+ physical: gotchi.physical - speedPenalty
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Get the damage of an attack
166
+ * @param {Object} attackingTeam The attacking team
167
+ * @param {Object} defendingTeam The defending team
168
+ * @param {Object} attackingGotchi The gotchi attacking
169
+ * @param {Object} defendingGotchi The gotchi defending
170
+ * @param {Number} multiplier The damage multiplier
171
+ * @param {Boolean} ignoreArmor Whether to ignore armor
172
+ * @param {Number} speedPenalty The speed penalty to apply
173
+ * @returns {Number} damage The damage of the attack
174
+ **/
175
+ const getDamage = (attackingTeam, defendingTeam, attackingGotchi, defendingGotchi, multiplier, ignoreArmor, speedPenalty) => {
176
+
177
+ const attackerWithSpeedPenalty = speedPenalty ? applySpeedPenalty(attackingGotchi, speedPenalty) : attackingGotchi
178
+
179
+ // Apply any status effects
180
+ const modifiedAttackingsGotchi = getModifiedStats(attackerWithSpeedPenalty)
181
+ const modifiedDefendingGotchi = getModifiedStats(defendingGotchi)
182
+
183
+ let attackValue = attackingGotchi.attack === 'magic' ? modifiedAttackingsGotchi.magic : modifiedAttackingsGotchi.physical
184
+
185
+ // If attacking gotchi is in the front row and physical attack then apply front row physical attack bonus
186
+ if (getFormationPosition(attackingTeam, defendingTeam, attackingGotchi.id).row === 'front' && attackingGotchi.attack === 'physical') {
187
+ attackValue = Math.round(attackValue * MULTS.FRONT_ROW_PHY_ATK)
188
+ }
189
+
190
+ let defenseValue = attackingGotchi.attack === 'magic' ? modifiedDefendingGotchi.magic : modifiedDefendingGotchi.physical
191
+
192
+ // If defending gotchi is in the front row and the attack is physical then apply front row physical defence penalty
193
+ if (getFormationPosition(attackingTeam, defendingTeam, defendingGotchi.id).row === 'front' && attackingGotchi.attack === 'physical') {
194
+ defenseValue = Math.round(defenseValue * MULTS.FRONT_ROW_PHY_DEF)
195
+ }
196
+
197
+ // Add armor to defense value
198
+ if (!ignoreArmor) defenseValue += modifiedDefendingGotchi.armor
199
+
200
+ // Calculate damage
201
+ let damage = Math.round((attackValue / defenseValue) * 100)
202
+
203
+ // Apply multiplier
204
+ if (multiplier) damage = Math.round(damage * multiplier)
205
+
206
+ // check for environment effects
207
+ if (defendingGotchi.environmentEffects && defendingGotchi.environmentEffects.length > 0) {
208
+ damage = Math.round(damage * (1 + (defendingGotchi.environmentEffects.length * 0.5)))
209
+ }
210
+
211
+ return damage
212
+ }
213
+
214
+ /**
215
+ * Apply status effects to a gotchi
216
+ * @param {Object} gotchi An in-game gotchi object
217
+ * @returns {Object} gotchi An in-game gotchi object with modified stats
218
+ */
219
+ const getModifiedStats = (gotchi) => {
220
+ const statMods = {}
221
+
222
+ gotchi.statuses.forEach(status => {
223
+ const statusStatMods = {}
224
+
225
+ // apply any modifier from BUFF_MULT_EFFECTS
226
+ if (BUFF_MULT_EFFECTS[status]) {
227
+ Object.keys(BUFF_MULT_EFFECTS[status]).forEach(stat => {
228
+ const modifier = Math.round(gotchi[stat] * BUFF_MULT_EFFECTS[status][stat])
229
+
230
+ statusStatMods[stat] = modifier
231
+ })
232
+ }
233
+
234
+ // apply any modifier from BUFF_FLAT_EFFECTS
235
+ if (BUFF_FLAT_EFFECTS[status]) {
236
+ Object.keys(BUFF_FLAT_EFFECTS[status]).forEach(stat => {
237
+ if (statusStatMods[stat]) {
238
+ // If a mod for this status already exists, only add if the new mod is greater
239
+ if (BUFF_FLAT_EFFECTS[status][stat] > statusStatMods[stat]) statusStatMods[stat] = BUFF_FLAT_EFFECTS[status][stat]
240
+ } else {
241
+ statusStatMods[stat] = BUFF_FLAT_EFFECTS[status][stat]
242
+ }
243
+ })
244
+ }
245
+
246
+ // apply any modifier from DEBUFF_MULT_EFFECTS
247
+ if (DEBUFF_MULT_EFFECTS[status]) {
248
+ Object.keys(DEBUFF_MULT_EFFECTS[status]).forEach(stat => {
249
+ const modifier = Math.round(gotchi[stat] * DEBUFF_MULT_EFFECTS[status][stat])
250
+
251
+ statusStatMods[stat] = -modifier
252
+ })
253
+ }
254
+
255
+ // apply any modifier from DEBUFF_FLAT_EFFECTS
256
+ if (DEBUFF_FLAT_EFFECTS[status]) {
257
+ Object.keys(DEBUFF_FLAT_EFFECTS[status]).forEach(stat => {
258
+ if (statusStatMods[stat]) {
259
+ // If a mod for this status already exists, only add if the new mod is greater
260
+ if (DEBUFF_FLAT_EFFECTS[status][stat] < statusStatMods[stat]) statusStatMods[stat] = DEBUFF_FLAT_EFFECTS[status][stat]
261
+ } else {
262
+ statusStatMods[stat] = -DEBUFF_FLAT_EFFECTS[status][stat]
263
+ }
264
+ })
265
+ }
266
+
267
+ // apply status mods
268
+ Object.keys(statusStatMods).forEach(stat => {
269
+ statMods[stat] = statMods[stat] ? statMods[stat] + statusStatMods[stat] : statusStatMods[stat]
270
+ })
271
+ })
272
+
273
+ const modifiedGotchi = {
274
+ ...gotchi
275
+ }
276
+
277
+ // apply stat mods
278
+ Object.keys(statMods).forEach(stat => {
279
+ if (statMods[stat] < 0) {
280
+ modifiedGotchi[stat] = modifiedGotchi[stat] + statMods[stat] < 0 ? 0 : modifiedGotchi[stat] + statMods[stat]
281
+ } else {
282
+ modifiedGotchi[stat] += statMods[stat]
283
+ }
284
+
285
+ })
286
+
287
+ return modifiedGotchi
288
+ }
289
+
290
+ const calculateActionDelay = (gotchi) => {
291
+ // Calculate action delay and round to 3 decimal places
292
+ return Math.round(((100 / getModifiedStats(gotchi).speed) + Number.EPSILON) * 1000) / 1000
293
+ }
294
+
295
+ const getNewActionDelay = (gotchi) => {
296
+ // Calculate new action delay and round to 3 decimal places
297
+ return Math.round((gotchi.actionDelay + calculateActionDelay(gotchi) + Number.EPSILON) * 1000) / 1000
298
+ }
299
+
300
+ /**
301
+ * Simplify a team object for storage
302
+ * @param {Object} team An in-game team object
303
+ * @returns {Object} simplifiedTeam A simplified team object
304
+ */
305
+ const simplifyTeam = (team) => {
306
+ return {
307
+ name: team.name,
308
+ owner: team.owner,
309
+ leaderId: team.leader,
310
+ rows: [
311
+ {
312
+ slots: team.formation.front.map((x) => {
313
+ return {
314
+ isActive: x ? true : false,
315
+ id: x ? x.id : null
316
+ }
317
+ })
318
+ },
319
+ {
320
+ slots: team.formation.back.map((x) => {
321
+ return {
322
+ isActive: x ? true : false,
323
+ id: x ? x.id : null
324
+ }
325
+ })
326
+ }
327
+ ],
328
+ uiOrder: getUiOrder(team)
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Get the UI order of a team (used for the front end)
334
+ * @param {Object} team An in-game team object
335
+ * @returns {Array} uiOrder An array of gotchi ids in the order they should be displayed
336
+ **/
337
+ const getUiOrder = (team) => {
338
+ const uiOrder = []
339
+
340
+ if (team.formation.front[0]) uiOrder.push(team.formation.front[0].id)
341
+ if (team.formation.back[0]) uiOrder.push(team.formation.back[0].id)
342
+ if (team.formation.front[1]) uiOrder.push(team.formation.front[1].id)
343
+ if (team.formation.back[1]) uiOrder.push(team.formation.back[1].id)
344
+ if (team.formation.front[2]) uiOrder.push(team.formation.front[2].id)
345
+ if (team.formation.back[2]) uiOrder.push(team.formation.back[2].id)
346
+ if (team.formation.front[3]) uiOrder.push(team.formation.front[3].id)
347
+ if (team.formation.back[3]) uiOrder.push(team.formation.back[3].id)
348
+ if (team.formation.front[4]) uiOrder.push(team.formation.front[4].id)
349
+ if (team.formation.back[4]) uiOrder.push(team.formation.back[4].id)
350
+
351
+ return uiOrder
352
+ }
353
+
354
+ /**
355
+ * Add the leader statuses to a team
356
+ * @param {Object} team An in-game team object
357
+ **/
358
+ const addLeaderToTeam = (team) => {
359
+ // Add passive leader abilities
360
+ const teamLeader = getLeaderGotchi(team)
361
+
362
+ team.leaderPassive = teamLeader.special.id
363
+
364
+ // Apply leader passive statuses
365
+ switch (team.leaderPassive) {
366
+ case 1:
367
+ // Sharpen blades - all allies gain 'sharp_blades' status
368
+ getAlive(team).forEach(x => {
369
+ x.statuses.push(PASSIVES[team.leaderPassive - 1])
370
+ })
371
+ break
372
+ case 2:
373
+ // Cloud of Zen - Leader get 'cloud_of_zen' status
374
+ teamLeader.statuses.push(PASSIVES[team.leaderPassive - 1])
375
+ break
376
+ case 3:
377
+ // Frenzy - all allies get 'frenzy' status
378
+ getAlive(team).forEach(x => {
379
+ x.statuses.push(PASSIVES[team.leaderPassive - 1])
380
+ })
381
+ break
382
+ case 4:
383
+ // All allies get 'fortify' status
384
+ getAlive(team).forEach(x => {
385
+ x.statuses.push(PASSIVES[team.leaderPassive - 1])
386
+ })
387
+
388
+ break
389
+ case 5:
390
+ // Spread the fear - all allies get 'spread_the_fear' status
391
+ getAlive(team).forEach(x => {
392
+ x.statuses.push(PASSIVES[team.leaderPassive - 1])
393
+ })
394
+ break
395
+ case 6:
396
+ // Cleansing aura - every healer ally and every tank ally gets 'cleansing_aura' status
397
+ getAlive(team).forEach(x => {
398
+ if (x.special.id === 6 || x.special.id === 4) x.statuses.push(PASSIVES[team.leaderPassive - 1])
399
+ })
400
+ break
401
+ case 7:
402
+ // Arcane thunder - every mage ally gets 'arcane_thunder' status
403
+ getAlive(team).forEach(x => {
404
+ if (x.special.id === 7) x.statuses.push(PASSIVES[team.leaderPassive - 1])
405
+ })
406
+ break
407
+ case 8:
408
+ // Clan momentum - every Troll ally gets 'clan_momentum' status
409
+ getAlive(team).forEach(x => {
410
+ if (x.special.id === 8) x.statuses.push(PASSIVES[team.leaderPassive - 1])
411
+ })
412
+ break
413
+ }
414
+ }
415
+
416
+ const removeLeaderPassivesFromTeam = (team) => {
417
+ let statusesRemoved = []
418
+ if (!team.leaderPassive) return statusesRemoved
419
+
420
+ // Remove leader passive statuses from team
421
+ getAlive(team).forEach(x => {
422
+ // add effects for each status removed
423
+ x.statuses.forEach(status => {
424
+ if (status === PASSIVES[team.leaderPassive - 1]) {
425
+ statusesRemoved.push({
426
+ target: x.id,
427
+ status: status
428
+ })
429
+ }
430
+ })
431
+
432
+ x.statuses = x.statuses.filter(x => x !== PASSIVES[team.leaderPassive - 1])
433
+ })
434
+
435
+ team.leaderPassive = null
436
+
437
+ return statusesRemoved
438
+ }
439
+
440
+ const getExpiredStatuses = (team1, team2) => {
441
+ // If leader is dead, remove leader passive
442
+ let statusesExpired = []
443
+ if (team1.leaderPassive && !getAlive(team1).find(x => x.id === team1.leader)) {
444
+ // Remove leader passive statuses
445
+ statusesExpired = removeLeaderPassivesFromTeam(team1)
446
+ }
447
+ if (team2.leaderPassive && !getAlive(team2).find(x => x.id === team2.leader)) {
448
+ // Remove leader passive statuses
449
+ statusesExpired = removeLeaderPassivesFromTeam(team2)
450
+ }
451
+
452
+ return statusesExpired
453
+ }
454
+
455
+ /**
456
+ * Add a status to a gotchi
457
+ * @param {Object} gotchi An in-game gotchi object
458
+ * @param {String} status The status to add
459
+ * @returns {Boolean} success A boolean to determine if the status was added
460
+ **/
461
+ const addStatusToGotchi = (gotchi, status) => {
462
+ // Check that gotchi doesn't already have max number of statuses
463
+ if (gotchi.statuses.filter(item => item === status).length >= MULTS.MAX_STATUSES) return false
464
+
465
+ gotchi.statuses.push(status)
466
+
467
+ return true
468
+ }
469
+
470
+ const scrambleGotchiIds = (allAliveGotchis, team1, team2) => {
471
+ // check there's no duplicate gotchis
472
+ const gotchiIds = allAliveGotchis.map(x => x.id)
473
+
474
+ if (gotchiIds.length !== new Set(gotchiIds).size) {
475
+ // scramble gotchi ids
476
+ allAliveGotchis.forEach(x => {
477
+ const newId = Math.floor(Math.random() * 10000000)
478
+
479
+ // find gotchi in team1 or team2
480
+ const position = getFormationPosition(team1, team2, x.id)
481
+
482
+ // change gotchi id
483
+ if (position) {
484
+ if (position.team === 1) {
485
+ if (x.id === team1.leader) team1.leader = newId
486
+ team1.formation[position.row][position.position].id = newId
487
+ } else {
488
+ if (x.id === team2.leader) team2.leader = newId
489
+ team2.formation[position.row][position.position].id = newId
490
+ }
491
+ } else {
492
+ throw new Error('Gotchi not found in team1 or team2')
493
+ }
494
+ })
495
+
496
+ // check again
497
+ const newGotchiIds = allAliveGotchis.map(x => x.id)
498
+ if (newGotchiIds.length !== new Set(newGotchiIds).size) {
499
+ // Scramble again
500
+ scrambleGotchiIds(allAliveGotchis, team1, team2)
501
+ }
502
+ }
503
+ }
504
+
505
+ /**
506
+ * Prepare teams for battle
507
+ * @param {Array} allAliveGotchis An array of all alive gotchis
508
+ * @param {Object} team1 An in-game team object
509
+ * @param {Object} team2 An in-game team object
510
+ **/
511
+ const prepareTeams = (allAliveGotchis, team1, team2) => {
512
+ // check there's no duplicate gotchis
513
+ scrambleGotchiIds(allAliveGotchis, team1, team2);
514
+
515
+ allAliveGotchis.forEach(x => {
516
+ // Add statuses property to all gotchis
517
+ x.statuses = []
518
+
519
+ // Calculate initial action delay for all gotchis
520
+ x.actionDelay = calculateActionDelay(x)
521
+
522
+ // Calculate attack type
523
+ x.attack = x.magic > x.physical ? 'magic' : 'physical'
524
+
525
+ // Add original stats to all gotchis
526
+ // Do a deep copy of the gotchi object to avoid modifying the original object
527
+ x.originalStats = JSON.parse(JSON.stringify(x))
528
+
529
+ // Add environmentEffects to all gotchis
530
+ x.environmentEffects = []
531
+ })
532
+
533
+ // Add leader passive to team
534
+ addLeaderToTeam(team1)
535
+ addLeaderToTeam(team2);
536
+ }
537
+
538
+ /**
539
+ * Get log gotchi object for battle logs
540
+ * @param {Array} allAliveGotchis An array of all alive gotchis
541
+ * @returns {Array} logGotchis An array of gotchi objects for logs
542
+ */
543
+ const getLogGotchis = (allAliveGotchis) => {
544
+ const logGotchis = JSON.parse(JSON.stringify(allAliveGotchis))
545
+
546
+ logGotchis.forEach(x => {
547
+ // Change gotchi.special.class to gotchi.special.gotchiClass to avoid conflicts with class keyword
548
+ x.special.gotchiClass = x.special.class
549
+
550
+ // Remove unnecessary properties to reduce log size
551
+ delete x.special.class
552
+ delete x.snapshotBlock
553
+ delete x.onchainId
554
+ delete x.brs
555
+ delete x.nrg
556
+ delete x.agg
557
+ delete x.spk
558
+ delete x.brn
559
+ delete x.eyc
560
+ delete x.eys
561
+ delete x.kinship
562
+ delete x.xp
563
+ delete x.actionDelay
564
+ delete x.attack
565
+ delete x.originalStats
566
+ delete x.environmentEffects
567
+ })
568
+
569
+ return logGotchis
570
+ }
571
+
572
+ /**
573
+ * Run a battle between two teams
574
+ * @param {Object} team1 An in-game team object
575
+ * @param {Object} team2 An in-game team object
576
+ * @param {String} seed A seed for the random number generator
577
+ * @param {Boolean} debug A boolean to determine if the logs should include debug information
578
+ * @returns {Object} logs The battle logs
579
+ */
580
+ const gameLoop = (team1, team2, seed, debug) => {
581
+ if (!team1) throw new Error("Team 1 not found")
582
+ if (!team2) throw new Error("Team 2 not found")
583
+ if (!seed) throw new Error("Seed not found")
584
+
585
+ const rng = seedrandom(seed)
586
+
587
+ const allAliveGotchis = [...getAlive(team1), ...getAlive(team2)]
588
+
589
+ prepareTeams(allAliveGotchis, team1, team2)
590
+
591
+ const logs = {
592
+ gotchis: getLogGotchis(allAliveGotchis),
593
+ layout: {
594
+ teams: [
595
+ simplifyTeam(team1),
596
+ simplifyTeam(team2)
597
+ ]
598
+ },
599
+ turns: []
600
+ };
601
+
602
+ // Used for turn by turn health and status summaries
603
+ // Deleted if not in development or no errors
604
+ logs.debug = []
605
+
606
+ let turnCounter = 0
607
+ let draw = false
608
+
609
+ try {
610
+ while (getAlive(team1).length && getAlive(team2).length) {
611
+ // Check if turnCounter is ready for environment effects (99,149,199, etc)
612
+ let isEnvironmentTurn = [99, 149, 199, 249, 299].includes(turnCounter)
613
+ if (isEnvironmentTurn) {
614
+ allAliveGotchis.forEach(x => {
615
+ x.environmentEffects.push('damage_up')
616
+ })
617
+ }
618
+
619
+ const turnLogs = executeTurn(team1, team2, rng)
620
+
621
+ // Check if turnCounter is ready for environment effects (99,149,199, etc)
622
+ if (isEnvironmentTurn) turnLogs.environmentEffects = ['damage_up']
623
+
624
+ if (MULTS.EXPIRE_LEADERSKILL) {
625
+ turnLogs.statusesExpired = [...turnLogs.statusesExpired, ...getExpiredStatuses(team1, team2)]
626
+ }
627
+
628
+ logs.turns.push({index: turnCounter, ...turnLogs})
629
+
630
+ if (debug) {
631
+ logs.debug.push({
632
+ turn: turnCounter,
633
+ user: logs.turns[logs.turns.length - 1].action.user,
634
+ move: logs.turns[logs.turns.length - 1].action.name,
635
+ team1: getAlive(team1).map((x) => {
636
+ return `Id: ${x.id}, Name: ${x.name}, Health: ${x.health}, Statuses: ${x.statuses}`
637
+ }),
638
+ team2: getAlive(team2).map((x) => {
639
+ return `Id: ${x.id}, Name: ${x.name}, Health: ${x.health}, Statuses: ${x.statuses}`
640
+ })
641
+ })
642
+ }
643
+
644
+ turnCounter++
645
+ }
646
+ } catch (e) {
647
+ console.error(e)
648
+ throw new GameError('Game loop failed', logs)
649
+ }
650
+
651
+ if (draw) {
652
+ logs.result = {
653
+ winner: 0,
654
+ loser: 0,
655
+ winningTeam: [],
656
+ numOfTurns: logs.turns.length
657
+ }
658
+ } else {
659
+ logs.result = {
660
+ winner: getAlive(team1).length ? 1 : 2,
661
+ loser: getAlive(team1).length ? 2 : 1,
662
+ winningTeam: getAlive(team1).length ? getAlive(team1) : getAlive(team2),
663
+ numOfTurns: logs.turns.length
664
+ }
665
+
666
+ // trim winning team objects
667
+ logs.result.winningTeam = logs.result.winningTeam.map((gotchi) => {
668
+ return {
669
+ id: gotchi.id,
670
+ name: gotchi.name,
671
+ brs: gotchi.brs,
672
+ health: gotchi.health
673
+ }
674
+ })
675
+ }
676
+
677
+ if (!debug) delete logs.debug
678
+
679
+ return logs
680
+ }
681
+
682
+ /**
683
+ * Attack one or more gotchis. This mutates the defending gotchis health
684
+ * @param {Object} attackingGotchi The attacking gotchi object
685
+ * @param {Array} attackingTeam A team object for the attacking team
686
+ * @param {Array} defendingTeam A team object for the defending team
687
+ * @param {Array} defendingTargets An array of gotchis to attack
688
+ * @param {Function} rng The random number generator
689
+ * @param {Object} options An object of options
690
+ * @param {Boolean} options.ignoreArmor Ignore the defending gotchi's defense
691
+ * @param {Boolean} options.multiplier A multiplier to apply to the damage
692
+ * @param {Boolean} options.statuses An array of status effects to apply
693
+ * @param {Boolean} options.cannotBeEvaded A boolean to determine if the attack can be evaded
694
+ * @param {Boolean} options.cannotBeResisted A boolean to determine if the attack can be resisted
695
+ * @param {Boolean} options.cannotBeCountered A boolean to determine if the attack can be countered
696
+ * @param {Boolean} options.inflictPassiveStatuses A boolean to determine if passive statuses should be inflicted
697
+ * @returns {Array} effects An array of effects to apply
698
+ */
699
+ const attack = (attackingGotchi, attackingTeam, defendingTeam, defendingTargets, rng, options = {
700
+ ignoreArmor: false,
701
+ multiplier: 1,
702
+ statuses: [],
703
+ cannotBeEvaded: false,
704
+ critCannotBeEvaded: false,
705
+ cannotBeResisted: false,
706
+ cannotBeCountered: false,
707
+ inflictPassiveStatuses: true,
708
+ speedPenalty: 0,
709
+ noResistSpeedPenalty: false
710
+ }) => {
711
+ const effects = []
712
+ if (!options.ignoreArmor) options.ignoreArmor = false
713
+ if (!options.multiplier) options.multiplier = 1
714
+ if (!options.statuses) options.statuses = []
715
+ if (!options.cannotBeEvaded) options.cannotBeEvaded = false
716
+ if (!options.critCannotBeEvaded) options.critCannotBeEvaded = false
717
+ if (!options.cannotBeResisted) options.cannotBeResisted = false
718
+ if (!options.cannotBeCountered) options.cannotBeCountered = false
719
+ if (!options.inflictPassiveStatuses) options.inflictPassiveStatuses = false
720
+ if (!options.speedPenalty) options.speedPenalty = 0
721
+ if (!options.noResistSpeedPenalty) options.noResistSpeedPenalty = false
722
+
723
+ // If inflictPassiveStatuses then add leaderPassive status effects to attackingGotchi
724
+ if (options.inflictPassiveStatuses) {
725
+ // If attacking gotchi has 'sharp_blades' status, add 'bleed' to statuses
726
+ if (attackingGotchi.statuses.includes('sharp_blades')) {
727
+ if (rng() < MULTS.SHARP_BLADES_BLEED_CHANCE) options.statuses.push('bleed')
728
+ }
729
+
730
+ // If attacking gotchi has 'spread_the_fear' status, add 'fear' to statuses
731
+ if (attackingGotchi.statuses.includes('spread_the_fear')) {
732
+ // Reduce the chance to spread the fear if attacking gotchi has speed over 100
733
+ const spreadTheFearChance = attackingGotchi.speed > 100 ? MULTS.SPREAD_THE_FEAR_CHANCE - MULTS.SPREAD_THE_FEAR_SPEED_PENALTY : MULTS.SPREAD_THE_FEAR_CHANCE
734
+ if (rng() < spreadTheFearChance) options.statuses.push('fear')
735
+ }
736
+ }
737
+
738
+ defendingTargets.forEach((defendingGotchi) => {
739
+ // Check attacking gotchi hasn't been killed by a counter
740
+ if (attackingGotchi.health <= 0) return
741
+
742
+ const modifiedAttackingGotchi = getModifiedStats(attackingGotchi)
743
+ const modifiedDefendingGotchi = getModifiedStats(defendingGotchi)
744
+
745
+ // Check for crit
746
+ const isCrit = rng() < modifiedAttackingGotchi.crit / 100
747
+ if (isCrit) {
748
+ // Apply different crit multipliers for -nrg and +nrg gotchis
749
+ if (attackingGotchi.speed <= 100) {
750
+ options.multiplier *= MULTS.CRIT_MULTIPLIER_SLOW
751
+ } else {
752
+ options.multiplier *= MULTS.CRIT_MULTIPLIER_FAST
753
+ }
754
+ }
755
+
756
+ let canEvade = true
757
+ if (options.cannotBeEvaded) canEvade = false
758
+ if (isCrit && options.critCannotBeEvaded) canEvade = false
759
+
760
+ const damage = getDamage(attackingTeam, defendingTeam, attackingGotchi, defendingGotchi, options.multiplier, options.ignoreArmor, options.speedPenalty)
761
+
762
+ let effect = {
763
+ target: defendingGotchi.id,
764
+ }
765
+
766
+ // Check for miss
767
+ if (rng() > modifiedAttackingGotchi.accuracy / 100) {
768
+ effect.outcome = 'miss'
769
+ effects.push(effect)
770
+ } else if (canEvade && rng() < modifiedDefendingGotchi.evade / 100){
771
+ effect.outcome = 'evade'
772
+ effects.push(effect)
773
+ } else {
774
+ if (!options.cannotBeResisted) {
775
+ // Check for status effect from the move
776
+ options.statuses.forEach((status) => {
777
+ if (rng() > modifiedDefendingGotchi.resist / 100) {
778
+ // Attempt to add status to defending gotchi
779
+ if (addStatusToGotchi(defendingGotchi, status)) {
780
+ // If status added, add to effect
781
+ if (!effect.statuses) {
782
+ effect.statuses = [status]
783
+ } else {
784
+ effect.statuses.push(status)
785
+ }
786
+ }
787
+ }
788
+ })
789
+ }
790
+
791
+ // Handle damage
792
+ defendingGotchi.health -= damage
793
+ effect.damage = damage
794
+ effect.outcome = isCrit ? 'critical' : 'success'
795
+ effects.push(effect)
796
+
797
+ // Check for counter attack
798
+ if (
799
+ defendingGotchi.statuses.includes('taunt')
800
+ && defendingGotchi.health > 0
801
+ && !options.cannotBeCountered) {
802
+
803
+ // Chance to counter based on speed over 100
804
+ let chanceToCounter = defendingGotchi.speed - 100
805
+
806
+ if (chanceToCounter < MULTS.COUNTER_CHANCE_MIN) chanceToCounter = MULTS.COUNTER_CHANCE_MIN
807
+
808
+ // Add a higher chance to counter if gotchi has 'fortify' status
809
+ if (defendingGotchi.statuses.includes('fortify')) chanceToCounter += MULTS.FORTIFY_COUNTER_CHANCE
810
+
811
+ if (rng() < chanceToCounter / 100) {
812
+ const counterDamage = getDamage(defendingTeam, attackingTeam, defendingGotchi, attackingGotchi, MULTS.COUNTER_DAMAGE, false, 0)
813
+
814
+ attackingGotchi.health -= counterDamage
815
+
816
+ effects.push({
817
+ target: attackingGotchi.id,
818
+ source: defendingGotchi.id,
819
+ damage: counterDamage,
820
+ outcome: 'counter'
821
+ })
822
+ }
823
+ }
824
+ }
825
+ })
826
+
827
+ return effects
828
+ }
829
+
830
+ // Deal with start of turn status effects
831
+ const handleStatusEffects = (attackingGotchi, attackingTeam, defendingTeam, rng) => {
832
+ const statusEffects = []
833
+ const passiveEffects = []
834
+
835
+ const modifiedAttackingGotchi = getModifiedStats(attackingGotchi)
836
+
837
+ // Check for cleansing_aura
838
+ // if (attackingGotchi.statuses.includes('cleansing_aura')) {
839
+ // // Remove all debuffs from all allies
840
+ // const aliveAllies = getAlive(attackingTeam)
841
+ // aliveAllies.forEach((ally) => {
842
+ // ally.statuses.forEach((status) => {
843
+ // if (DEBUFFS.includes(status)) {
844
+ // passiveEffects.push({
845
+ // source: attackingGotchi.id,
846
+ // target: ally.id,
847
+ // status,
848
+ // damage: 0,
849
+ // remove: true
850
+ // })
851
+ // }
852
+ // })
853
+
854
+ // // Remove status effects
855
+ // ally.statuses = ally.statuses.filter((status) => !DEBUFFS.includes(status))
856
+ // })
857
+ // }
858
+
859
+ // Check for global status effects
860
+ const allAliveGotchis = [...getAlive(attackingTeam), ...getAlive(defendingTeam)]
861
+
862
+ allAliveGotchis.forEach((gotchi) => {
863
+ if (gotchi.statuses && gotchi.statuses.length) {
864
+ gotchi.statuses.forEach((status) => {
865
+ // Handle cleansing_aura (health regen)
866
+ if (status === 'cleansing_aura') {
867
+ let amountToHeal
868
+
869
+ // Check if healer
870
+ if (gotchi.special.id === 6) {
871
+ amountToHeal = Math.round(gotchi.resist * MULTS.CLEANSING_AURA_REGEN)
872
+ } else {
873
+ amountToHeal = MULTS.CLEANSING_AURA_NON_HEALER_REGEN
874
+ }
875
+
876
+ // Don't allow amountToHeal to be more than the difference between current health and max health
877
+ if (amountToHeal > gotchi.originalStats.health - gotchi.health) {
878
+ amountToHeal = gotchi.originalStats.health - gotchi.health
879
+ }
880
+
881
+ // if amountToHeal > 0, add status effect
882
+ if (amountToHeal) {
883
+ // Add status effect
884
+ statusEffects.push({
885
+ target: gotchi.id,
886
+ status,
887
+ damage: -Math.abs(amountToHeal),
888
+ remove: false
889
+ })
890
+
891
+ gotchi.health += amountToHeal
892
+ }
893
+ }
894
+
895
+ /*
896
+ * Handle damage effect at the bottom of the loop
897
+ */
898
+
899
+ // Handle bleed
900
+ if (status === 'bleed') {
901
+ let damage = MULTS.BLEED_DAMAGE
902
+
903
+ gotchi.health -= damage
904
+ if (gotchi.health <= 0) gotchi.health = 0
905
+
906
+ // Add status effect
907
+ statusEffects.push({
908
+ target: gotchi.id,
909
+ status,
910
+ damage,
911
+ remove: false
912
+ })
913
+ }
914
+ })
915
+ }
916
+ })
917
+
918
+ let skipTurn = null
919
+
920
+ // Check if gotchi is dead
921
+ if (attackingGotchi.health <= 0) {
922
+ return {
923
+ statusEffects,
924
+ passiveEffects,
925
+ skipTurn: 'ATTACKER_DEAD'
926
+ }
927
+ }
928
+
929
+ // Check if a whole team is dead
930
+ if (getAlive(attackingTeam).length === 0 || getAlive(defendingTeam).length === 0) {
931
+ return {
932
+ statusEffects,
933
+ passiveEffects,
934
+ skipTurn: 'TEAM_DEAD'
935
+ }
936
+ }
937
+
938
+ // Check for turn skipping statuses
939
+ for (let i = 0; i < attackingGotchi.statuses.length; i++) {
940
+ const status = attackingGotchi.statuses[i]
941
+ // Fear - skip turn
942
+ if (status === 'fear') {
943
+ // Skip turn
944
+ statusEffects.push({
945
+ target: attackingGotchi.id,
946
+ status,
947
+ damage: 0,
948
+ remove: true
949
+ })
950
+
951
+ skipTurn = 'FEAR'
952
+
953
+ // Remove fear first instance of fear
954
+ attackingGotchi.statuses.splice(i, 1)
955
+
956
+ break
957
+ }
958
+
959
+ // Stun
960
+ if (status === 'stun') {
961
+ // Skip turn
962
+ statusEffects.push({
963
+ target: attackingGotchi.id,
964
+ status,
965
+ damage: 0,
966
+ remove: true
967
+ })
968
+
969
+ skipTurn = 'STUN'
970
+
971
+ // Remove first instance of stun
972
+ attackingGotchi.statuses.splice(i, 1)
973
+
974
+ break
975
+ }
976
+ }
977
+
978
+ return {
979
+ statusEffects,
980
+ passiveEffects,
981
+ skipTurn
982
+ }
983
+ }
984
+
985
+ const executeTurn = (team1, team2, rng) => {
986
+ const nextToAct = getNextToAct(team1, team2, rng)
987
+
988
+ const attackingTeam = nextToAct.team === 1 ? team1 : team2
989
+ const defendingTeam = nextToAct.team === 1 ? team2 : team1
990
+
991
+ const attackingGotchi = attackingTeam.formation[nextToAct.row][nextToAct.position]
992
+
993
+ let { statusEffects, passiveEffects, skipTurn } = handleStatusEffects(attackingGotchi, attackingTeam, defendingTeam, rng)
994
+ let statusesExpired = []
995
+
996
+ let effects = []
997
+ if (skipTurn) {
998
+ // Increase actionDelay
999
+ attackingGotchi.actionDelay = getNewActionDelay(attackingGotchi)
1000
+
1001
+ return {
1002
+ skipTurn,
1003
+ action: {
1004
+ user: attackingGotchi.id,
1005
+ name: 'auto',
1006
+ effects
1007
+ },
1008
+ passiveEffects,
1009
+ statusEffects,
1010
+ statusesExpired
1011
+ }
1012
+ }
1013
+
1014
+ let specialDone = false
1015
+ // Check if special attack is ready
1016
+ if (attackingGotchi.special.cooldown === 0) {
1017
+ // TODO: Check if special attack should be used
1018
+
1019
+ // Execute special attack
1020
+ const specialResults = specialAttack(attackingGotchi, attackingTeam, defendingTeam, rng)
1021
+
1022
+ effects = specialResults.effects
1023
+ statusesExpired = specialResults.statusesExpired
1024
+
1025
+ // Reset cooldown
1026
+ attackingGotchi.special.cooldown = 2
1027
+
1028
+ if (specialResults.specialNotDone) {
1029
+ // Do nothing which will lead to an auto attack
1030
+ } else {
1031
+ specialDone = true
1032
+ }
1033
+
1034
+ } else {
1035
+ // Decrease cooldown
1036
+ attackingGotchi.special.cooldown--
1037
+ }
1038
+
1039
+ if (!specialDone) {
1040
+ // Do an auto attack
1041
+ const target = getTarget(defendingTeam, rng)
1042
+
1043
+ effects = attack(attackingGotchi, attackingTeam, defendingTeam, [target], rng)
1044
+ }
1045
+
1046
+ // Increase actionDelay
1047
+ attackingGotchi.actionDelay = getNewActionDelay(attackingGotchi)
1048
+
1049
+ return {
1050
+ skipTurn,
1051
+ action: {
1052
+ user: attackingGotchi.id,
1053
+ name: specialDone ? attackingGotchi.special.name : 'auto',
1054
+ effects
1055
+ },
1056
+ passiveEffects,
1057
+ statusEffects,
1058
+ statusesExpired
1059
+ }
1060
+ }
1061
+
1062
+ /**
1063
+ * Execute a special attack
1064
+ * @param {Object} attackingGotchi The attacking gotchi object
1065
+ * @param {Array} attackingTeam An array of gotchis to attack
1066
+ * @param {Array} defendingTeam An array of gotchis to attack
1067
+ * @param {Function} rng The random number generator
1068
+ * @returns {Array} effects An array of effects to apply
1069
+ **/
1070
+ const specialAttack = (attackingGotchi, attackingTeam, defendingTeam, rng) => {
1071
+ const specialId = attackingGotchi.special.id
1072
+ let effects = []
1073
+ let statusesExpired = []
1074
+ let specialNotDone = false
1075
+
1076
+ const modifiedAttackingGotchi = getModifiedStats(attackingGotchi)
1077
+
1078
+ switch (specialId) {
1079
+ case 1:
1080
+ // Spectral Strike - ignore armor and appply bleed status
1081
+ // get single target
1082
+ const ssTarget = getTarget(defendingTeam, rng)
1083
+
1084
+ effects = attack(attackingGotchi, attackingTeam, defendingTeam, [ssTarget], rng, {
1085
+ multiplier: MULTS.SPECTRAL_STRIKE_DAMAGE,
1086
+ ignoreArmor: true,
1087
+ statuses: ['bleed'],
1088
+ cannotBeCountered: true,
1089
+ cannotBeEvaded: true,
1090
+ inflictPassiveStatuses: false,
1091
+ noResistSpeedPenalty: true
1092
+ })
1093
+ break
1094
+ case 2:
1095
+ // Meditate - Boost own speed, magic, physical by 30%
1096
+ // If gotchi already has 2 power_up statuses, do nothing
1097
+ if (!addStatusToGotchi(attackingGotchi, 'power_up_2')) {
1098
+ specialNotDone = true
1099
+ break
1100
+ }
1101
+
1102
+ effects = [
1103
+ {
1104
+ target: attackingGotchi.id,
1105
+ outcome: 'success',
1106
+ statuses: ['power_up_2']
1107
+ }
1108
+ ]
1109
+
1110
+ // Check for leaderPassive 'Cloud of Zen'
1111
+ if (attackingGotchi.statuses.includes(PASSIVES[specialId - 1])) {
1112
+ // Increase allies speed, magic and physical by 15% of the original value
1113
+
1114
+ const cloudOfZenGotchis = getAlive(attackingTeam)
1115
+
1116
+ cloudOfZenGotchis.forEach((gotchi) => {
1117
+ if (addStatusToGotchi(gotchi, 'power_up_1')) {
1118
+ effects.push({
1119
+ target: gotchi.id,
1120
+ outcome: 'success',
1121
+ statuses: ['power_up_1']
1122
+ })
1123
+ }
1124
+ })
1125
+ }
1126
+
1127
+ break
1128
+ case 3:
1129
+ // Cleave - attack all enemies in a row (that have the most gotchis) for 75% damage
1130
+ // Find row with most gotchis
1131
+ const cleaveRow = getAlive(defendingTeam, 'front').length > getAlive(defendingTeam, 'back').length ? 'front' : 'back'
1132
+
1133
+ // Attack all gotchis in that row for 75% damage
1134
+ effects = attack(attackingGotchi, attackingTeam, defendingTeam, getAlive(defendingTeam, cleaveRow), rng, {
1135
+ multiplier: MULTS.CLEAVE_DAMAGE,
1136
+ cannotBeCountered: true,
1137
+ inflictPassiveStatuses: false
1138
+ })
1139
+ break
1140
+ case 4:
1141
+ // Taunt - add taunt status to self
1142
+
1143
+ // Check if gotchi already has taunt status
1144
+ if (attackingGotchi.statuses.includes('taunt')) {
1145
+ specialNotDone = true
1146
+ break
1147
+ }
1148
+
1149
+ if (!addStatusToGotchi(attackingGotchi, 'taunt')) {
1150
+ specialNotDone = true
1151
+ break
1152
+ }
1153
+
1154
+ effects = [
1155
+ {
1156
+ target: attackingGotchi.id,
1157
+ outcome: 'success',
1158
+ statuses: ['taunt']
1159
+ }
1160
+ ]
1161
+ break
1162
+ case 5:
1163
+ // Curse - attack random enemy for 50% damage, apply fear status and remove all buffs
1164
+
1165
+ const curseTarget = getTarget(defendingTeam, rng)
1166
+
1167
+ const curseTargetStatuses = ['fear']
1168
+
1169
+ effects = attack(attackingGotchi, attackingTeam, defendingTeam, [curseTarget], rng, {
1170
+ multiplier: MULTS.CURSE_DAMAGE,
1171
+ statuses: curseTargetStatuses,
1172
+ cannotBeCountered: true,
1173
+ inflictPassiveStatuses: false,
1174
+ speedPenalty: MULTS.CURSE_SPEED_PENALTY,
1175
+ noResistSpeedPenalty: true
1176
+ })
1177
+
1178
+ const removeRandomBuff = (target) => {
1179
+ const modifiedTarget = getModifiedStats(target)
1180
+
1181
+ if (rng() > modifiedTarget.resist / 100) {
1182
+ const buffsToRemove = target.statuses.filter((status) => BUFFS.includes(status))
1183
+
1184
+ if (buffsToRemove.length) {
1185
+ const randomBuff = buffsToRemove[Math.floor(rng() * buffsToRemove.length)]
1186
+ statusesExpired.push({
1187
+ target: target.id,
1188
+ status: randomBuff
1189
+ })
1190
+
1191
+ // Remove first instance of randomBuff (there may be multiple)
1192
+ const index = target.statuses.indexOf(randomBuff)
1193
+ target.statuses.splice(index, 1)
1194
+ }
1195
+ }
1196
+ }
1197
+
1198
+ if (effects[0] && effects[0].outcome === 'success') {
1199
+ // 1 chance to remove a random buff
1200
+ removeRandomBuff(curseTarget)
1201
+
1202
+ } else if (effects[0] && effects[0].outcome === 'critical') {
1203
+ // 2 chances to remove a random buff
1204
+ removeRandomBuff(curseTarget)
1205
+ removeRandomBuff(curseTarget)
1206
+ }
1207
+
1208
+ break
1209
+ case 6:
1210
+ // Blessing - Heal all non-healer allies and remove all debuffs
1211
+
1212
+ // Get all alive non-healer allies on the attacking team
1213
+ const gotchisToHeal = getAlive(attackingTeam).filter(x => x.special.id !== 6)
1214
+
1215
+ // Heal all allies for multiple of healers resistance
1216
+ gotchisToHeal.forEach((gotchi) => {
1217
+ let amountToHeal
1218
+
1219
+ // If gotchi has 'cleansing_aura' status, increase heal amount
1220
+ if (attackingGotchi.statuses.includes('cleansing_aura')) {
1221
+ amountToHeal = Math.round(modifiedAttackingGotchi.resist * MULTS.CLEANSING_AURA_HEAL)
1222
+ } else {
1223
+ amountToHeal = Math.round(modifiedAttackingGotchi.resist * MULTS.BLESSING_HEAL)
1224
+ }
1225
+
1226
+ // Check for crit
1227
+ const isCrit = rng() < modifiedAttackingGotchi.crit / 100
1228
+ if (isCrit) {
1229
+ amountToHeal = Math.round(amountToHeal * MULTS.BLESSING_HEAL_CRIT_MULTIPLIER)
1230
+ }
1231
+
1232
+ // Apply speed penalty
1233
+ const speedPenalty = (modifiedAttackingGotchi.speed - 100) * MULTS.BLESSING_HEAL_SPEED_PENALTY
1234
+ if (speedPenalty > 0) amountToHeal -= speedPenalty
1235
+
1236
+ // Don't allow amountToHeal to be more than the difference between current health and max health
1237
+ if (amountToHeal > gotchi.originalStats.health - gotchi.health) {
1238
+ amountToHeal = gotchi.originalStats.health - gotchi.health
1239
+ }
1240
+
1241
+ gotchi.health += amountToHeal
1242
+
1243
+ if (amountToHeal) {
1244
+ effects.push({
1245
+ target: gotchi.id,
1246
+ outcome: isCrit ? 'critical' : 'success',
1247
+ damage: -Math.abs(amountToHeal)
1248
+ })
1249
+ }
1250
+
1251
+ // Remove all debuffs
1252
+ // Add removed debuffs to statusesExpired
1253
+ gotchi.statuses.forEach((status) => {
1254
+ if (DEBUFFS.includes(status)) {
1255
+ statusesExpired.push({
1256
+ target: gotchi.id,
1257
+ status
1258
+ })
1259
+ }
1260
+ })
1261
+
1262
+ // Remove all debuffs from gotchi
1263
+ gotchi.statuses = gotchi.statuses.filter((status) => !DEBUFFS.includes(status))
1264
+ })
1265
+
1266
+ // If no allies have been healed and no debuffs removed, then special attack not done
1267
+ if (!effects.length && !statusesExpired.length) {
1268
+ specialNotDone = true
1269
+ break
1270
+ }
1271
+
1272
+ break
1273
+ case 7:
1274
+ // Thunder - Attack all enemies for 50% damage and apply stun status
1275
+
1276
+ const thunderTargets = getAlive(defendingTeam)
1277
+
1278
+ // Check if leader passive is 'arcane_thunder' then apply stun status
1279
+ if (attackingGotchi.statuses.includes(PASSIVES[specialId - 1])) {
1280
+ const stunStatuses = ['stun']
1281
+
1282
+ effects = attack(attackingGotchi, attackingTeam, defendingTeam, thunderTargets, rng, {
1283
+ multiplier: modifiedAttackingGotchi.speed > 100 ? MULTS.CHANNEL_THE_COVEN_DAMAGE_FAST : MULTS.CHANNEL_THE_COVEN_DAMAGE_SLOW,
1284
+ statuses: stunStatuses,
1285
+ cannotBeCountered: true,
1286
+ inflictPassiveStatuses: false
1287
+ })
1288
+ } else {
1289
+ effects = attack(attackingGotchi, attackingTeam, defendingTeam, thunderTargets, rng, {
1290
+ multiplier: modifiedAttackingGotchi.speed > 100 ? MULTS.THUNDER_DAMAGE_FAST : MULTS.THUNDER_DAMAGE_SLOW,
1291
+ cannotBeCountered: true,
1292
+ inflictPassiveStatuses: false
1293
+ })
1294
+ }
1295
+
1296
+ break
1297
+ case 8:
1298
+ // Devestating Smash - Attack random enemy for 200% damage
1299
+
1300
+ const smashTarget = getTarget(defendingTeam, rng)
1301
+
1302
+ effects = attack(attackingGotchi, attackingTeam, defendingTeam, [smashTarget], rng, {
1303
+ multiplier: MULTS.DEVESTATING_SMASH_DAMAGE,
1304
+ cannotBeCountered: true,
1305
+ inflictPassiveStatuses: false
1306
+ })
1307
+
1308
+ // If crit then attack again
1309
+ if (effects[0].outcome === 'critical') {
1310
+ const aliveEnemies = getAlive(defendingTeam)
1311
+
1312
+ if (aliveEnemies.length) {
1313
+ const target = getTarget(defendingTeam, rng)
1314
+
1315
+ effects.push(...attack(attackingGotchi, attackingTeam, defendingTeam, [target], rng, {
1316
+ multiplier: MULTS.DEVESTATING_SMASH_DAMAGE,
1317
+ cannotBeCountered: true,
1318
+ inflictPassiveStatuses: false
1319
+ }))
1320
+ }
1321
+ }
1322
+
1323
+ // If leader passive is 'Clan momentum', attack again
1324
+ if (attackingGotchi.statuses.includes(PASSIVES[specialId - 1])) {
1325
+ // Check if any enemies are alive
1326
+ const aliveEnemies = getAlive(defendingTeam)
1327
+
1328
+ if (aliveEnemies.length) {
1329
+ // Do an extra devestating smash
1330
+ const target = getTarget(defendingTeam, rng)
1331
+
1332
+ effects.push(...attack(attackingGotchi, attackingTeam, defendingTeam, [target], rng, {
1333
+ multiplier: MULTS.CLAN_MOMENTUM_DAMAGE,
1334
+ cannotBeCountered: true,
1335
+ inflictPassiveStatuses: false
1336
+ }))
1337
+ }
1338
+ }
1339
+
1340
+ break
1341
+ }
1342
+
1343
+ return {
1344
+ effects,
1345
+ statusesExpired,
1346
+ specialNotDone
1347
+ }
1348
+ }
1349
+
1350
+ module.exports = {
1351
+ getFormationPosition,
1352
+ getModifiedStats,
1353
+ gameLoop
1354
+ }