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