gotchi-battler-game-logic 2.0.7 → 2.0.8

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