gotchi-battler-game-logic 1.0.0 → 2.0.0

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