gotchi-battler-game-logic 3.0.0 → 4.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 (61) hide show
  1. package/.cursor/rules/cursor-rules.mdc +67 -0
  2. package/.cursor/rules/directory-structure.mdc +63 -0
  3. package/.cursor/rules/self-improvement.mdc +64 -0
  4. package/.cursor/rules/tech-stack.mdc +99 -0
  5. package/README.md +4 -0
  6. package/eslint.config.js +31 -0
  7. package/game-logic/index.js +2 -6
  8. package/game-logic/v1.4/constants.js +0 -23
  9. package/game-logic/v1.4/index.js +64 -56
  10. package/game-logic/v1.5/constants.js +0 -23
  11. package/game-logic/v1.5/index.js +27 -21
  12. package/game-logic/v1.6/constants.js +0 -23
  13. package/game-logic/v1.6/index.js +27 -21
  14. package/game-logic/v1.7/constants.js +0 -23
  15. package/game-logic/v1.7/helpers.js +2 -2
  16. package/game-logic/v1.7/index.js +24 -18
  17. package/game-logic/v1.8/constants.js +0 -23
  18. package/game-logic/v1.8/helpers.js +2 -2
  19. package/game-logic/v1.8/index.js +25 -19
  20. package/game-logic/v2.0/constants.js +112 -0
  21. package/game-logic/v2.0/helpers.js +713 -0
  22. package/game-logic/v2.0/index.js +782 -0
  23. package/game-logic/v2.0/statuses.json +439 -0
  24. package/package.json +11 -4
  25. package/schemas/crystal.js +14 -0
  26. package/schemas/effect.js +25 -0
  27. package/schemas/gotchi.js +53 -0
  28. package/schemas/ingameteam.js +14 -0
  29. package/schemas/item.js +13 -0
  30. package/schemas/leaderskill.js +15 -0
  31. package/schemas/leaderskillstatus.js +12 -0
  32. package/schemas/special.js +22 -0
  33. package/schemas/team.js +24 -0
  34. package/schemas/team.json +252 -114
  35. package/scripts/balancing/createTrainingGotchis.js +44 -44
  36. package/scripts/balancing/extractOnchainTraits.js +3 -3
  37. package/scripts/balancing/fixTrainingGotchis.js +41 -41
  38. package/scripts/balancing/processSims.js +5 -5
  39. package/scripts/balancing/sims.js +8 -15
  40. package/scripts/balancing/v1.7/setTeamPositions.js +2 -2
  41. package/scripts/balancing/v1.7.1/setTeamPositions.js +2 -2
  42. package/scripts/balancing/v1.7.2/setTeamPositions.js +2 -2
  43. package/scripts/balancing/v1.7.3/setTeamPositions.js +2 -2
  44. package/scripts/data/dungeon_mob_1.json +87 -0
  45. package/scripts/data/dungeon_mob_2.json +87 -0
  46. package/scripts/data/immaterialTeam1.json +374 -0
  47. package/scripts/data/immaterialTeam2.json +365 -0
  48. package/scripts/generateAllSpecialsLogs.js +93 -0
  49. package/scripts/generateSpecialLogs.js +94 -0
  50. package/scripts/runCampaignBattles.js +41 -0
  51. package/scripts/runLocalBattle.js +6 -3
  52. package/scripts/runLocalDungeon.js +52 -0
  53. package/scripts/runPvPBattle.js +16 -0
  54. package/scripts/runRealBattle.js +8 -8
  55. package/scripts/simRealBattle.js +8 -8
  56. package/scripts/validateBattle.js +12 -14
  57. package/scripts/validateTournament.js +9 -9
  58. package/tests/getModifiedStats.test.js +78 -0
  59. package/utils/errors.js +13 -13
  60. package/utils/transforms.js +2 -8
  61. package/scripts/output/.gitkeep +0 -0
@@ -0,0 +1,782 @@
1
+ const seedrandom = require('seedrandom')
2
+ const { InGameTeamSchema } = require('../../schemas/ingameteam')
3
+ const { GameError } = require('../../utils/errors')
4
+
5
+ const STATUSES = require('./statuses.json')
6
+
7
+ const {
8
+ getTeamGotchis,
9
+ getAlive,
10
+ getNextToAct,
11
+ getTargetsFromCode,
12
+ getDamage,
13
+ getHealFromMultiplier,
14
+ getNewActionDelay,
15
+ simplifyTeam,
16
+ addStatusToGotchi,
17
+ prepareTeams,
18
+ getLogGotchis,
19
+ getTeamStats,
20
+ getStatusByCode,
21
+ getTeamSpecialBars,
22
+ focusCheck,
23
+ getCritMultiplier,
24
+ getModifiedStats
25
+ } = require('./helpers')
26
+
27
+ /**
28
+ * Run a battle between two teams
29
+ * @param {Object} team1 An in-game team object
30
+ * @param {Object} team2 An in-game team object
31
+ * @param {String} seed A seed for the random number generator
32
+ * @param {Object} options An object containing options for the game loop
33
+ * @param {Boolean} options.debug A boolean to determine if the logs should include debug information
34
+ * @param {String} options.type A string to determine the type of the game loop
35
+ * @param {Object} options.campaign An object containing the campaign information
36
+ * @param {String} options.isBoss A boolean to determine if team2 is a boss
37
+ * @returns {Object} logs The battle logs
38
+ */
39
+ const gameLoop = (team1, team2, seed, options = { debug: false, type: 'pve', campaign: {}, isBoss: false }) => {
40
+ if (!seed) throw new Error('Seed not found')
41
+
42
+ // Validate team objects
43
+ team1 = InGameTeamSchema.parse(team1)
44
+ team2 = InGameTeamSchema.parse(team2)
45
+
46
+ const rng = seedrandom(seed)
47
+
48
+ const allAliveGotchis = [...getAlive(team1), ...getAlive(team2)]
49
+
50
+ prepareTeams(allAliveGotchis, team1, team2)
51
+
52
+ const logs = {
53
+ meta: {
54
+ seed,
55
+ timestamp: new Date(),
56
+ type: options.type || 'pve',
57
+ campaign: options.campaign || {},
58
+ isBoss: options.isBoss || false
59
+ },
60
+ gotchis: getLogGotchis(allAliveGotchis),
61
+ layout: {
62
+ teams: [
63
+ simplifyTeam(team1),
64
+ simplifyTeam(team2)
65
+ ]
66
+ },
67
+ turns: [],
68
+ result: {},
69
+ debug: []
70
+ }
71
+
72
+ let turnCounter = 0
73
+
74
+ try {
75
+ while (getAlive(team1).length && getAlive(team2).length) {
76
+ // Check if turnCounter is ready for environment effects (99,149,199, etc)
77
+ let isEnvironmentTurn = [99, 149, 199, 249, 299].includes(turnCounter)
78
+ if (isEnvironmentTurn) {
79
+ allAliveGotchis.forEach(x => {
80
+ x.environmentEffects.push('damage_up')
81
+ })
82
+ }
83
+
84
+ const turnLogs = executeTurn(team1, team2, rng)
85
+
86
+ turnLogs.specialBars = getTeamSpecialBars(team1, team2)
87
+
88
+ // Check if turnCounter is ready for environment effects (99,149,199, etc)
89
+ if (isEnvironmentTurn) turnLogs.environmentEffects = ['damage_up']
90
+
91
+ logs.turns.push({ index: turnCounter, ...turnLogs })
92
+
93
+ if (options.debug) {
94
+ logs.debug.push({
95
+ turn: turnCounter,
96
+ user: logs.turns[logs.turns.length - 1].action.user,
97
+ move: logs.turns[logs.turns.length - 1].action.name,
98
+ team1: getAlive(team1).map((x) => {
99
+ return `Id: ${x.id}, Name: ${x.name}, Health: ${x.health}, Statuses: ${x.statuses}`
100
+ }),
101
+ team2: getAlive(team2).map((x) => {
102
+ return `Id: ${x.id}, Name: ${x.name}, Health: ${x.health}, Statuses: ${x.statuses}`
103
+ })
104
+ })
105
+ }
106
+
107
+ turnCounter++
108
+ }
109
+ } catch (e) {
110
+ console.error(e)
111
+ throw new GameError('Game loop failed', logs)
112
+ }
113
+
114
+ // Add stats to logs
115
+ logs.result.stats = {
116
+ numOfTurns: turnCounter,
117
+ team1: getTeamStats(team1),
118
+ team2: getTeamStats(team2)
119
+ }
120
+
121
+ logs.result.winner = getAlive(team1).length ? 1 : 2
122
+ logs.result.winningTeam = getTeamGotchis(team1).length ? getTeamGotchis(team1) : getTeamGotchis(team2)
123
+ logs.result.winningTeam = logs.result.winningTeam.map((gotchi) => {
124
+ return {
125
+ id: gotchi.id,
126
+ name: gotchi.name,
127
+ health: gotchi.health,
128
+ statuses: gotchi.statuses,
129
+ originalStats: {
130
+ speed: gotchi.speed,
131
+ attack: gotchi.attack,
132
+ defense: gotchi.defense,
133
+ criticalRate: gotchi.criticalRate,
134
+ criticalDamage: gotchi.criticalDamage,
135
+ resist: gotchi.resist,
136
+ focus: gotchi.focus
137
+ },
138
+ modifiedStats: {
139
+ speed: getModifiedStats(gotchi).speed,
140
+ attack: getModifiedStats(gotchi).attack,
141
+ defense: getModifiedStats(gotchi).defense,
142
+ criticalRate: getModifiedStats(gotchi).criticalRate,
143
+ criticalDamage: getModifiedStats(gotchi).criticalDamage,
144
+ resist: getModifiedStats(gotchi).resist,
145
+ focus: getModifiedStats(gotchi).focus
146
+ }
147
+ }
148
+ })
149
+
150
+ if (!options.debug) delete logs.debug
151
+
152
+ return logs
153
+ }
154
+
155
+ const executeTurn = (team1, team2, rng) => {
156
+ const nextToAct = getNextToAct(team1, team2, rng)
157
+
158
+ const attackingTeam = nextToAct.team === 1 ? team1 : team2
159
+ const defendingTeam = nextToAct.team === 1 ? team2 : team1
160
+
161
+ const attackingGotchi = attackingTeam.formation[nextToAct.row][nextToAct.position]
162
+
163
+ let { statusEffects, skipTurn } = handleStatusEffects(attackingGotchi, attackingTeam, defendingTeam, rng)
164
+ let statusesExpired = []
165
+
166
+ let actionEffects = []
167
+ let additionalEffects = []
168
+ if (skipTurn) {
169
+ // Increase actionDelay
170
+ attackingGotchi.actionDelay = getNewActionDelay(attackingGotchi)
171
+
172
+ return {
173
+ skipTurn,
174
+ action: {
175
+ user: attackingGotchi.id,
176
+ name: 'auto',
177
+ actionEffects,
178
+ additionalEffects
179
+ },
180
+ statusEffects,
181
+ statusesExpired
182
+ }
183
+ }
184
+
185
+ let actionName = 'auto'
186
+ let repeatAttack = false
187
+ // Check if special attack is ready
188
+ if (attackingGotchi.specialBar === 100) {
189
+ // Execute special attack
190
+ actionName = attackingGotchi.specialExpanded.code
191
+ const specialResults = attack(attackingGotchi, attackingTeam, defendingTeam, rng, true)
192
+
193
+ actionEffects = specialResults.actionEffects
194
+ additionalEffects = specialResults.additionalEffects
195
+ statusesExpired = specialResults.statusesExpired
196
+
197
+ if (specialResults.repeatAttack) {
198
+ // Don't reset specialBar, just repeat the attack
199
+ repeatAttack = true
200
+ } else {
201
+ // Reset specialBar
202
+ attackingGotchi.specialBar = Math.round((100/6) * (6 - attackingGotchi.specialExpanded.cooldown))
203
+ }
204
+ } else {
205
+ // Do an auto attack
206
+ const attackResults = attack(attackingGotchi, attackingTeam, defendingTeam, rng)
207
+
208
+ actionEffects = attackResults.actionEffects
209
+ additionalEffects = attackResults.additionalEffects
210
+ statusesExpired = attackResults.statusesExpired
211
+
212
+ // Increase specialBar by 1/6th
213
+ attackingGotchi.specialBar = Math.round(attackingGotchi.specialBar + (100/6))
214
+ if (attackingGotchi.specialBar > 100) attackingGotchi.specialBar = 100
215
+ }
216
+
217
+ // Increase actionDelay
218
+ if (!repeatAttack) {
219
+ attackingGotchi.actionDelay = getNewActionDelay(attackingGotchi)
220
+ }
221
+
222
+ return {
223
+ skipTurn,
224
+ action: {
225
+ user: attackingGotchi.id,
226
+ name: actionName,
227
+ actionEffects,
228
+ additionalEffects
229
+ },
230
+ statusEffects,
231
+ statusesExpired
232
+ }
233
+ }
234
+
235
+ // Deal with start of turn status effects
236
+ const handleStatusEffects = (attackingGotchi, attackingTeam, defendingTeam) => {
237
+ const statusEffects = []
238
+ let skipTurn = null
239
+
240
+ // Check for global status effects
241
+ const allAliveGotchis = [...getAlive(attackingTeam), ...getAlive(defendingTeam)]
242
+
243
+ allAliveGotchis.forEach((gotchi) => {
244
+ // Get all statuses that have turnEffects
245
+ const turnEffectStatuses = gotchi.statuses.filter(status => {
246
+ const statusEffect = getStatusByCode(status)
247
+ return statusEffect.turnEffects
248
+ })
249
+
250
+ turnEffectStatuses.forEach((turnEffectStatus) => {
251
+
252
+ const status = getStatusByCode(turnEffectStatus)
253
+
254
+ const turnEffects = status.turnEffects
255
+
256
+ turnEffects.forEach((turnEffect) => {
257
+ switch (turnEffect.type) {
258
+ case 'heal': {
259
+ let amountToHeal = turnEffect.value
260
+
261
+ if (turnEffect.valueType === 'percent') {
262
+ amountToHeal = Math.round(gotchi.fullHealth * (amountToHeal / 100))
263
+ }
264
+
265
+ // Don't allow amountToHeal to be more than the difference between current health and max health
266
+ if (amountToHeal > gotchi.fullHealth - gotchi.health) {
267
+ amountToHeal = gotchi.fullHealth - gotchi.health
268
+ }
269
+
270
+ if (amountToHeal > 0) {
271
+ // Add status effect
272
+ statusEffects.push({
273
+ target: gotchi.id,
274
+ status: status.code,
275
+ damage: -amountToHeal,
276
+ remove: false
277
+ })
278
+
279
+ gotchi.health += amountToHeal
280
+ }
281
+
282
+ break
283
+ }
284
+ case 'damage': {
285
+ const damage = turnEffect.value
286
+
287
+ gotchi.health -= damage
288
+ if (gotchi.health <= 0) gotchi.health = 0
289
+
290
+ // Add status effect
291
+ statusEffects.push({
292
+ target: gotchi.id,
293
+ status: status.code,
294
+ damage: damage,
295
+ remove: false
296
+ })
297
+
298
+ gotchi.stats.dmgReceived += damage
299
+
300
+ break
301
+ }
302
+ case 'skip_turn': {
303
+ // Do nothing here and handle after damage/heal
304
+ break
305
+ }
306
+ default: {
307
+ throw new Error(`Invalid turn status effect type: ${turnEffect.type}`)
308
+ }
309
+ }
310
+ })
311
+ })
312
+ })
313
+
314
+ // Check if gotchi is dead
315
+ if (attackingGotchi.health <= 0) {
316
+ return {
317
+ statusEffects,
318
+ skipTurn: 'attacker_dead'
319
+ }
320
+ }
321
+
322
+ // Check if a whole team is dead
323
+ if (getAlive(attackingTeam).length === 0 || getAlive(defendingTeam).length === 0) {
324
+ return {
325
+ statusEffects,
326
+ skipTurn: 'team_dead'
327
+ }
328
+ }
329
+
330
+ // Check for turn skipping statuses
331
+ for (let i = 0; i < attackingGotchi.statuses.length; i++) {
332
+ const status = getStatusByCode(attackingGotchi.statuses[i])
333
+
334
+ if (status.turnEffects) {
335
+ // Get first instance of a turn effect that is a skip_turn
336
+ const skipTurnEffect = status.turnEffects.find(turnEffect => turnEffect.type === 'skip_turn')
337
+ if (skipTurnEffect) {
338
+ statusEffects.push({
339
+ target: attackingGotchi.id,
340
+ status: status.code,
341
+ damage: 0,
342
+ remove: true
343
+ })
344
+
345
+ skipTurn = status.code
346
+
347
+ // Remove status
348
+ attackingGotchi.statuses.splice(i, 1)
349
+
350
+ break
351
+ }
352
+ }
353
+ }
354
+
355
+ return {
356
+ statusEffects,
357
+ skipTurn
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Attack one or more gotchis. This mutates the defending gotchis health
363
+ * @param {Object} attackingGotchi The attacking gotchi object
364
+ * @param {Array} attackingTeam A team object for the attacking team
365
+ * @param {Array} defendingTeam A team object for the defending team
366
+ * @param {Function} rng The random number generator
367
+ * @param {Boolean} isSpecial A boolean to determine if the attack is a special attack
368
+ * @returns {Object} results The results of the attack
369
+ * @returns {Array} results.effects An array of effects to apply
370
+ * @returns {Array} results.statusesExpired An array of statuses that expired
371
+ */
372
+ const attack = (attackingGotchi, attackingTeam, defendingTeam, rng, isSpecial = false) => {
373
+ const action = isSpecial ? attackingGotchi.specialExpanded.actionType : 'attack'
374
+
375
+ const targetCode = isSpecial ? attackingGotchi.specialExpanded.target : 'enemy_random'
376
+ const targets = getTargetsFromCode(targetCode, attackingGotchi, attackingTeam, defendingTeam, rng)
377
+
378
+ let actionMultipler = isSpecial ? attackingGotchi.specialExpanded.actionMultiplier : 1
379
+
380
+ const actionEffects = []
381
+ const additionalEffects = []
382
+ const statusesExpired = []
383
+ let repeatAttack = false
384
+
385
+ targets.forEach((target) => {
386
+ // The effect for the main action of the attack
387
+ let targetActionEffect
388
+
389
+ // For an additional effects that come for the special attack e.g. heals
390
+ const targetAdditionalEffects = []
391
+
392
+ // Handle action first
393
+ if (action === 'attack') {
394
+ const critMultiplier = getCritMultiplier(attackingGotchi, rng)
395
+ const isCrit = critMultiplier > 1
396
+ actionMultipler *= critMultiplier
397
+
398
+ const damage = getDamage(attackingGotchi, target, actionMultipler)
399
+
400
+ targetActionEffect = {
401
+ target: target.id,
402
+ statuses: [],
403
+ damage,
404
+ outcome: isCrit ? 'critical' : 'success'
405
+ }
406
+
407
+ // Handle damage
408
+ target.health -= damage
409
+
410
+ // Handle stats
411
+ if (isCrit) attackingGotchi.stats.crits++
412
+ attackingGotchi.stats.hits++
413
+ attackingGotchi.stats.dmgGiven += damage
414
+ target.stats.dmgReceived += damage
415
+
416
+ } else if (action === 'heal') {
417
+ const amountToHeal = getHealFromMultiplier(attackingGotchi, actionMultipler)
418
+
419
+ targetActionEffect = {
420
+ target: target.id,
421
+ statuses: [],
422
+ damage: -amountToHeal,
423
+ outcome: 'success'
424
+ }
425
+
426
+ // Handle healing
427
+ target.health += amountToHeal
428
+
429
+ // Handle stats
430
+ attackingGotchi.stats.healGiven += amountToHeal
431
+ target.stats.healReceived += amountToHeal
432
+
433
+ } else if (action === 'none') {
434
+ // Do nothing
435
+ } else {
436
+ // Check we actually have a valid action
437
+ throw new Error(`Invalid action: ${action}`)
438
+ }
439
+
440
+ // If it's a special attack then handle the special effects with target 'same_as_attack'
441
+ if (isSpecial) {
442
+ attackingGotchi.specialExpanded.effects.forEach((specialEffect) => {
443
+ // Only handle special effects here that have a target code of 'same_as_attack'
444
+ // Handle the rest after the action is done
445
+ // This is to ensure that these effects are not applied multiple times
446
+ // e.g. if the target is 'all_enemies' then we don't want to apply that here for every target
447
+
448
+ if (specialEffect.target === 'same_as_attack') {
449
+ // Handle the effect
450
+ const specialEffectResults = handleSpecialEffects(attackingTeam, attackingGotchi, target, specialEffect, rng)
451
+
452
+ if (specialEffectResults) {
453
+ if (specialEffectResults.actionEffect) {
454
+ if (targetActionEffect) {
455
+ // If target is same as the actionEffect then just add statuses to the actionEffect
456
+ if (targetActionEffect.target && targetActionEffect.target === specialEffectResults.actionEffect.target) {
457
+ targetActionEffect.statuses.push(...specialEffectResults.actionEffect.statuses)
458
+ } else {
459
+ // If not then add to additionalEffects
460
+ targetAdditionalEffects.push(specialEffectResults.actionEffect)
461
+ }
462
+ } else {
463
+ targetActionEffect = specialEffectResults.actionEffect
464
+ }
465
+ }
466
+
467
+ if (specialEffectResults.additionalEffects) {
468
+ targetAdditionalEffects.push(...specialEffectResults.additionalEffects)
469
+ }
470
+
471
+ if (specialEffectResults.statusesExpired) {
472
+ statusesExpired.push(...specialEffectResults.statusesExpired)
473
+ }
474
+
475
+ if (specialEffectResults.repeatAttack) {
476
+ repeatAttack = true
477
+ }
478
+ }
479
+ }
480
+ })
481
+ } else {
482
+ // If it's an auto attack then handle all the statuses that have attackEffects
483
+ const attackEffects = attackingGotchi.statuses.filter(status => {
484
+ const statusEffect = getStatusByCode(status)
485
+ return statusEffect.attackEffects
486
+ })
487
+
488
+ attackEffects.forEach((attackEffect) => {
489
+ if (attackEffect.effectChance && attackEffect.effectChance < 1 && rng() > attackEffect.effectChance) {
490
+ return
491
+ }
492
+
493
+ // 'apply_status', 'gain_status', 'remove_buff', 'cleanse_target', 'cleanse_self'
494
+ switch (attackEffect.type) {
495
+ case 'apply_status': {
496
+ if (focusCheck(attackingTeam, attackingGotchi, target, rng)) {
497
+ addStatusToGotchi(target, attackEffect.status)
498
+ targetActionEffect.statuses.push(attackEffect.status)
499
+ }
500
+ break
501
+ }
502
+ case 'gain_status': {
503
+ addStatusToGotchi(attackingGotchi, attackEffect.status)
504
+ targetAdditionalEffects.push({
505
+ target: attackingGotchi.id,
506
+ status: attackEffect.status,
507
+ outcome: 'success'
508
+ })
509
+ break
510
+ }
511
+ case 'remove_buff': {
512
+ if (focusCheck(attackingTeam, attackingGotchi, target, rng)) {
513
+ const buffs = target.statuses.filter(status => STATUSES[status].isBuff)
514
+
515
+ if (buffs.length) {
516
+ const randomBuff = buffs[Math.floor(rng() * buffs.length)]
517
+ statusesExpired.push({
518
+ target: target.id,
519
+ status: randomBuff
520
+ })
521
+
522
+ // Remove first instance of randomBuff (there may be multiple)
523
+ const index = target.statuses.indexOf(randomBuff)
524
+ target.statuses.splice(index, 1)
525
+ }
526
+ }
527
+ break
528
+ }
529
+ case 'cleanse_target': {
530
+ const debuffs = target.statuses.filter(status => STATUSES[status].isDebuff)
531
+
532
+ if (debuffs.length) {
533
+ const randomDebuff = debuffs[Math.floor(rng() * debuffs.length)]
534
+ statusesExpired.push({
535
+ target: target.id,
536
+ status: randomDebuff
537
+ })
538
+
539
+ // Remove first instance of randomDebuff (there may be multiple)
540
+ const index = target.statuses.indexOf(randomDebuff)
541
+ target.statuses.splice(index, 1)
542
+ }
543
+ break
544
+ }
545
+ case 'cleanse_self': {
546
+ const debuffs = attackingGotchi.statuses.filter(status => STATUSES[status].isDebuff)
547
+
548
+ if (debuffs.length) {
549
+ const randomDebuff = debuffs[Math.floor(rng() * debuffs.length)]
550
+ statusesExpired.push({
551
+ target: attackingGotchi.id,
552
+ status: randomDebuff
553
+ })
554
+
555
+ // Remove first instance of randomDebuff (there may be multiple)
556
+ const index = attackingGotchi.statuses.indexOf(randomDebuff)
557
+ attackingGotchi.statuses.splice(index, 1)
558
+ }
559
+ break
560
+ }
561
+ }
562
+ })
563
+
564
+ // Check for counter attack
565
+ if (target.statuses.includes('taunt') && target.health > 0) {
566
+ const counterDamageMultiplier = 0.5
567
+ const counterDamage = getDamage(target, attackingGotchi, counterDamageMultiplier)
568
+
569
+ attackingGotchi.health -= counterDamage
570
+
571
+ targetAdditionalEffects.push({
572
+ target: attackingGotchi.id,
573
+ source: target.id,
574
+ damage: counterDamage,
575
+ outcome: 'counter'
576
+ })
577
+
578
+ // Add to stats
579
+ target.stats.counters++
580
+ }
581
+ }
582
+
583
+ // If the actionType is 'none' then there may not be an actionEffect
584
+ if (targetActionEffect) {
585
+ actionEffects.push(targetActionEffect)
586
+ }
587
+
588
+ // Add additional effects to the effects array
589
+ additionalEffects.push(...targetAdditionalEffects)
590
+ })
591
+
592
+ // Handle specialEffects that are not 'same_as_attack'
593
+ if (isSpecial) {
594
+ attackingGotchi.specialExpanded.effects.forEach((specialEffect) => {
595
+ if (specialEffect.target !== 'same_as_attack') {
596
+ const targets = getTargetsFromCode(specialEffect.target, attackingGotchi, attackingTeam, defendingTeam, rng)
597
+
598
+ targets.forEach((target) => {
599
+ const specialEffectResults = handleSpecialEffects(attackingTeam, attackingGotchi, target, specialEffect, rng)
600
+
601
+ if (specialEffectResults) {
602
+ if (specialEffectResults.actionEffect) {
603
+ additionalEffects.push(specialEffectResults.actionEffect)
604
+ }
605
+
606
+ if (specialEffectResults.additionalEffects) {
607
+ additionalEffects.push(...specialEffectResults.additionalEffects)
608
+ }
609
+
610
+ if (specialEffectResults.statusesExpired) {
611
+ statusesExpired.push(...specialEffectResults.statusesExpired)
612
+ }
613
+
614
+ if (specialEffectResults.repeatAttack) {
615
+ repeatAttack = true
616
+ }
617
+ }
618
+ })
619
+ }
620
+ })
621
+ }
622
+
623
+ return {
624
+ actionEffects,
625
+ additionalEffects,
626
+ statusesExpired,
627
+ repeatAttack
628
+ }
629
+ }
630
+
631
+ const handleSpecialEffects = (attackingTeam, attackingGotchi, target, specialEffect, rng) => {
632
+ if (specialEffect.chance && specialEffect.chance < 1 && rng() > specialEffect.chance) {
633
+ return false
634
+ }
635
+
636
+ const actionEffect = {
637
+ target: target.id,
638
+ statuses: [],
639
+ outcome: 'success'
640
+ }
641
+
642
+ const additionalEffects = []
643
+ const statusesExpired = []
644
+ let repeatAttack = false
645
+
646
+ switch (specialEffect.effectType) {
647
+ case 'status': {
648
+ // Focus/resistance check if target is not on the same team as the attacking gotchi
649
+ if (focusCheck(attackingTeam, attackingGotchi, target, rng)) {
650
+ addStatusToGotchi(target, specialEffect.status)
651
+ actionEffect.statuses.push(specialEffect.status)
652
+ }
653
+ break
654
+ }
655
+ case 'heal': {
656
+ const amountToHeal = getHealFromMultiplier(target, specialEffect.actionMultiplier)
657
+ target.health += amountToHeal
658
+
659
+ // Add another effect for the healing
660
+ additionalEffects.push({
661
+ target: target.id,
662
+ source: attackingGotchi.id,
663
+ damage: -amountToHeal,
664
+ outcome: 'success'
665
+ })
666
+
667
+ // Handle stats
668
+ attackingGotchi.stats.healGiven += amountToHeal
669
+ target.stats.healReceived += amountToHeal
670
+ break
671
+ }
672
+ case 'remove_buff': {
673
+ // Focus/resistance check if target is not on the same team as the attacking gotchi
674
+ if (focusCheck(attackingTeam, attackingGotchi, target, rng)) {
675
+ const buffs = target.statuses.filter(statusCode => {
676
+ const status = getStatusByCode(statusCode)
677
+ return status.isBuff
678
+ })
679
+
680
+ if (buffs.length) {
681
+ const randomBuff = buffs[Math.floor(rng() * buffs.length)]
682
+ statusesExpired.push({
683
+ target: target.id,
684
+ status: randomBuff
685
+ })
686
+
687
+ // Remove first instance of randomBuff (there may be multiple)
688
+ const index = target.statuses.indexOf(randomBuff)
689
+ target.statuses.splice(index, 1)
690
+ }
691
+ }
692
+
693
+ break
694
+ }
695
+ case 'remove_debuff': {
696
+ const debuffs = target.statuses.filter(statusCode => {
697
+ const status = getStatusByCode(statusCode)
698
+ return !status.isBuff
699
+ })
700
+
701
+ if (debuffs.length) {
702
+ const randomDebuff = debuffs[Math.floor(rng() * debuffs.length)]
703
+ statusesExpired.push({
704
+ target: target.id,
705
+ status: randomDebuff
706
+ })
707
+
708
+ // Remove first instance of randomDebuff (there may be multiple)
709
+ const index = target.statuses.indexOf(randomDebuff)
710
+ target.statuses.splice(index, 1)
711
+ }
712
+ break
713
+ }
714
+ case 'remove_all_buffs': {
715
+ // Focus/resistance check if target is not on the same team as the attacking gotchi
716
+ if (focusCheck(attackingTeam, attackingGotchi, target, rng)) {
717
+ const buffsToRemove = target.statuses.filter(statusCode => {
718
+ const status = getStatusByCode(statusCode)
719
+ return status.isBuff
720
+ })
721
+
722
+ buffsToRemove.forEach((buff) => {
723
+ statusesExpired.push({
724
+ target: target.id,
725
+ status: buff
726
+ })
727
+ })
728
+
729
+ if (buffsToRemove.length) {
730
+ // Filter statuses so only debuffs remain
731
+ target.statuses = target.statuses.filter(statusCode => {
732
+ const status = getStatusByCode(statusCode)
733
+ return !status.isBuff
734
+ })
735
+ }
736
+ }
737
+
738
+ break
739
+ }
740
+ case 'remove_all_debuffs': {
741
+ const debuffsToRemove = target.statuses.filter(statusCode => {
742
+ const status = getStatusByCode(statusCode)
743
+ return !status.isBuff
744
+ })
745
+
746
+ debuffsToRemove.forEach((debuff) => {
747
+ statusesExpired.push({
748
+ target: target.id,
749
+ status: debuff
750
+ })
751
+ })
752
+
753
+ if (debuffsToRemove.length) {
754
+ // Filter statuses so only buffs remain
755
+ target.statuses = target.statuses.filter(statusCode => {
756
+ const status = getStatusByCode(statusCode)
757
+ return status.isBuff
758
+ })
759
+ }
760
+
761
+ break
762
+ }
763
+ case 'repeat_attack': {
764
+ repeatAttack = true
765
+ break
766
+ }
767
+ default:
768
+ throw new Error(`Invalid special effect type: ${specialEffect.effectType}`)
769
+ }
770
+
771
+ return {
772
+ actionEffect,
773
+ additionalEffects,
774
+ statusesExpired,
775
+ repeatAttack
776
+ }
777
+ }
778
+
779
+ module.exports = {
780
+ gameLoop,
781
+ attack
782
+ }