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