gotchi-battler-game-logic 2.0.7 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) 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 +7 -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 +610 -593
  13. package/game-logic/v1.7/index.js +802 -795
  14. package/game-logic/v1.8/constants.js +135 -0
  15. package/game-logic/v1.8/helpers.js +628 -0
  16. package/game-logic/v1.8/index.js +826 -0
  17. package/index.js +12 -12
  18. package/package.json +26 -26
  19. package/schemas/team.json +349 -343
  20. package/scripts/balancing/createCSV.js +126 -126
  21. package/scripts/balancing/createTrainingGotchis.js +267 -0
  22. package/scripts/balancing/extractOnchainTraits.js +61 -0
  23. package/scripts/balancing/fixTrainingGotchis.js +155 -259
  24. package/scripts/balancing/processSims.js +229 -229
  25. package/scripts/balancing/sims.js +278 -278
  26. package/scripts/balancing/v1.7/class_combos.js +43 -43
  27. package/scripts/balancing/v1.7/mapGotchi.js +119 -0
  28. package/scripts/balancing/v1.7/setTeamPositions.js +105 -105
  29. package/scripts/balancing/v1.7/training_gotchis.json +20161 -20161
  30. package/scripts/balancing/v1.7/training_gotchis_traits.json +520 -0
  31. package/scripts/balancing/v1.7/trait_combos.json +9 -9
  32. package/scripts/balancing/v1.7.1/class_combos.js +43 -43
  33. package/scripts/balancing/v1.7.1/mapGotchi.js +119 -0
  34. package/scripts/balancing/v1.7.1/setTeamPositions.js +122 -122
  35. package/scripts/balancing/v1.7.1/training_gotchis.json +22401 -22401
  36. package/scripts/balancing/v1.7.1/training_gotchis_traits.json +520 -0
  37. package/scripts/balancing/v1.7.1/trait_combos.json +9 -9
  38. package/scripts/balancing/v1.7.2/class_combos.js +44 -0
  39. package/scripts/balancing/v1.7.2/mapGotchi.js +157 -0
  40. package/scripts/balancing/v1.7.2/setTeamPositions.js +122 -0
  41. package/scripts/balancing/v1.7.2/training_gotchis.json +22402 -0
  42. package/scripts/balancing/v1.7.2/training_gotchis_traits.json +520 -0
  43. package/scripts/balancing/v1.7.2/trait_combos.json +10 -0
  44. package/scripts/balancing/v1.7.3/class_combos.js +44 -0
  45. package/scripts/balancing/v1.7.3/mapGotchi.js +164 -0
  46. package/scripts/balancing/v1.7.3/setTeamPositions.js +122 -0
  47. package/scripts/balancing/v1.7.3/training_gotchis.json +22402 -0
  48. package/scripts/balancing/v1.7.3/training_gotchis_traits.json +37 -0
  49. package/scripts/balancing/v1.7.3/trait_combos.json +10 -0
  50. package/scripts/data/team1.json +213 -213
  51. package/scripts/data/team2.json +200 -200
  52. package/scripts/data/tournaments.json +71 -66
  53. package/scripts/{runBattle.js → runLocalBattle.js} +18 -18
  54. package/scripts/runRealBattle.js +52 -0
  55. package/scripts/simRealBattle.js +121 -0
  56. package/scripts/validateBattle.js +83 -70
  57. package/scripts/validateTournament.js +101 -101
  58. package/utils/contracts.js +12 -12
  59. package/utils/errors.js +29 -29
  60. package/utils/mapGotchi.js +119 -0
  61. package/utils/transforms.js +89 -88
  62. package/utils/validations.js +39 -39
  63. package/debug.log +0 -2
  64. package/game-logic/v1.6/debug.log +0 -1
  65. package/game-logic/v1.7/debug.log +0 -3
  66. package/scripts/data/debug.log +0 -2
  67. package/scripts/debug.log +0 -1
@@ -1,796 +1,803 @@
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
- const {
9
- PASSIVES,
10
- DEBUFFS,
11
- BUFFS,
12
- MULTS
13
- } = require('./constants')
14
-
15
- const {
16
- getAlive,
17
- getNextToAct,
18
- getTarget,
19
- getDamage,
20
- getModifiedStats,
21
- getNewActionDelay,
22
- simplifyTeam,
23
- getExpiredStatuses,
24
- addStatusToGotchi,
25
- prepareTeams,
26
- getLogGotchis
27
- } = require('./helpers')
28
-
29
- /**
30
- * Run a battle between two teams
31
- * @param {Object} team1 An in-game team object
32
- * @param {Object} team2 An in-game team object
33
- * @param {String} seed A seed for the random number generator
34
- * @param {Boolean} debug A boolean to determine if the logs should include debug information
35
- * @returns {Object} logs The battle logs
36
- */
37
- const gameLoop = (team1, team2, seed, debug) => {
38
- if (!team1) throw new Error("Team 1 not found")
39
- if (!team2) throw new Error("Team 2 not found")
40
- if (!seed) throw new Error("Seed not found")
41
-
42
- // Validate team objects
43
- const team1Validation = validator.validate(team1, teamSchema)
44
- if (!team1Validation) {
45
- console.error('Team 1 validation failed: ', JSON.stringify(validator.getLastErrors(), null, 2))
46
- throw new Error(`Team 1 validation failed`)
47
- }
48
- const team2Validation = validator.validate(team2, teamSchema)
49
- if (!team2Validation) {
50
- console.error('Team 2 validation failed: ', JSON.stringify(validator.getLastErrors(), null, 2))
51
- throw new Error(`Team 2 validation failed`)
52
- }
53
-
54
- // Make deep copy of team objects to avoid modifying the original objects
55
- team1 = JSON.parse(JSON.stringify(team1))
56
- team2 = JSON.parse(JSON.stringify(team2))
57
-
58
- const rng = seedrandom(seed)
59
-
60
- const allAliveGotchis = [...getAlive(team1), ...getAlive(team2)]
61
-
62
- prepareTeams(allAliveGotchis, team1, team2)
63
-
64
- const logs = {
65
- gotchis: getLogGotchis(allAliveGotchis),
66
- layout: {
67
- teams: [
68
- simplifyTeam(team1),
69
- simplifyTeam(team2)
70
- ]
71
- },
72
- turns: []
73
- };
74
-
75
- // Used for turn by turn health and status summaries
76
- // Deleted if not in development or no errors
77
- logs.debug = []
78
-
79
- let turnCounter = 0
80
- let draw = false
81
-
82
- try {
83
- while (getAlive(team1).length && getAlive(team2).length) {
84
- // Check if turnCounter is ready for environment effects (99,149,199, etc)
85
- let isEnvironmentTurn = [99, 149, 199, 249, 299].includes(turnCounter)
86
- if (isEnvironmentTurn) {
87
- allAliveGotchis.forEach(x => {
88
- x.environmentEffects.push('damage_up')
89
- })
90
- }
91
-
92
- const turnLogs = executeTurn(team1, team2, rng)
93
-
94
- // Check if turnCounter is ready for environment effects (99,149,199, etc)
95
- if (isEnvironmentTurn) turnLogs.environmentEffects = ['damage_up']
96
-
97
- if (MULTS.EXPIRE_LEADERSKILL) {
98
- turnLogs.statusesExpired = [...turnLogs.statusesExpired, ...getExpiredStatuses(team1, team2)]
99
- }
100
-
101
- logs.turns.push({index: turnCounter, ...turnLogs})
102
-
103
- if (debug) {
104
- logs.debug.push({
105
- turn: turnCounter,
106
- user: logs.turns[logs.turns.length - 1].action.user,
107
- move: logs.turns[logs.turns.length - 1].action.name,
108
- team1: getAlive(team1).map((x) => {
109
- return `Id: ${x.id}, Name: ${x.name}, Health: ${x.health}, Statuses: ${x.statuses}`
110
- }),
111
- team2: getAlive(team2).map((x) => {
112
- return `Id: ${x.id}, Name: ${x.name}, Health: ${x.health}, Statuses: ${x.statuses}`
113
- })
114
- })
115
- }
116
-
117
- turnCounter++
118
- }
119
- } catch (e) {
120
- console.error(e)
121
- throw new GameError('Game loop failed', logs)
122
- }
123
-
124
- if (draw) {
125
- logs.result = {
126
- winner: 0,
127
- loser: 0,
128
- winningTeam: [],
129
- numOfTurns: logs.turns.length
130
- }
131
- } else {
132
- logs.result = {
133
- winner: getAlive(team1).length ? 1 : 2,
134
- loser: getAlive(team1).length ? 2 : 1,
135
- winningTeam: getAlive(team1).length ? getAlive(team1) : getAlive(team2),
136
- numOfTurns: logs.turns.length
137
- }
138
-
139
- // trim winning team objects
140
- logs.result.winningTeam = logs.result.winningTeam.map((gotchi) => {
141
- return {
142
- id: gotchi.id,
143
- name: gotchi.name,
144
- brs: gotchi.brs,
145
- health: gotchi.health
146
- }
147
- })
148
- }
149
-
150
- if (!debug) delete logs.debug
151
-
152
- return logs
153
- }
154
-
155
- /**
156
- * Attack one or more gotchis. This mutates the defending gotchis health
157
- * @param {Object} attackingGotchi The attacking gotchi object
158
- * @param {Array} attackingTeam A team object for the attacking team
159
- * @param {Array} defendingTeam A team object for the defending team
160
- * @param {Array} defendingTargets An array of gotchis to attack
161
- * @param {Function} rng The random number generator
162
- * @param {Object} options An object of options
163
- * @param {Boolean} options.ignoreArmor Ignore the defending gotchi's defense
164
- * @param {Boolean} options.multiplier A multiplier to apply to the damage
165
- * @param {Boolean} options.statuses An array of status effects to apply
166
- * @param {Boolean} options.cannotBeEvaded A boolean to determine if the attack can be evaded
167
- * @param {Boolean} options.cannotBeResisted A boolean to determine if the attack can be resisted
168
- * @param {Boolean} options.cannotBeCountered A boolean to determine if the attack can be countered
169
- * @param {Boolean} options.noPassiveStatuses A boolean to determine if passive statuses should be inflicted
170
- * @param {Number} options.critMultiplier Override the crit multiplier
171
- * @returns {Array} effects An array of effects to apply
172
- */
173
- const attack = (attackingGotchi, attackingTeam, defendingTeam, defendingTargets, rng, options) => {
174
- if (!options) options = {}
175
- if (!options.ignoreArmor) options.ignoreArmor = false
176
- if (!options.multiplier) options.multiplier = 1
177
- if (!options.statuses) options.statuses = []
178
- if (!options.cannotBeEvaded) options.cannotBeEvaded = false
179
- if (!options.critCannotBeEvaded) options.critCannotBeEvaded = false
180
- if (!options.cannotBeResisted) options.cannotBeResisted = false
181
- if (!options.cannotBeCountered) options.cannotBeCountered = false
182
- if (!options.noPassiveStatuses) options.noPassiveStatuses = false
183
- if (!options.speedPenalty) options.speedPenalty = 0
184
- if (!options.noResistSpeedPenalty) options.noResistSpeedPenalty = false
185
- if (!options.critMultiplier) options.critMultiplier = null
186
-
187
- // If passive statuses are allowed then add leaderPassive status effects to attackingGotchi
188
- if (!options.noPassiveStatuses) {
189
- // If attacking gotchi has 'sharp_blades' status, add 'bleed' to statuses
190
- if (attackingGotchi.statuses.includes('sharp_blades')) {
191
- if (rng() < MULTS.SHARP_BLADES_BLEED_CHANCE) options.statuses.push('bleed')
192
- }
193
-
194
- // If attacking gotchi has 'spread_the_fear' status, add 'fear' to statuses
195
- if (attackingGotchi.statuses.includes('spread_the_fear')) {
196
- // Reduce the chance to spread the fear if attacking gotchi has speed over 100
197
- const spreadTheFearChance = attackingGotchi.speed > 100 ? MULTS.SPREAD_THE_FEAR_CHANCE - MULTS.SPREAD_THE_FEAR_SPEED_PENALTY : MULTS.SPREAD_THE_FEAR_CHANCE
198
- if (rng() < spreadTheFearChance) options.statuses.push('fear')
199
- }
200
- }
201
-
202
- const effects = []
203
-
204
- defendingTargets.forEach((defendingGotchi) => {
205
- // Check attacking gotchi hasn't been killed by a counter
206
- if (attackingGotchi.health <= 0) return
207
-
208
- const modifiedAttackingGotchi = getModifiedStats(attackingGotchi)
209
- const modifiedDefendingGotchi = getModifiedStats(defendingGotchi)
210
-
211
- // Check for crit
212
- const isCrit = rng() < modifiedAttackingGotchi.crit / 100
213
- if (isCrit) {
214
- if (options.critMultiplier) {
215
- options.multiplier *= options.critMultiplier
216
- } else {
217
- // Apply different crit multipliers for -nrg and +nrg gotchis
218
- if (attackingGotchi.speed <= 100) {
219
- options.multiplier *= MULTS.CRIT_MULTIPLIER_SLOW
220
- } else {
221
- options.multiplier *= MULTS.CRIT_MULTIPLIER_FAST
222
- }
223
- }
224
- }
225
-
226
- let canEvade = true
227
- if (options.cannotBeEvaded) canEvade = false
228
- if (isCrit && options.critCannotBeEvaded) canEvade = false
229
-
230
- const damage = getDamage(attackingTeam, defendingTeam, attackingGotchi, defendingGotchi, options.multiplier, options.ignoreArmor, options.speedPenalty)
231
-
232
- let effect = {
233
- target: defendingGotchi.id,
234
- }
235
-
236
- // Check for miss
237
- if (rng() > modifiedAttackingGotchi.accuracy / 100) {
238
- effect.outcome = 'miss'
239
- effects.push(effect)
240
- } else if (canEvade && rng() < modifiedDefendingGotchi.evade / 100){
241
- effect.outcome = 'evade'
242
- effects.push(effect)
243
- } else {
244
- if (!options.cannotBeResisted) {
245
- // Check for status effect from the move
246
- options.statuses.forEach((status) => {
247
- if (rng() > modifiedDefendingGotchi.resist / 100) {
248
- // Attempt to add status to defending gotchi
249
- if (addStatusToGotchi(defendingGotchi, status)) {
250
- // If status added, add to effect
251
- if (!effect.statuses) {
252
- effect.statuses = [status]
253
- } else {
254
- effect.statuses.push(status)
255
- }
256
- }
257
- }
258
- })
259
- }
260
-
261
- // Handle damage
262
- defendingGotchi.health -= damage
263
- effect.damage = damage
264
- effect.outcome = isCrit ? 'critical' : 'success'
265
- effects.push(effect)
266
-
267
- // Check for counter attack
268
- if (
269
- defendingGotchi.statuses.includes('taunt')
270
- && defendingGotchi.health > 0
271
- && !options.cannotBeCountered) {
272
-
273
- // Chance to counter based on speed over 100
274
- let chanceToCounter = modifiedDefendingGotchi.speed - 100
275
-
276
- if (chanceToCounter < MULTS.COUNTER_CHANCE_MIN) chanceToCounter = MULTS.COUNTER_CHANCE_MIN
277
-
278
- // Add chance if gotchi has fortify status
279
- if (defendingGotchi.statuses.includes('fortify')) {
280
- chanceToCounter += MULTS.FORTIFY_COUNTER_CHANCE
281
- }
282
-
283
- if (rng() < chanceToCounter / 100) {
284
- const counterDamage = getDamage(defendingTeam, attackingTeam, defendingGotchi, attackingGotchi, MULTS.COUNTER_DAMAGE, false, 0)
285
-
286
- attackingGotchi.health -= counterDamage
287
-
288
- effects.push({
289
- target: attackingGotchi.id,
290
- source: defendingGotchi.id,
291
- damage: counterDamage,
292
- outcome: 'counter'
293
- })
294
- }
295
- }
296
- }
297
- })
298
-
299
- return effects
300
- }
301
-
302
- // Deal with start of turn status effects
303
- const handleStatusEffects = (attackingGotchi, attackingTeam, defendingTeam, rng) => {
304
- const statusEffects = []
305
- const passiveEffects = []
306
-
307
- const modifiedAttackingGotchi = getModifiedStats(attackingGotchi)
308
-
309
- // Check for global status effects
310
- const allAliveGotchis = [...getAlive(attackingTeam), ...getAlive(defendingTeam)]
311
-
312
- allAliveGotchis.forEach((gotchi) => {
313
- const modifiedGotchi = getModifiedStats(gotchi)
314
- if (gotchi.statuses && gotchi.statuses.length) {
315
- gotchi.statuses.forEach((status) => {
316
- // Handle cleansing_aura (health regen)
317
- if (status === 'cleansing_aura') {
318
- let amountToHeal
319
-
320
- // Check if healer
321
- if (gotchi.special.id === 6) {
322
- amountToHeal = Math.round(modifiedGotchi.resist * MULTS.CLEANSING_AURA_REGEN)
323
- } else {
324
- amountToHeal = MULTS.CLEANSING_AURA_NON_HEALER_REGEN
325
- }
326
-
327
- // Don't allow amountToHeal to be more than the difference between current health and max health
328
- if (amountToHeal > gotchi.originalStats.health - gotchi.health) {
329
- amountToHeal = gotchi.originalStats.health - gotchi.health
330
- }
331
-
332
- // if amountToHeal > 0, add status effect
333
- if (amountToHeal) {
334
- // Add status effect
335
- statusEffects.push({
336
- target: gotchi.id,
337
- status,
338
- damage: -Math.abs(amountToHeal),
339
- remove: false
340
- })
341
-
342
- gotchi.health += amountToHeal
343
- }
344
- }
345
-
346
- /*
347
- * Handle damage effect at the bottom of the loop
348
- */
349
-
350
- // Handle bleed
351
- if (status === 'bleed') {
352
- let damage = MULTS.BLEED_DAMAGE
353
-
354
- gotchi.health -= damage
355
- if (gotchi.health <= 0) gotchi.health = 0
356
-
357
- // Add status effect
358
- statusEffects.push({
359
- target: gotchi.id,
360
- status,
361
- damage,
362
- remove: false
363
- })
364
- }
365
- })
366
- }
367
- })
368
-
369
- let skipTurn = null
370
-
371
- // Check if gotchi is dead
372
- if (attackingGotchi.health <= 0) {
373
- return {
374
- statusEffects,
375
- passiveEffects,
376
- skipTurn: 'ATTACKER_DEAD'
377
- }
378
- }
379
-
380
- // Check if a whole team is dead
381
- if (getAlive(attackingTeam).length === 0 || getAlive(defendingTeam).length === 0) {
382
- return {
383
- statusEffects,
384
- passiveEffects,
385
- skipTurn: 'TEAM_DEAD'
386
- }
387
- }
388
-
389
- // Check for turn skipping statuses
390
- for (let i = 0; i < attackingGotchi.statuses.length; i++) {
391
- const status = attackingGotchi.statuses[i]
392
- // Fear - skip turn
393
- if (status === 'fear') {
394
- // Skip turn
395
- statusEffects.push({
396
- target: attackingGotchi.id,
397
- status,
398
- damage: 0,
399
- remove: true
400
- })
401
-
402
- skipTurn = 'FEAR'
403
-
404
- // Remove fear first instance of fear
405
- attackingGotchi.statuses.splice(i, 1)
406
-
407
- break
408
- }
409
- }
410
-
411
- return {
412
- statusEffects,
413
- passiveEffects,
414
- skipTurn
415
- }
416
- }
417
-
418
- const executeTurn = (team1, team2, rng) => {
419
- const nextToAct = getNextToAct(team1, team2, rng)
420
-
421
- const attackingTeam = nextToAct.team === 1 ? team1 : team2
422
- const defendingTeam = nextToAct.team === 1 ? team2 : team1
423
-
424
- const attackingGotchi = attackingTeam.formation[nextToAct.row][nextToAct.position]
425
-
426
- let { statusEffects, passiveEffects, skipTurn } = handleStatusEffects(attackingGotchi, attackingTeam, defendingTeam, rng)
427
- let statusesExpired = []
428
-
429
- let effects = []
430
- if (skipTurn) {
431
- // Increase actionDelay
432
- attackingGotchi.actionDelay = getNewActionDelay(attackingGotchi)
433
-
434
- return {
435
- skipTurn,
436
- action: {
437
- user: attackingGotchi.id,
438
- name: 'auto',
439
- effects
440
- },
441
- passiveEffects,
442
- statusEffects,
443
- statusesExpired
444
- }
445
- }
446
-
447
- let specialDone = false
448
- // Check if special attack is ready
449
- if (attackingGotchi.special.cooldown === 0) {
450
- // Execute special attack
451
- const specialResults = specialAttack(attackingGotchi, attackingTeam, defendingTeam, rng)
452
-
453
- if (specialResults.specialNotDone) {
454
- // Do nothing which will lead to an auto attack
455
- } else {
456
- specialDone = true
457
-
458
- effects = specialResults.effects
459
- statusesExpired = specialResults.statusesExpired
460
-
461
- // Reset cooldown
462
- attackingGotchi.special.cooldown = 2
463
- }
464
-
465
- } else {
466
- // Decrease cooldown
467
- attackingGotchi.special.cooldown--
468
- }
469
-
470
- if (!specialDone) {
471
- // Do an auto attack
472
- const target = getTarget(defendingTeam, rng)
473
-
474
- effects = attack(attackingGotchi, attackingTeam, defendingTeam, [target], rng)
475
- }
476
-
477
- // Increase actionDelay
478
- attackingGotchi.actionDelay = getNewActionDelay(attackingGotchi)
479
-
480
- return {
481
- skipTurn,
482
- action: {
483
- user: attackingGotchi.id,
484
- name: specialDone ? attackingGotchi.special.name : 'auto',
485
- effects
486
- },
487
- passiveEffects,
488
- statusEffects,
489
- statusesExpired
490
- }
491
- }
492
-
493
- /**
494
- * Execute a special attack
495
- * @param {Object} attackingGotchi The attacking gotchi object
496
- * @param {Array} attackingTeam An array of gotchis to attack
497
- * @param {Array} defendingTeam An array of gotchis to attack
498
- * @param {Function} rng The random number generator
499
- * @returns {Array} effects An array of effects to apply
500
- **/
501
- const specialAttack = (attackingGotchi, attackingTeam, defendingTeam, rng) => {
502
- const specialId = attackingGotchi.special.id
503
- let effects = []
504
- let statusesExpired = []
505
- let specialNotDone = false
506
-
507
- const modifiedAttackingGotchi = getModifiedStats(attackingGotchi)
508
-
509
- switch (specialId) {
510
- case 1:
511
- // Spectral Strike - ignore armor and appply bleed status
512
- // get single target
513
- const ssTarget = getTarget(defendingTeam, rng)
514
-
515
- effects = attack(attackingGotchi, attackingTeam, defendingTeam, [ssTarget], rng, {
516
- multiplier: MULTS.SPECTRAL_STRIKE_DAMAGE,
517
- ignoreArmor: true,
518
- statuses: ['bleed'],
519
- cannotBeCountered: true,
520
- cannotBeEvaded: true,
521
- noPassiveStatuses: true,
522
- noResistSpeedPenalty: true
523
- })
524
- break
525
- case 2:
526
- // Meditate - Boost own speed, magic, physical by 30%
527
- // If gotchi already has 2 power_up statuses, do nothing
528
- if (!addStatusToGotchi(attackingGotchi, 'power_up_2')) {
529
- specialNotDone = true
530
- break
531
- }
532
-
533
- effects = [
534
- {
535
- target: attackingGotchi.id,
536
- outcome: 'success',
537
- statuses: ['power_up_2']
538
- }
539
- ]
540
-
541
- // Check for leaderPassive 'Cloud of Zen'
542
- if (attackingGotchi.statuses.includes(PASSIVES[specialId - 1])) {
543
- // Increase allies speed, magic and physical by 15% of the original value
544
-
545
- const cloudOfZenGotchis = getAlive(attackingTeam)
546
-
547
- cloudOfZenGotchis.forEach((gotchi) => {
548
- if (addStatusToGotchi(gotchi, 'power_up_1')) {
549
- effects.push({
550
- target: gotchi.id,
551
- outcome: 'success',
552
- statuses: ['power_up_1']
553
- })
554
- }
555
- })
556
- }
557
-
558
- break
559
- case 3:
560
- // Cleave - attack all enemies in a row (that have the most gotchis) for 75% damage
561
- // Find row with most gotchis
562
- const cleaveRow = getAlive(defendingTeam, 'front').length > getAlive(defendingTeam, 'back').length ? 'front' : 'back'
563
-
564
- // Attack all gotchis in that row for 75% damage
565
- effects = attack(attackingGotchi, attackingTeam, defendingTeam, getAlive(defendingTeam, cleaveRow), rng, {
566
- multiplier: MULTS.CLEAVE_DAMAGE,
567
- cannotBeCountered: true,
568
- noPassiveStatuses: true
569
- })
570
- break
571
- case 4:
572
- // Taunt - add taunt status to self
573
-
574
- // Check if gotchi already has taunt status
575
- if (attackingGotchi.statuses.includes('taunt')) {
576
- specialNotDone = true
577
- break
578
- }
579
-
580
- if (!addStatusToGotchi(attackingGotchi, 'taunt')) {
581
- specialNotDone = true
582
- break
583
- }
584
-
585
- effects = [
586
- {
587
- target: attackingGotchi.id,
588
- outcome: 'success',
589
- statuses: ['taunt']
590
- }
591
- ]
592
- break
593
- case 5:
594
- // Curse - attack random enemy for 50% damage, apply fear status and remove all buffs
595
-
596
- const curseTarget = getTarget(defendingTeam, rng)
597
-
598
- const curseTargetStatuses = ['fear']
599
-
600
- effects = attack(attackingGotchi, attackingTeam, defendingTeam, [curseTarget], rng, {
601
- multiplier: MULTS.CURSE_DAMAGE,
602
- statuses: curseTargetStatuses,
603
- cannotBeCountered: true,
604
- noPassiveStatuses: true,
605
- speedPenalty: MULTS.CURSE_SPEED_PENALTY,
606
- noResistSpeedPenalty: true
607
- })
608
-
609
- const removeRandomBuff = (target) => {
610
- const modifiedTarget = getModifiedStats(target)
611
-
612
- if (rng() > modifiedTarget.resist / 100) {
613
- const buffsToRemove = target.statuses.filter((status) => BUFFS.includes(status))
614
-
615
- if (buffsToRemove.length) {
616
- const randomBuff = buffsToRemove[Math.floor(rng() * buffsToRemove.length)]
617
- statusesExpired.push({
618
- target: target.id,
619
- status: randomBuff
620
- })
621
-
622
- // Remove first instance of randomBuff (there may be multiple)
623
- const index = target.statuses.indexOf(randomBuff)
624
- target.statuses.splice(index, 1)
625
- }
626
- }
627
- }
628
-
629
- if (effects[0] && (effects[0].outcome === 'success' || effects[0].outcome === 'critical')) {
630
- // 1 chance to remove a random buff
631
- removeRandomBuff(curseTarget)
632
-
633
- if (effects[0].outcome === 'critical') {
634
- // 2 chances to remove a random buff
635
- removeRandomBuff(curseTarget)
636
- }
637
-
638
- // heal attacking gotchi for % of damage dealt
639
- let amountToHeal = Math.round(effects[0].damage * MULTS.CURSE_HEAL)
640
-
641
- // Don't allow amountToHeal to be more than the difference between current health and max health
642
- if (amountToHeal > attackingGotchi.originalStats.health - attackingGotchi.health) {
643
- amountToHeal = attackingGotchi.originalStats.health - attackingGotchi.health
644
- }
645
-
646
- if (amountToHeal) {
647
- attackingGotchi.health += amountToHeal
648
-
649
- effects.push({
650
- target: attackingGotchi.id,
651
- outcome: effects[0].outcome,
652
- damage: -Math.abs(amountToHeal)
653
- })
654
- }
655
- }
656
-
657
- break
658
- case 6:
659
- // Blessing - Heal all non-healer allies and remove all debuffs
660
-
661
- // Get all alive non-healer allies on the attacking team
662
- // const gotchisToHeal = getAlive(attackingTeam).filter(x => x.special.id !== 6)
663
- const gotchisToHeal = getAlive(attackingTeam)
664
-
665
- // Heal all allies for multiple of healers resistance
666
- gotchisToHeal.forEach((gotchi) => {
667
- let amountToHeal
668
-
669
- // If gotchi has 'cleansing_aura' status, increase heal amount
670
- if (attackingGotchi.statuses.includes('cleansing_aura')) {
671
- amountToHeal = Math.round(modifiedAttackingGotchi.resist * MULTS.CLEANSING_AURA_HEAL)
672
- } else {
673
- amountToHeal = Math.round(modifiedAttackingGotchi.resist * MULTS.BLESSING_HEAL)
674
- }
675
-
676
- // Check for crit
677
- const isCrit = rng() < modifiedAttackingGotchi.crit / 100
678
- if (isCrit) {
679
- amountToHeal = Math.round(amountToHeal * MULTS.BLESSING_HEAL_CRIT_MULTIPLIER)
680
- }
681
-
682
- // Apply speed penalty
683
- let speedPenalty
684
- if (attackingGotchi.statuses.includes('cleansing_aura')) {
685
- speedPenalty = Math.round((modifiedAttackingGotchi.speed - 100) * MULTS.CLEANSING_AURA_HEAL_SPEED_PENALTY)
686
- } else {
687
- speedPenalty = Math.round((modifiedAttackingGotchi.speed - 100) * MULTS.BLESSING_HEAL_SPEED_PENALTY)
688
- }
689
- if (speedPenalty > 0) amountToHeal -= speedPenalty
690
-
691
- // Don't allow amountToHeal to be more than the difference between current health and max health
692
- if (amountToHeal > gotchi.originalStats.health - gotchi.health) {
693
- amountToHeal = gotchi.originalStats.health - gotchi.health
694
- }
695
-
696
- gotchi.health += amountToHeal
697
-
698
- if (amountToHeal) {
699
- effects.push({
700
- target: gotchi.id,
701
- outcome: isCrit ? 'critical' : 'success',
702
- damage: -Math.abs(amountToHeal)
703
- })
704
- }
705
-
706
- // Remove all debuffs
707
- // Add removed debuffs to statusesExpired
708
- gotchi.statuses.forEach((status) => {
709
- if (DEBUFFS.includes(status)) {
710
- statusesExpired.push({
711
- target: gotchi.id,
712
- status
713
- })
714
- }
715
- })
716
-
717
- // Remove all debuffs from gotchi
718
- gotchi.statuses = gotchi.statuses.filter((status) => !DEBUFFS.includes(status))
719
- })
720
-
721
- // If no allies have been healed and no debuffs removed, then special attack not done
722
- if (!effects.length && !statusesExpired.length) {
723
- specialNotDone = true
724
- break
725
- }
726
-
727
- break
728
- case 7:
729
- // Thunder - Attack all enemies for 50% damage and apply stun status
730
-
731
- const thunderTargets = getAlive(defendingTeam)
732
-
733
- let stunStatuses = []
734
- // Check if leader passive is 'channel_the_coven' then apply stun status
735
- if (attackingGotchi.statuses.includes(PASSIVES[specialId - 1])) {
736
- if (rng() < MULTS.CHANNEL_THE_COVEN_STUN_CHANCE) stunStatuses.push('stun')
737
- } else {
738
- if (rng() < MULTS.THUNDER_STUN_CHANCE) stunStatuses.push('stun')
739
- }
740
-
741
- effects = attack(attackingGotchi, attackingTeam, defendingTeam, thunderTargets, rng, {
742
- multiplier: MULTS.THUNDER_DAMAGE,
743
- statuses: stunStatuses,
744
- cannotBeCountered: true,
745
- noPassiveStatuses: true,
746
- critMultiplier: MULTS.THUNDER_CRIT_MULTIPLIER
747
- })
748
-
749
- break
750
- case 8:
751
- // Devestating Smash - Attack random enemy for 200% damage
752
-
753
- const smashTarget = getTarget(defendingTeam, rng)
754
-
755
- effects = attack(attackingGotchi, attackingTeam, defendingTeam, [smashTarget], rng, {
756
- multiplier: MULTS.DEVESTATING_SMASH_DAMAGE,
757
- cannotBeCountered: true,
758
- noPassiveStatuses: true
759
- })
760
-
761
- let anotherAttack = false
762
- if (attackingGotchi.statuses.includes(PASSIVES[specialId - 1])) {
763
- if (rng() < MULTS.CLAN_MOMENTUM_CHANCE) anotherAttack = true
764
- } else {
765
- if (rng() < MULTS.DEVESTATING_SMASH_X2_CHANCE) anotherAttack = true
766
- }
767
-
768
- if (anotherAttack) {
769
- // Check if any enemies are alive
770
- const aliveEnemies = getAlive(defendingTeam)
771
-
772
- if (aliveEnemies.length) {
773
- // Do an extra devestating smash
774
- const target = getTarget(defendingTeam, rng)
775
-
776
- effects.push(...attack(attackingGotchi, attackingTeam, defendingTeam, [target], rng, {
777
- multiplier: MULTS.DEVESTATING_SMASH_X2_DAMAGE,
778
- cannotBeCountered: true,
779
- noPassiveStatuses: true
780
- }))
781
- }
782
- }
783
-
784
- break
785
- }
786
-
787
- return {
788
- effects,
789
- statusesExpired,
790
- specialNotDone
791
- }
792
- }
793
-
794
- module.exports = {
795
- 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
+ const {
9
+ PASSIVES,
10
+ DEBUFFS,
11
+ BUFFS,
12
+ MULTS
13
+ } = require('./constants')
14
+
15
+ const {
16
+ getAlive,
17
+ getNextToAct,
18
+ getTarget,
19
+ getDamage,
20
+ getModifiedStats,
21
+ getNewActionDelay,
22
+ simplifyTeam,
23
+ getExpiredStatuses,
24
+ addStatusToGotchi,
25
+ prepareTeams,
26
+ getLogGotchis
27
+ } = require('./helpers')
28
+
29
+ /**
30
+ * Run a battle between two teams
31
+ * @param {Object} team1 An in-game team object
32
+ * @param {Object} team2 An in-game team object
33
+ * @param {String} seed A seed for the random number generator
34
+ * @param {Boolean} debug A boolean to determine if the logs should include debug information
35
+ * @returns {Object} logs The battle logs
36
+ */
37
+ const gameLoop = (team1, team2, seed, debug) => {
38
+ if (!team1) throw new Error("Team 1 not found")
39
+ if (!team2) throw new Error("Team 2 not found")
40
+ if (!seed) throw new Error("Seed not found")
41
+
42
+ // Validate team objects
43
+ const team1Validation = validator.validate(team1, teamSchema)
44
+ if (!team1Validation) {
45
+ console.error('Team 1 validation failed: ', JSON.stringify(validator.getLastErrors(), null, 2))
46
+ throw new Error(`Team 1 validation failed`)
47
+ }
48
+ const team2Validation = validator.validate(team2, teamSchema)
49
+ if (!team2Validation) {
50
+ console.error('Team 2 validation failed: ', JSON.stringify(validator.getLastErrors(), null, 2))
51
+ throw new Error(`Team 2 validation failed`)
52
+ }
53
+
54
+ // Make deep copy of team objects to avoid modifying the original objects
55
+ team1 = JSON.parse(JSON.stringify(team1))
56
+ team2 = JSON.parse(JSON.stringify(team2))
57
+
58
+ const rng = seedrandom(seed)
59
+
60
+ const allAliveGotchis = [...getAlive(team1), ...getAlive(team2)]
61
+
62
+ prepareTeams(allAliveGotchis, team1, team2)
63
+
64
+ const logs = {
65
+ gotchis: getLogGotchis(allAliveGotchis),
66
+ layout: {
67
+ teams: [
68
+ simplifyTeam(team1),
69
+ simplifyTeam(team2)
70
+ ]
71
+ },
72
+ turns: []
73
+ };
74
+
75
+ // Used for turn by turn health and status summaries
76
+ // Deleted if not in development or no errors
77
+ logs.debug = []
78
+
79
+ let turnCounter = 0
80
+ let draw = false
81
+
82
+ try {
83
+ while (getAlive(team1).length && getAlive(team2).length) {
84
+ // Check if turnCounter is ready for environment effects (99,149,199, etc)
85
+ let isEnvironmentTurn = [99, 149, 199, 249, 299].includes(turnCounter)
86
+ if (isEnvironmentTurn) {
87
+ allAliveGotchis.forEach(x => {
88
+ x.environmentEffects.push('damage_up')
89
+ })
90
+ }
91
+
92
+ const turnLogs = executeTurn(team1, team2, rng)
93
+
94
+ // Check if turnCounter is ready for environment effects (99,149,199, etc)
95
+ if (isEnvironmentTurn) turnLogs.environmentEffects = ['damage_up']
96
+
97
+ if (MULTS.EXPIRE_LEADERSKILL) {
98
+ turnLogs.statusesExpired = [...turnLogs.statusesExpired, ...getExpiredStatuses(team1, team2)]
99
+ }
100
+
101
+ logs.turns.push({index: turnCounter, ...turnLogs})
102
+
103
+ if (debug) {
104
+ logs.debug.push({
105
+ turn: turnCounter,
106
+ user: logs.turns[logs.turns.length - 1].action.user,
107
+ move: logs.turns[logs.turns.length - 1].action.name,
108
+ team1: getAlive(team1).map((x) => {
109
+ return `Id: ${x.id}, Name: ${x.name}, Health: ${x.health}, Statuses: ${x.statuses}`
110
+ }),
111
+ team2: getAlive(team2).map((x) => {
112
+ return `Id: ${x.id}, Name: ${x.name}, Health: ${x.health}, Statuses: ${x.statuses}`
113
+ })
114
+ })
115
+ }
116
+
117
+ turnCounter++
118
+ }
119
+ } catch (e) {
120
+ console.error(e)
121
+ throw new GameError('Game loop failed', logs)
122
+ }
123
+
124
+ if (draw) {
125
+ logs.result = {
126
+ winner: 0,
127
+ loser: 0,
128
+ winningTeam: [],
129
+ numOfTurns: logs.turns.length
130
+ }
131
+ } else {
132
+ logs.result = {
133
+ winner: getAlive(team1).length ? 1 : 2,
134
+ loser: getAlive(team1).length ? 2 : 1,
135
+ winningTeam: getAlive(team1).length ? getAlive(team1) : getAlive(team2),
136
+ numOfTurns: logs.turns.length
137
+ }
138
+
139
+ // trim winning team objects
140
+ logs.result.winningTeam = logs.result.winningTeam.map((gotchi) => {
141
+ return {
142
+ id: gotchi.id,
143
+ name: gotchi.name,
144
+ brs: gotchi.brs,
145
+ health: gotchi.health
146
+ }
147
+ })
148
+ }
149
+
150
+ if (!debug) delete logs.debug
151
+
152
+ return logs
153
+ }
154
+
155
+ /**
156
+ * Attack one or more gotchis. This mutates the defending gotchis health
157
+ * @param {Object} attackingGotchi The attacking gotchi object
158
+ * @param {Array} attackingTeam A team object for the attacking team
159
+ * @param {Array} defendingTeam A team object for the defending team
160
+ * @param {Array} defendingTargets An array of gotchis to attack
161
+ * @param {Function} rng The random number generator
162
+ * @param {Object} options An object of options
163
+ * @param {Boolean} options.ignoreArmor Ignore the defending gotchi's defense
164
+ * @param {Boolean} options.multiplier A multiplier to apply to the damage
165
+ * @param {Boolean} options.statuses An array of status effects to apply
166
+ * @param {Boolean} options.cannotBeEvaded A boolean to determine if the attack can be evaded
167
+ * @param {Boolean} options.cannotBeResisted A boolean to determine if the attack can be resisted
168
+ * @param {Boolean} options.cannotBeCountered A boolean to determine if the attack can be countered
169
+ * @param {Boolean} options.noPassiveStatuses A boolean to determine if passive statuses should be inflicted
170
+ * @param {Number} options.critMultiplier Override the crit multiplier
171
+ * @returns {Array} effects An array of effects to apply
172
+ */
173
+ const attack = (attackingGotchi, attackingTeam, defendingTeam, defendingTargets, rng, options) => {
174
+ if (!options) options = {}
175
+ if (!options.ignoreArmor) options.ignoreArmor = false
176
+ if (!options.multiplier) options.multiplier = 1
177
+ if (!options.statuses) options.statuses = []
178
+ if (!options.cannotBeEvaded) options.cannotBeEvaded = false
179
+ if (!options.critCannotBeEvaded) options.critCannotBeEvaded = false
180
+ if (!options.cannotBeResisted) options.cannotBeResisted = false
181
+ if (!options.cannotBeCountered) options.cannotBeCountered = false
182
+ if (!options.noPassiveStatuses) options.noPassiveStatuses = false
183
+ if (!options.speedPenalty) options.speedPenalty = 0
184
+ if (!options.noResistSpeedPenalty) options.noResistSpeedPenalty = false
185
+ if (!options.critMultiplier) options.critMultiplier = null
186
+
187
+ // If passive statuses are allowed then add leaderPassive status effects to attackingGotchi
188
+ if (!options.noPassiveStatuses) {
189
+ // If attacking gotchi has 'sharp_blades' status, add 'bleed' to statuses
190
+ if (attackingGotchi.statuses.includes('sharp_blades')) {
191
+ if (rng() < MULTS.SHARP_BLADES_BLEED_CHANCE) options.statuses.push('bleed')
192
+ }
193
+
194
+ // If attacking gotchi has 'spread_the_fear' status, add 'fear' to statuses
195
+ if (attackingGotchi.statuses.includes('spread_the_fear')) {
196
+ // Reduce the chance to spread the fear if attacking gotchi has speed over 100
197
+ const spreadTheFearChance = attackingGotchi.speed > 100 ? MULTS.SPREAD_THE_FEAR_CHANCE - MULTS.SPREAD_THE_FEAR_SPEED_PENALTY : MULTS.SPREAD_THE_FEAR_CHANCE
198
+ if (rng() < spreadTheFearChance) options.statuses.push('fear')
199
+ }
200
+ }
201
+
202
+ const effects = []
203
+
204
+ // Calculate what the multiplier should be if the attack is a crit
205
+ let critMultiplier = options.multiplier
206
+ if (options.critMultiplier) {
207
+ critMultiplier *= options.critMultiplier
208
+ } else {
209
+ // Apply different crit multipliers for -nrg and +nrg gotchis
210
+ if (attackingGotchi.speed <= 100) {
211
+ critMultiplier *= MULTS.CRIT_MULTIPLIER_SLOW
212
+ } else {
213
+ critMultiplier *= MULTS.CRIT_MULTIPLIER_FAST
214
+ }
215
+ }
216
+
217
+ defendingTargets.forEach((defendingGotchi) => {
218
+ // Check attacking gotchi hasn't been killed by a counter
219
+ if (attackingGotchi.health <= 0) return
220
+
221
+ const modifiedAttackingGotchi = getModifiedStats(attackingGotchi)
222
+ const modifiedDefendingGotchi = getModifiedStats(defendingGotchi)
223
+
224
+ // Check for crit
225
+ let damageMultiplier = options.multiplier
226
+ const isCrit = rng() < modifiedAttackingGotchi.crit / 100
227
+ if (isCrit) {
228
+ damageMultiplier = critMultiplier
229
+ }
230
+
231
+ let canEvade = true
232
+ if (options.cannotBeEvaded) canEvade = false
233
+ if (isCrit && options.critCannotBeEvaded) canEvade = false
234
+
235
+ const damage = getDamage(attackingTeam, defendingTeam, attackingGotchi, defendingGotchi, damageMultiplier, options.ignoreArmor, options.speedPenalty)
236
+
237
+ let effect = {
238
+ target: defendingGotchi.id,
239
+ }
240
+
241
+ // Check for miss
242
+ if (rng() > modifiedAttackingGotchi.accuracy / 100) {
243
+ effect.outcome = 'miss'
244
+ effects.push(effect)
245
+ } else if (canEvade && rng() < modifiedDefendingGotchi.evade / 100){
246
+ effect.outcome = 'evade'
247
+ effects.push(effect)
248
+ } else {
249
+ if (!options.cannotBeResisted) {
250
+ // Check for status effect from the move
251
+ options.statuses.forEach((status) => {
252
+ if (rng() > modifiedDefendingGotchi.resist / 100) {
253
+ // Attempt to add status to defending gotchi
254
+ if (addStatusToGotchi(defendingGotchi, status)) {
255
+ // If status added, add to effect
256
+ if (!effect.statuses) {
257
+ effect.statuses = [status]
258
+ } else {
259
+ effect.statuses.push(status)
260
+ }
261
+ }
262
+ }
263
+ })
264
+ }
265
+
266
+ // Handle damage
267
+ defendingGotchi.health -= damage
268
+ effect.damage = damage
269
+ effect.outcome = isCrit ? 'critical' : 'success'
270
+ effects.push(effect)
271
+
272
+ // Check for counter attack
273
+ if (
274
+ defendingGotchi.statuses.includes('taunt')
275
+ && defendingGotchi.health > 0
276
+ && !options.cannotBeCountered) {
277
+
278
+ // Chance to counter based on speed over 100
279
+ let chanceToCounter = modifiedDefendingGotchi.speed - 100
280
+
281
+ if (chanceToCounter < MULTS.COUNTER_CHANCE_MIN) chanceToCounter = MULTS.COUNTER_CHANCE_MIN
282
+
283
+ // Add chance if gotchi has fortify status
284
+ if (defendingGotchi.statuses.includes('fortify')) {
285
+ chanceToCounter += MULTS.FORTIFY_COUNTER_CHANCE
286
+ }
287
+
288
+ if (rng() < chanceToCounter / 100) {
289
+ const counterDamageMultiplier = defendingGotchi.statuses.includes('fortify') ? MULTS.FORTIFY_COUNTER_DAMAGE : MULTS.COUNTER_DAMAGE
290
+ const counterDamage = getDamage(defendingTeam, attackingTeam, defendingGotchi, attackingGotchi, counterDamageMultiplier, false, 0)
291
+
292
+ attackingGotchi.health -= counterDamage
293
+
294
+ effects.push({
295
+ target: attackingGotchi.id,
296
+ source: defendingGotchi.id,
297
+ damage: counterDamage,
298
+ outcome: 'counter'
299
+ })
300
+ }
301
+ }
302
+ }
303
+ })
304
+
305
+ return effects
306
+ }
307
+
308
+ // Deal with start of turn status effects
309
+ const handleStatusEffects = (attackingGotchi, attackingTeam, defendingTeam, rng) => {
310
+ const statusEffects = []
311
+ const passiveEffects = []
312
+
313
+ const modifiedAttackingGotchi = getModifiedStats(attackingGotchi)
314
+
315
+ // Check for global status effects
316
+ const allAliveGotchis = [...getAlive(attackingTeam), ...getAlive(defendingTeam)]
317
+
318
+ allAliveGotchis.forEach((gotchi) => {
319
+ const modifiedGotchi = getModifiedStats(gotchi)
320
+ if (gotchi.statuses && gotchi.statuses.length) {
321
+ gotchi.statuses.forEach((status) => {
322
+ // Handle cleansing_aura (health regen)
323
+ if (status === 'cleansing_aura') {
324
+ let amountToHeal
325
+
326
+ // Check if healer
327
+ if (gotchi.special.id === 6) {
328
+ amountToHeal = Math.round(modifiedGotchi.resist * MULTS.CLEANSING_AURA_REGEN)
329
+ } else {
330
+ amountToHeal = MULTS.CLEANSING_AURA_NON_HEALER_REGEN
331
+ }
332
+
333
+ // Don't allow amountToHeal to be more than the difference between current health and max health
334
+ if (amountToHeal > gotchi.originalStats.health - gotchi.health) {
335
+ amountToHeal = gotchi.originalStats.health - gotchi.health
336
+ }
337
+
338
+ // if amountToHeal > 0, add status effect
339
+ if (amountToHeal) {
340
+ // Add status effect
341
+ statusEffects.push({
342
+ target: gotchi.id,
343
+ status,
344
+ damage: -Math.abs(amountToHeal),
345
+ remove: false
346
+ })
347
+
348
+ gotchi.health += amountToHeal
349
+ }
350
+ }
351
+
352
+ /*
353
+ * Handle damage effect at the bottom of the loop
354
+ */
355
+
356
+ // Handle bleed
357
+ if (status === 'bleed') {
358
+ let damage = MULTS.BLEED_DAMAGE
359
+
360
+ gotchi.health -= damage
361
+ if (gotchi.health <= 0) gotchi.health = 0
362
+
363
+ // Add status effect
364
+ statusEffects.push({
365
+ target: gotchi.id,
366
+ status,
367
+ damage,
368
+ remove: false
369
+ })
370
+ }
371
+ })
372
+ }
373
+ })
374
+
375
+ let skipTurn = null
376
+
377
+ // Check if gotchi is dead
378
+ if (attackingGotchi.health <= 0) {
379
+ return {
380
+ statusEffects,
381
+ passiveEffects,
382
+ skipTurn: 'ATTACKER_DEAD'
383
+ }
384
+ }
385
+
386
+ // Check if a whole team is dead
387
+ if (getAlive(attackingTeam).length === 0 || getAlive(defendingTeam).length === 0) {
388
+ return {
389
+ statusEffects,
390
+ passiveEffects,
391
+ skipTurn: 'TEAM_DEAD'
392
+ }
393
+ }
394
+
395
+ // Check for turn skipping statuses
396
+ for (let i = 0; i < attackingGotchi.statuses.length; i++) {
397
+ const status = attackingGotchi.statuses[i]
398
+ // Fear - skip turn
399
+ if (status === 'fear') {
400
+ // Skip turn
401
+ statusEffects.push({
402
+ target: attackingGotchi.id,
403
+ status,
404
+ damage: 0,
405
+ remove: true
406
+ })
407
+
408
+ skipTurn = 'FEAR'
409
+
410
+ // Remove fear first instance of fear
411
+ attackingGotchi.statuses.splice(i, 1)
412
+
413
+ break
414
+ }
415
+ }
416
+
417
+ return {
418
+ statusEffects,
419
+ passiveEffects,
420
+ skipTurn
421
+ }
422
+ }
423
+
424
+ const executeTurn = (team1, team2, rng) => {
425
+ const nextToAct = getNextToAct(team1, team2, rng)
426
+
427
+ const attackingTeam = nextToAct.team === 1 ? team1 : team2
428
+ const defendingTeam = nextToAct.team === 1 ? team2 : team1
429
+
430
+ const attackingGotchi = attackingTeam.formation[nextToAct.row][nextToAct.position]
431
+
432
+ let { statusEffects, passiveEffects, skipTurn } = handleStatusEffects(attackingGotchi, attackingTeam, defendingTeam, rng)
433
+ let statusesExpired = []
434
+
435
+ let effects = []
436
+ if (skipTurn) {
437
+ // Increase actionDelay
438
+ attackingGotchi.actionDelay = getNewActionDelay(attackingGotchi)
439
+
440
+ return {
441
+ skipTurn,
442
+ action: {
443
+ user: attackingGotchi.id,
444
+ name: 'auto',
445
+ effects
446
+ },
447
+ passiveEffects,
448
+ statusEffects,
449
+ statusesExpired
450
+ }
451
+ }
452
+
453
+ let specialDone = false
454
+ // Check if special attack is ready
455
+ if (attackingGotchi.special.cooldown === 0) {
456
+ // Execute special attack
457
+ const specialResults = specialAttack(attackingGotchi, attackingTeam, defendingTeam, rng)
458
+
459
+ if (specialResults.specialNotDone) {
460
+ // Do nothing which will lead to an auto attack
461
+ } else {
462
+ specialDone = true
463
+
464
+ effects = specialResults.effects
465
+ statusesExpired = specialResults.statusesExpired
466
+
467
+ // Reset cooldown
468
+ attackingGotchi.special.cooldown = 2
469
+ }
470
+
471
+ } else {
472
+ // Decrease cooldown
473
+ attackingGotchi.special.cooldown--
474
+ }
475
+
476
+ if (!specialDone) {
477
+ // Do an auto attack
478
+ const target = getTarget(defendingTeam, rng)
479
+
480
+ effects = attack(attackingGotchi, attackingTeam, defendingTeam, [target], rng)
481
+ }
482
+
483
+ // Increase actionDelay
484
+ attackingGotchi.actionDelay = getNewActionDelay(attackingGotchi)
485
+
486
+ return {
487
+ skipTurn,
488
+ action: {
489
+ user: attackingGotchi.id,
490
+ name: specialDone ? attackingGotchi.special.name : 'auto',
491
+ effects
492
+ },
493
+ passiveEffects,
494
+ statusEffects,
495
+ statusesExpired
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Execute a special attack
501
+ * @param {Object} attackingGotchi The attacking gotchi object
502
+ * @param {Array} attackingTeam An array of gotchis to attack
503
+ * @param {Array} defendingTeam An array of gotchis to attack
504
+ * @param {Function} rng The random number generator
505
+ * @returns {Array} effects An array of effects to apply
506
+ **/
507
+ const specialAttack = (attackingGotchi, attackingTeam, defendingTeam, rng) => {
508
+ const specialId = attackingGotchi.special.id
509
+ let effects = []
510
+ let statusesExpired = []
511
+ let specialNotDone = false
512
+
513
+ const modifiedAttackingGotchi = getModifiedStats(attackingGotchi)
514
+
515
+ switch (specialId) {
516
+ case 1:
517
+ // Spectral Strike - ignore armor and appply bleed status
518
+ // get single target
519
+ const ssTarget = getTarget(defendingTeam, rng)
520
+
521
+ effects = attack(attackingGotchi, attackingTeam, defendingTeam, [ssTarget], rng, {
522
+ multiplier: MULTS.SPECTRAL_STRIKE_DAMAGE,
523
+ ignoreArmor: true,
524
+ statuses: ['bleed'],
525
+ cannotBeCountered: true,
526
+ cannotBeEvaded: true,
527
+ noPassiveStatuses: true,
528
+ noResistSpeedPenalty: true
529
+ })
530
+ break
531
+ case 2:
532
+ // Meditate - Boost own speed, magic, physical by 30%
533
+ // If gotchi already has 2 power_up statuses, do nothing
534
+ if (!addStatusToGotchi(attackingGotchi, 'power_up_2')) {
535
+ specialNotDone = true
536
+ break
537
+ }
538
+
539
+ effects = [
540
+ {
541
+ target: attackingGotchi.id,
542
+ outcome: 'success',
543
+ statuses: ['power_up_2']
544
+ }
545
+ ]
546
+
547
+ // Check for leaderPassive 'Cloud of Zen'
548
+ if (attackingGotchi.statuses.includes(PASSIVES[specialId - 1])) {
549
+ // Increase allies speed, magic and physical by 15% of the original value
550
+
551
+ const cloudOfZenGotchis = getAlive(attackingTeam)
552
+
553
+ cloudOfZenGotchis.forEach((gotchi) => {
554
+ if (addStatusToGotchi(gotchi, 'power_up_1')) {
555
+ effects.push({
556
+ target: gotchi.id,
557
+ outcome: 'success',
558
+ statuses: ['power_up_1']
559
+ })
560
+ }
561
+ })
562
+ }
563
+
564
+ break
565
+ case 3:
566
+ // Cleave - attack all enemies in a row (that have the most gotchis) for 75% damage
567
+ // Find row with most gotchis
568
+ const cleaveRow = getAlive(defendingTeam, 'front').length > getAlive(defendingTeam, 'back').length ? 'front' : 'back'
569
+
570
+ // Attack all gotchis in that row for 75% damage
571
+ effects = attack(attackingGotchi, attackingTeam, defendingTeam, getAlive(defendingTeam, cleaveRow), rng, {
572
+ multiplier: MULTS.CLEAVE_DAMAGE,
573
+ cannotBeCountered: true,
574
+ noPassiveStatuses: true
575
+ })
576
+ break
577
+ case 4:
578
+ // Taunt - add taunt status to self
579
+
580
+ // Check if gotchi already has taunt status
581
+ if (attackingGotchi.statuses.includes('taunt')) {
582
+ specialNotDone = true
583
+ break
584
+ }
585
+
586
+ if (!addStatusToGotchi(attackingGotchi, 'taunt')) {
587
+ specialNotDone = true
588
+ break
589
+ }
590
+
591
+ effects = [
592
+ {
593
+ target: attackingGotchi.id,
594
+ outcome: 'success',
595
+ statuses: ['taunt']
596
+ }
597
+ ]
598
+ break
599
+ case 5:
600
+ // Curse - attack random enemy for 50% damage, apply fear status and remove all buffs
601
+
602
+ const curseTarget = getTarget(defendingTeam, rng)
603
+
604
+ const curseTargetStatuses = ['fear']
605
+
606
+ effects = attack(attackingGotchi, attackingTeam, defendingTeam, [curseTarget], rng, {
607
+ multiplier: MULTS.CURSE_DAMAGE,
608
+ statuses: curseTargetStatuses,
609
+ cannotBeCountered: true,
610
+ noPassiveStatuses: true,
611
+ speedPenalty: MULTS.CURSE_SPEED_PENALTY,
612
+ noResistSpeedPenalty: true
613
+ })
614
+
615
+ const removeRandomBuff = (target) => {
616
+ const modifiedTarget = getModifiedStats(target)
617
+
618
+ if (rng() > modifiedTarget.resist / 100) {
619
+ const buffsToRemove = target.statuses.filter((status) => BUFFS.includes(status))
620
+
621
+ if (buffsToRemove.length) {
622
+ const randomBuff = buffsToRemove[Math.floor(rng() * buffsToRemove.length)]
623
+ statusesExpired.push({
624
+ target: target.id,
625
+ status: randomBuff
626
+ })
627
+
628
+ // Remove first instance of randomBuff (there may be multiple)
629
+ const index = target.statuses.indexOf(randomBuff)
630
+ target.statuses.splice(index, 1)
631
+ }
632
+ }
633
+ }
634
+
635
+ if (effects[0] && (effects[0].outcome === 'success' || effects[0].outcome === 'critical')) {
636
+ // 1 chance to remove a random buff
637
+ removeRandomBuff(curseTarget)
638
+
639
+ if (effects[0].outcome === 'critical') {
640
+ // 2 chances to remove a random buff
641
+ removeRandomBuff(curseTarget)
642
+ }
643
+
644
+ // heal attacking gotchi for % of damage dealt
645
+ let amountToHeal = Math.round(effects[0].damage * MULTS.CURSE_HEAL)
646
+
647
+ // Don't allow amountToHeal to be more than the difference between current health and max health
648
+ if (amountToHeal > attackingGotchi.originalStats.health - attackingGotchi.health) {
649
+ amountToHeal = attackingGotchi.originalStats.health - attackingGotchi.health
650
+ }
651
+
652
+ if (amountToHeal) {
653
+ attackingGotchi.health += amountToHeal
654
+
655
+ effects.push({
656
+ target: attackingGotchi.id,
657
+ outcome: effects[0].outcome,
658
+ damage: -Math.abs(amountToHeal)
659
+ })
660
+ }
661
+ }
662
+
663
+ break
664
+ case 6:
665
+ // Blessing - Heal all non-healer allies and remove all debuffs
666
+
667
+ // Get all alive non-healer allies on the attacking team
668
+ // const gotchisToHeal = getAlive(attackingTeam).filter(x => x.special.id !== 6)
669
+ const gotchisToHeal = getAlive(attackingTeam)
670
+
671
+ // Heal all allies for multiple of healers resistance
672
+ gotchisToHeal.forEach((gotchi) => {
673
+ let amountToHeal
674
+
675
+ // If gotchi has 'cleansing_aura' status, increase heal amount
676
+ if (attackingGotchi.statuses.includes('cleansing_aura')) {
677
+ amountToHeal = Math.round(modifiedAttackingGotchi.resist * MULTS.CLEANSING_AURA_HEAL)
678
+ } else {
679
+ amountToHeal = Math.round(modifiedAttackingGotchi.resist * MULTS.BLESSING_HEAL)
680
+ }
681
+
682
+ // Check for crit
683
+ const isCrit = rng() < modifiedAttackingGotchi.crit / 100
684
+ if (isCrit) {
685
+ amountToHeal = Math.round(amountToHeal * MULTS.BLESSING_HEAL_CRIT_MULTIPLIER)
686
+ }
687
+
688
+ // Apply speed penalty
689
+ let speedPenalty
690
+ if (attackingGotchi.statuses.includes('cleansing_aura')) {
691
+ speedPenalty = Math.round((modifiedAttackingGotchi.speed - 100) * MULTS.CLEANSING_AURA_HEAL_SPEED_PENALTY)
692
+ } else {
693
+ speedPenalty = Math.round((modifiedAttackingGotchi.speed - 100) * MULTS.BLESSING_HEAL_SPEED_PENALTY)
694
+ }
695
+ if (speedPenalty > 0) amountToHeal -= speedPenalty
696
+
697
+ // Don't allow amountToHeal to be more than the difference between current health and max health
698
+ if (amountToHeal > gotchi.originalStats.health - gotchi.health) {
699
+ amountToHeal = gotchi.originalStats.health - gotchi.health
700
+ }
701
+
702
+ gotchi.health += amountToHeal
703
+
704
+ if (amountToHeal) {
705
+ effects.push({
706
+ target: gotchi.id,
707
+ outcome: isCrit ? 'critical' : 'success',
708
+ damage: -Math.abs(amountToHeal)
709
+ })
710
+ }
711
+
712
+ // Remove all debuffs
713
+ // Add removed debuffs to statusesExpired
714
+ gotchi.statuses.forEach((status) => {
715
+ if (DEBUFFS.includes(status)) {
716
+ statusesExpired.push({
717
+ target: gotchi.id,
718
+ status
719
+ })
720
+ }
721
+ })
722
+
723
+ // Remove all debuffs from gotchi
724
+ gotchi.statuses = gotchi.statuses.filter((status) => !DEBUFFS.includes(status))
725
+ })
726
+
727
+ // If no allies have been healed and no debuffs removed, then special attack not done
728
+ if (!effects.length && !statusesExpired.length) {
729
+ specialNotDone = true
730
+ break
731
+ }
732
+
733
+ break
734
+ case 7:
735
+ // Thunder - Attack all enemies for 50% damage and apply stun status
736
+
737
+ const thunderTargets = getAlive(defendingTeam)
738
+
739
+ let stunStatuses = []
740
+ // Check if leader passive is 'channel_the_coven' then apply stun status
741
+ if (attackingGotchi.statuses.includes(PASSIVES[specialId - 1])) {
742
+ if (rng() < MULTS.CHANNEL_THE_COVEN_STUN_CHANCE) stunStatuses.push('stun')
743
+ } else {
744
+ if (rng() < MULTS.THUNDER_STUN_CHANCE) stunStatuses.push('stun')
745
+ }
746
+
747
+ effects = attack(attackingGotchi, attackingTeam, defendingTeam, thunderTargets, rng, {
748
+ multiplier: MULTS.THUNDER_DAMAGE,
749
+ statuses: stunStatuses,
750
+ cannotBeCountered: true,
751
+ noPassiveStatuses: true
752
+ })
753
+
754
+ break
755
+ case 8:
756
+ // Devestating Smash - Attack random enemy for 200% damage
757
+
758
+ const smashTarget = getTarget(defendingTeam, rng)
759
+
760
+ effects = attack(attackingGotchi, attackingTeam, defendingTeam, [smashTarget], rng, {
761
+ multiplier: MULTS.DEVESTATING_SMASH_DAMAGE,
762
+ cannotBeCountered: true,
763
+ noPassiveStatuses: true
764
+ })
765
+
766
+ let anotherAttack = false
767
+ if (attackingGotchi.statuses.includes(PASSIVES[specialId - 1])) {
768
+ if (rng() < MULTS.CLAN_MOMENTUM_CHANCE) anotherAttack = true
769
+ } else {
770
+ if (rng() < MULTS.DEVESTATING_SMASH_X2_CHANCE) anotherAttack = true
771
+ }
772
+
773
+ if (anotherAttack) {
774
+ // Check if any enemies are alive
775
+ const aliveEnemies = getAlive(defendingTeam)
776
+
777
+ if (aliveEnemies.length) {
778
+ // Do an extra devestating smash
779
+ const target = getTarget(defendingTeam, rng)
780
+
781
+ effects.push(...attack(attackingGotchi, attackingTeam, defendingTeam, [target], rng, {
782
+ multiplier: MULTS.DEVESTATING_SMASH_X2_DAMAGE,
783
+ cannotBeCountered: true,
784
+ noPassiveStatuses: true
785
+ }))
786
+ }
787
+ }
788
+
789
+ break
790
+ }
791
+
792
+ return {
793
+ effects,
794
+ statusesExpired,
795
+ specialNotDone
796
+ }
797
+ }
798
+
799
+ module.exports = {
800
+ gameLoop,
801
+ attack,
802
+ specialAttack
796
803
  }