Fraser commited on
Commit
e78d70c
·
1 Parent(s): a6cd8d1

battle battle

Browse files
src/lib/battle-engine/BattleEngine.ts CHANGED
@@ -9,32 +9,70 @@ import type {
9
  PicletDefinition,
10
  BattleAction,
11
  MoveAction,
 
12
  BattleEffect,
13
  DamageAmount,
14
  StatModification,
15
  HealAmount,
16
  StatusEffect,
17
  BaseStats,
18
- Move
 
19
  } from './types';
20
  import { getEffectivenessMultiplier } from '../types/picletTypes';
21
 
22
  export class BattleEngine {
23
  private state: BattleState;
24
-
25
- constructor(playerPiclet: PicletDefinition, opponentPiclet: PicletDefinition, playerLevel = 50, opponentLevel = 50) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  this.state = {
27
  turn: 1,
28
  phase: 'selection',
29
- playerPiclet: this.createBattlePiclet(playerPiclet, playerLevel),
30
- opponentPiclet: this.createBattlePiclet(opponentPiclet, opponentLevel),
31
  fieldEffects: [],
32
  log: [],
33
  winner: undefined
34
  };
35
 
 
 
 
 
36
  this.log('Battle started!');
37
- this.log(`${playerPiclet.name} vs ${opponentPiclet.name}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  }
39
 
40
  private createBattlePiclet(definition: PicletDefinition, level: number): BattlePiclet {
@@ -112,6 +150,12 @@ export class BattleEngine {
112
  this.processTurnEnd();
113
  }
114
 
 
 
 
 
 
 
115
  // Check for battle end
116
  this.checkBattleEnd();
117
 
@@ -183,13 +227,18 @@ export class BattleEngine {
183
  if (action.type === 'move') {
184
  this.executeMove(action);
185
  } else if (action.type === 'switch') {
186
- this.log(`${action.executor} attempted to switch (not implemented)`);
187
  }
188
  }
189
 
190
  private executeMove(action: MoveAction & { executor: 'player' | 'opponent' }): void {
191
  const attacker = action.executor === 'player' ? this.state.playerPiclet : this.state.opponentPiclet;
192
  const defender = action.executor === 'player' ? this.state.opponentPiclet : this.state.playerPiclet;
 
 
 
 
 
193
 
194
  const moveData = attacker.moves[action.moveIndex];
195
  if (!moveData || moveData.currentPP <= 0) {
@@ -198,17 +247,26 @@ export class BattleEngine {
198
  }
199
 
200
  const move = moveData.move;
 
 
 
 
201
  this.log(`${attacker.definition.name} used ${move.name}!`);
202
 
203
  // Consume PP
204
  moveData.currentPP--;
205
 
206
  // Check if move hits
207
- if (!this.checkMoveHits(move, attacker, defender)) {
 
208
  this.log(`${attacker.definition.name}'s attack missed!`);
 
209
  return;
210
  }
211
 
 
 
 
212
  // For gambling/luck-based moves, roll once and store the result
213
  const luckyRoll = Math.random() < 0.5;
214
 
@@ -216,6 +274,127 @@ export class BattleEngine {
216
  for (const effect of move.effects) {
217
  this.processEffect(effect, attacker, defender, move, luckyRoll);
218
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  }
220
 
221
  private checkMoveHits(move: Move, _attacker: BattlePiclet, _defender: BattlePiclet): boolean {
@@ -400,12 +579,20 @@ export class BattleEngine {
400
  // Apply damage multiplier from abilities
401
  const damageMultiplier = this.getDamageMultiplier(attacker);
402
  damage = Math.floor(damage * damageMultiplier);
 
 
 
 
 
403
 
404
  // Check for critical hits
405
  const critMod = this.checkCriticalHitModification(attacker, target);
406
- if (critMod === 'always' || (critMod === 'normal' && Math.random() < 0.0625)) { // 1/16 base crit rate
 
407
  damage = Math.floor(damage * 1.5);
408
  this.log("A critical hit!");
 
 
409
  }
410
 
411
  // Apply damage
@@ -413,6 +600,14 @@ export class BattleEngine {
413
  target.currentHp = Math.max(0, target.currentHp - damage);
414
  this.log(`${target.definition.name} took ${damage} damage!`);
415
 
 
 
 
 
 
 
 
 
416
  // Check for counter effects on the target
417
  this.checkCounterEffects(target, attacker, move);
418
  }
@@ -423,6 +618,7 @@ export class BattleEngine {
423
  attacker.currentHp = Math.min(attacker.maxHp, attacker.currentHp + healAmount);
424
  if (healAmount > 0) {
425
  this.log(`${attacker.definition.name} recovered ${healAmount} HP from draining!`);
 
426
  }
427
  } else if (effect.formula === 'recoil') {
428
  const recoilDamage = Math.floor(damage * (effect.value || 0.25));
@@ -544,30 +740,12 @@ export class BattleEngine {
544
  (target as any)[statKey] = Math.floor((target as any)[statKey] * multiplier);
545
  }
546
 
 
547
  this.log(`${target.definition.name}'s ${stat} ${modification.includes('increase') ? 'rose' : 'fell'}!`);
 
548
  }
549
  }
550
 
551
- private processApplyStatusEffect(effect: { status: StatusEffect; chance?: number }, target: BattlePiclet): void {
552
- // Check chance if specified
553
- if (effect.chance !== undefined) {
554
- const roll = Math.random() * 100;
555
- if (roll >= effect.chance) {
556
- return; // Status effect failed to apply
557
- }
558
- }
559
-
560
- // Check for status immunity
561
- if (this.checkStatusImmunity(target, effect.status)) {
562
- this.log(`${target.definition.name} is immune to ${effect.status}!`);
563
- return;
564
- }
565
-
566
- if (!target.statusEffects.includes(effect.status)) {
567
- target.statusEffects.push(effect.status);
568
- this.log(`${target.definition.name} was ${effect.status}ed!`);
569
- }
570
- }
571
 
572
  private processHealEffect(effect: { amount?: HealAmount; formula?: string; value?: number }, target: BattlePiclet): void {
573
  let healAmount = 0;
@@ -605,6 +783,7 @@ export class BattleEngine {
605
 
606
  if (actualHeal > 0) {
607
  this.log(`${target.definition.name} recovered ${actualHeal} HP!`);
 
608
  }
609
  }
610
  }
@@ -644,16 +823,27 @@ export class BattleEngine {
644
  this.processStatusEffects(this.state.playerPiclet);
645
  this.processStatusEffects(this.state.opponentPiclet);
646
 
647
- // Process field effects
 
 
 
648
  this.processFieldEffects();
649
 
 
 
 
650
  // Decrement temporary effects
651
  this.processTemporaryEffects(this.state.playerPiclet);
652
  this.processTemporaryEffects(this.state.opponentPiclet);
653
  }
654
 
655
  private processStatusEffects(piclet: BattlePiclet): void {
656
- for (const status of piclet.statusEffects) {
 
 
 
 
 
657
  switch (status) {
658
  case 'burn':
659
  case 'poison':
@@ -661,7 +851,56 @@ export class BattleEngine {
661
  piclet.currentHp = Math.max(0, piclet.currentHp - damage);
662
  this.log(`${piclet.definition.name} was hurt by ${status}!`);
663
  break;
664
- // Other status effects can be implemented later
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
665
  }
666
  }
667
  }
@@ -675,52 +914,209 @@ export class BattleEngine {
675
  }
676
 
677
  private processFieldEffects(): void {
678
- // Process field effects at end of turn
679
- for (const fieldEffect of this.state.fieldEffects) {
680
- switch (fieldEffect.name) {
681
- case 'spikes':
682
- // Spikes damage any piclet that switches in (for now, just log)
683
- this.log('Spikes are scattered on the battlefield!');
684
- break;
685
- case 'stealth_rock':
686
- // Stealth Rock damages based on type effectiveness
687
- this.log('Pointed stones float in the air!');
688
- break;
689
- case 'reflect':
690
- // Reduce physical damage
691
- this.log('A barrier reflects physical attacks!');
692
- break;
693
- case 'light_screen':
694
- // Reduce special damage
695
- this.log('A barrier weakens special attacks!');
696
- break;
697
- }
698
- }
699
 
700
- // Decrement field effect durations
701
  this.state.fieldEffects = this.state.fieldEffects.filter(effect => {
702
  effect.duration--;
703
  if (effect.duration <= 0) {
704
- this.log(`${effect.name} faded away!`);
705
  return false;
706
  }
707
  return true;
708
  });
709
  }
710
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
711
  private checkBattleEnd(): void {
712
- if (this.state.playerPiclet.currentHp <= 0 && this.state.opponentPiclet.currentHp <= 0) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
713
  this.state.winner = 'draw';
714
  this.state.phase = 'ended';
715
  this.log('Battle ended in a draw!');
716
- } else if (this.state.playerPiclet.currentHp <= 0) {
717
  this.state.winner = 'opponent';
718
  this.state.phase = 'ended';
719
  this.log(`${this.state.opponentPiclet.definition.name} wins!`);
720
- } else if (this.state.opponentPiclet.currentHp <= 0) {
721
  this.state.winner = 'player';
722
  this.state.phase = 'ended';
723
  this.log(`${this.state.playerPiclet.definition.name} wins!`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
724
  }
725
  }
726
 
@@ -784,34 +1180,50 @@ export class BattleEngine {
784
  }
785
 
786
  private processFieldEffect(effect: { effect: string; target: string; stackable?: boolean }): void {
 
 
 
 
 
 
 
 
 
 
 
787
  // Add field effect to battle state
788
  const fieldEffect = {
789
- name: effect.effect,
790
  duration: 5, // Default duration
791
  effect: effect
792
  };
793
 
794
  // Check if effect already exists and is not stackable
795
  if (!effect.stackable) {
796
- this.state.fieldEffects = this.state.fieldEffects.filter(fe => fe.name !== effect.effect);
797
  }
798
 
799
  this.state.fieldEffects.push(fieldEffect);
800
- switch (effect.effect) {
801
- case 'spikes':
802
- this.log('Spikes were set on the field!');
 
 
803
  break;
804
- case 'reflect':
805
- this.log('Reflect was applied to the field!');
806
  break;
807
- case 'lightScreen':
808
- this.log('Light Screen was applied to the field!');
809
  break;
810
- case 'stealthRock':
811
- this.log('Stealth Rock was applied to the field!');
 
 
 
812
  break;
813
  default:
814
- this.log(`${effect.effect.charAt(0).toUpperCase() + effect.effect.slice(1)} was applied to the field!`);
815
  }
816
  }
817
 
@@ -989,4 +1401,265 @@ export class BattleEngine {
989
  }
990
  }
991
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
992
  }
 
9
  PicletDefinition,
10
  BattleAction,
11
  MoveAction,
12
+ SwitchAction,
13
  BattleEffect,
14
  DamageAmount,
15
  StatModification,
16
  HealAmount,
17
  StatusEffect,
18
  BaseStats,
19
+ Move,
20
+ Trigger
21
  } from './types';
22
  import { getEffectivenessMultiplier } from '../types/picletTypes';
23
 
24
  export class BattleEngine {
25
  private state: BattleState;
26
+ private playerRoster: PicletDefinition[];
27
+ private opponentRoster: PicletDefinition[];
28
+ private playerRosterStates: Array<{ currentHp: number; maxHp: number; fainted: boolean; moves: Array<{move: Move; currentPP: number}> }>;
29
+ private opponentRosterStates: Array<{ currentHp: number; maxHp: number; fainted: boolean; moves: Array<{move: Move; currentPP: number}> }>;
30
+
31
+ constructor(
32
+ playerPiclet: PicletDefinition | PicletDefinition[],
33
+ opponentPiclet: PicletDefinition | PicletDefinition[],
34
+ playerLevel = 50,
35
+ opponentLevel = 50
36
+ ) {
37
+ // Handle roster setup
38
+ this.playerRoster = Array.isArray(playerPiclet) ? playerPiclet : [playerPiclet];
39
+ this.opponentRoster = Array.isArray(opponentPiclet) ? opponentPiclet : [opponentPiclet];
40
+
41
+ // Initialize roster states
42
+ this.playerRosterStates = this.initializeRosterStates(this.playerRoster, playerLevel);
43
+ this.opponentRosterStates = this.initializeRosterStates(this.opponentRoster, opponentLevel);
44
  this.state = {
45
  turn: 1,
46
  phase: 'selection',
47
+ playerPiclet: this.createBattlePiclet(this.playerRoster[0], playerLevel),
48
+ opponentPiclet: this.createBattlePiclet(this.opponentRoster[0], opponentLevel),
49
  fieldEffects: [],
50
  log: [],
51
  winner: undefined
52
  };
53
 
54
+ // Sync initial states to roster for consistency
55
+ this.syncActivePicketToRoster('player');
56
+ this.syncActivePicketToRoster('opponent');
57
+
58
  this.log('Battle started!');
59
+ this.log(`${this.playerRoster[0].name} vs ${this.opponentRoster[0].name}`);
60
+ }
61
+
62
+ private initializeRosterStates(roster: PicletDefinition[], level: number): Array<{ currentHp: number; maxHp: number; fainted: boolean; moves: Array<{move: Move; currentPP: number}> }> {
63
+ return roster.map(piclet => {
64
+ const statMultiplier = 1 + (level - 50) * 0.02;
65
+ const hp = Math.floor(piclet.baseStats.hp * statMultiplier);
66
+ return {
67
+ currentHp: hp,
68
+ maxHp: hp,
69
+ fainted: false,
70
+ moves: piclet.movepool.slice(0, 4).map(move => ({
71
+ move,
72
+ currentPP: move.pp
73
+ }))
74
+ };
75
+ });
76
  }
77
 
78
  private createBattlePiclet(definition: PicletDefinition, level: number): BattlePiclet {
 
150
  this.processTurnEnd();
151
  }
152
 
153
+ // Sync active piclets to roster to preserve state changes
154
+ if ((this.state.phase as string) !== 'ended') {
155
+ this.syncActivePicketToRoster('player');
156
+ this.syncActivePicketToRoster('opponent');
157
+ }
158
+
159
  // Check for battle end
160
  this.checkBattleEnd();
161
 
 
227
  if (action.type === 'move') {
228
  this.executeMove(action);
229
  } else if (action.type === 'switch') {
230
+ this.executeSwitch(action as SwitchAction & { executor: 'player' | 'opponent' });
231
  }
232
  }
233
 
234
  private executeMove(action: MoveAction & { executor: 'player' | 'opponent' }): void {
235
  const attacker = action.executor === 'player' ? this.state.playerPiclet : this.state.opponentPiclet;
236
  const defender = action.executor === 'player' ? this.state.opponentPiclet : this.state.playerPiclet;
237
+
238
+ // Check if attacker can act due to status effects
239
+ if (!this.canPicletAct(attacker)) {
240
+ return; // Skip this action
241
+ }
242
 
243
  const moveData = attacker.moves[action.moveIndex];
244
  if (!moveData || moveData.currentPP <= 0) {
 
247
  }
248
 
249
  const move = moveData.move;
250
+
251
+ // Trigger before move use
252
+ this.triggerBeforeMoveUse(attacker, move);
253
+
254
  this.log(`${attacker.definition.name} used ${move.name}!`);
255
 
256
  // Consume PP
257
  moveData.currentPP--;
258
 
259
  // Check if move hits
260
+ const moveHit = this.checkMoveHits(move, attacker, defender);
261
+ if (!moveHit) {
262
  this.log(`${attacker.definition.name}'s attack missed!`);
263
+ this.triggerAfterMoveUse(attacker, move, false);
264
  return;
265
  }
266
 
267
+ // Trigger opponent contact move (if applicable)
268
+ this.triggerOnOpponentContactMove(defender, attacker, move);
269
+
270
  // For gambling/luck-based moves, roll once and store the result
271
  const luckyRoll = Math.random() < 0.5;
272
 
 
274
  for (const effect of move.effects) {
275
  this.processEffect(effect, attacker, defender, move, luckyRoll);
276
  }
277
+
278
+ // Trigger after move use
279
+ this.triggerAfterMoveUse(attacker, move, true);
280
+ }
281
+
282
+ private executeSwitch(action: SwitchAction & { executor: 'player' | 'opponent' }): void {
283
+ const isPlayer = action.executor === 'player';
284
+ const roster = isPlayer ? this.playerRoster : this.opponentRoster;
285
+ const rosterStates = isPlayer ? this.playerRosterStates : this.opponentRosterStates;
286
+ const currentPiclet = isPlayer ? this.state.playerPiclet : this.state.opponentPiclet;
287
+
288
+ // Validate switch action
289
+ if (action.newPicletIndex < 0 || action.newPicletIndex >= roster.length) {
290
+ this.log(`${action.executor} cannot switch - invalid piclet index!`);
291
+ return;
292
+ }
293
+
294
+ if (action.newPicletIndex === this.getCurrentPicletIndex(action.executor)) {
295
+ this.log(`${roster[action.newPicletIndex].name} is already active!`);
296
+ return;
297
+ }
298
+
299
+ if (rosterStates[action.newPicletIndex].fainted) {
300
+ this.log(`${roster[action.newPicletIndex].name} is unable to battle!`);
301
+ return;
302
+ }
303
+
304
+ const oldPiclet = currentPiclet;
305
+ const newPicletDef = roster[action.newPicletIndex];
306
+
307
+ // Trigger switch-out ability
308
+ this.triggerOnSwitchOut(oldPiclet);
309
+
310
+ // Save current piclet state back to roster
311
+ this.savePicletToRoster(oldPiclet, action.executor);
312
+
313
+ // Load new piclet from roster
314
+ const newPiclet = this.loadPicletFromRoster(action.newPicletIndex, action.executor);
315
+
316
+ // Update battle state
317
+ if (isPlayer) {
318
+ this.state.playerPiclet = newPiclet;
319
+ } else {
320
+ this.state.opponentPiclet = newPiclet;
321
+ }
322
+
323
+ this.log(`${action.executor} switched to ${newPicletDef.name}!`);
324
+
325
+ // Apply entry hazards
326
+ this.applyEntryHazards(newPiclet);
327
+
328
+ // Trigger switch-in ability
329
+ this.triggerOnSwitchIn(newPiclet);
330
+ }
331
+
332
+ private getCurrentPicletIndex(executor: 'player' | 'opponent'): number {
333
+ const isPlayer = executor === 'player';
334
+ const roster = isPlayer ? this.playerRoster : this.opponentRoster;
335
+ const currentPiclet = isPlayer ? this.state.playerPiclet : this.state.opponentPiclet;
336
+
337
+ return roster.findIndex(piclet => piclet.name === currentPiclet.definition.name);
338
+ }
339
+
340
+ private savePicletToRoster(piclet: BattlePiclet, executor: 'player' | 'opponent'): void {
341
+ const isPlayer = executor === 'player';
342
+ const rosterStates = isPlayer ? this.playerRosterStates : this.opponentRosterStates;
343
+ const currentIndex = this.getCurrentPicletIndex(executor);
344
+
345
+ if (currentIndex !== -1) {
346
+ // Save current state back to roster
347
+ rosterStates[currentIndex].currentHp = piclet.currentHp;
348
+ rosterStates[currentIndex].fainted = piclet.currentHp <= 0;
349
+
350
+ // Save current PP state
351
+ for (let i = 0; i < piclet.moves.length; i++) {
352
+ if (rosterStates[currentIndex].moves[i]) {
353
+ rosterStates[currentIndex].moves[i].currentPP = piclet.moves[i].currentPP;
354
+ }
355
+ }
356
+ }
357
+ }
358
+
359
+ private syncActivePicketToRoster(executor: 'player' | 'opponent'): void {
360
+ const piclet = executor === 'player' ? this.state.playerPiclet : this.state.opponentPiclet;
361
+ this.savePicletToRoster(piclet, executor);
362
+ }
363
+
364
+ private loadPicletFromRoster(index: number, executor: 'player' | 'opponent'): BattlePiclet {
365
+ const isPlayer = executor === 'player';
366
+ const roster = isPlayer ? this.playerRoster : this.opponentRoster;
367
+ const rosterStates = isPlayer ? this.playerRosterStates : this.opponentRosterStates;
368
+ const level = isPlayer ? this.state.playerPiclet.level : this.state.opponentPiclet.level;
369
+
370
+ const definition = roster[index];
371
+ const savedState = rosterStates[index];
372
+
373
+ // Create fresh battle piclet
374
+ const piclet = this.createBattlePiclet(definition, level);
375
+
376
+ // Restore saved state
377
+ piclet.currentHp = savedState.currentHp;
378
+
379
+ // Restore PP
380
+ for (let i = 0; i < piclet.moves.length; i++) {
381
+ if (savedState.moves[i]) {
382
+ piclet.moves[i].currentPP = savedState.moves[i].currentPP;
383
+ }
384
+ }
385
+
386
+ // Reset stat modifications (switching clears temporary stat changes)
387
+ piclet.statModifiers = {};
388
+
389
+ return piclet;
390
+ }
391
+
392
+ private triggerOnSwitchIn(piclet: BattlePiclet): void {
393
+ this.triggerAbilities('onSwitchIn', piclet);
394
+ }
395
+
396
+ private triggerOnSwitchOut(piclet: BattlePiclet): void {
397
+ this.triggerAbilities('onSwitchOut', piclet);
398
  }
399
 
400
  private checkMoveHits(move: Move, _attacker: BattlePiclet, _defender: BattlePiclet): boolean {
 
579
  // Apply damage multiplier from abilities
580
  const damageMultiplier = this.getDamageMultiplier(attacker);
581
  damage = Math.floor(damage * damageMultiplier);
582
+
583
+ // Apply field effect damage multipliers
584
+ const isPlayerAttacking = attacker === this.state.playerPiclet;
585
+ const fieldEffectMultiplier = this.getFieldEffectDamageMultiplier(move, isPlayerAttacking);
586
+ damage = Math.floor(damage * fieldEffectMultiplier);
587
 
588
  // Check for critical hits
589
  const critMod = this.checkCriticalHitModification(attacker, target);
590
+ const isCriticalHit = critMod === 'always' || (critMod === 'normal' && Math.random() < 0.0625);
591
+ if (isCriticalHit) { // 1/16 base crit rate
592
  damage = Math.floor(damage * 1.5);
593
  this.log("A critical hit!");
594
+ // Trigger critical hit ability
595
+ this.triggerOnCriticalHit(attacker, target);
596
  }
597
 
598
  // Apply damage
 
600
  target.currentHp = Math.max(0, target.currentHp - damage);
601
  this.log(`${target.definition.name} took ${damage} damage!`);
602
 
603
+ // Wake up from sleep when damaged
604
+ this.wakeUpFromSleep(target);
605
+
606
+ // Trigger ability events
607
+ this.triggerOnDamageTaken(target, damage, move.flags.includes('contact'));
608
+ this.triggerOnDamageDealt(attacker, damage, target);
609
+ this.triggerOnLowHP(target);
610
+
611
  // Check for counter effects on the target
612
  this.checkCounterEffects(target, attacker, move);
613
  }
 
618
  attacker.currentHp = Math.min(attacker.maxHp, attacker.currentHp + healAmount);
619
  if (healAmount > 0) {
620
  this.log(`${attacker.definition.name} recovered ${healAmount} HP from draining!`);
621
+ this.triggerOnHPDrained(attacker, target, healAmount);
622
  }
623
  } else if (effect.formula === 'recoil') {
624
  const recoilDamage = Math.floor(damage * (effect.value || 0.25));
 
740
  (target as any)[statKey] = Math.floor((target as any)[statKey] * multiplier);
741
  }
742
 
743
+ const changeType = modification.includes('increase') ? 'increase' : 'decrease';
744
  this.log(`${target.definition.name}'s ${stat} ${modification.includes('increase') ? 'rose' : 'fell'}!`);
745
+ this.triggerOnStatChange(target, stat, changeType);
746
  }
747
  }
748
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
749
 
750
  private processHealEffect(effect: { amount?: HealAmount; formula?: string; value?: number }, target: BattlePiclet): void {
751
  let healAmount = 0;
 
783
 
784
  if (actualHeal > 0) {
785
  this.log(`${target.definition.name} recovered ${actualHeal} HP!`);
786
+ this.triggerOnFullHP(target);
787
  }
788
  }
789
  }
 
823
  this.processStatusEffects(this.state.playerPiclet);
824
  this.processStatusEffects(this.state.opponentPiclet);
825
 
826
+ // Apply field healing effects
827
+ this.applyFieldHealingEffects();
828
+
829
+ // Process field effects (duration management)
830
  this.processFieldEffects();
831
 
832
+ // Trigger end of turn abilities
833
+ this.triggerEndOfTurn();
834
+
835
  // Decrement temporary effects
836
  this.processTemporaryEffects(this.state.playerPiclet);
837
  this.processTemporaryEffects(this.state.opponentPiclet);
838
  }
839
 
840
  private processStatusEffects(piclet: BattlePiclet): void {
841
+ // Process status effects that trigger at end of turn
842
+ const statusesToRemove: string[] = [];
843
+
844
+ for (let i = 0; i < piclet.statusEffects.length; i++) {
845
+ const status = piclet.statusEffects[i];
846
+
847
  switch (status) {
848
  case 'burn':
849
  case 'poison':
 
851
  piclet.currentHp = Math.max(0, piclet.currentHp - damage);
852
  this.log(`${piclet.definition.name} was hurt by ${status}!`);
853
  break;
854
+
855
+ case 'freeze':
856
+ // Don't process freeze on the turn it was applied
857
+ if ((piclet as any).freezeJustApplied) {
858
+ delete (piclet as any).freezeJustApplied;
859
+ } else {
860
+ // 20% chance to thaw out each turn
861
+ if (Math.random() < 0.2) {
862
+ statusesToRemove.push(status);
863
+ this.log(`${piclet.definition.name} thawed out!`);
864
+ }
865
+ }
866
+ break;
867
+
868
+ case 'sleep':
869
+ // Don't process sleep on the turn it was applied
870
+ if ((piclet as any).sleepJustApplied) {
871
+ delete (piclet as any).sleepJustApplied;
872
+ } else {
873
+ // Decrement sleep turns and wake up
874
+ const sleepTurns = (piclet as any).sleepTurns || 0;
875
+ if (sleepTurns <= 1) {
876
+ statusesToRemove.push(status);
877
+ this.log(`${piclet.definition.name} woke up!`);
878
+ delete (piclet as any).sleepTurns;
879
+ } else {
880
+ (piclet as any).sleepTurns = sleepTurns - 1;
881
+ }
882
+ }
883
+ break;
884
+
885
+ case 'confuse':
886
+ // Decrement confusion turns
887
+ const confusionTurns = (piclet as any).confusionTurns || 0;
888
+ if (confusionTurns <= 1) {
889
+ statusesToRemove.push(status);
890
+ this.log(`${piclet.definition.name} snapped out of confusion!`);
891
+ delete (piclet as any).confusionTurns;
892
+ } else {
893
+ (piclet as any).confusionTurns = confusionTurns - 1;
894
+ }
895
+ break;
896
+ }
897
+ }
898
+
899
+ // Remove statuses that expired
900
+ for (const statusToRemove of statusesToRemove) {
901
+ const index = piclet.statusEffects.indexOf(statusToRemove as any);
902
+ if (index > -1) {
903
+ piclet.statusEffects.splice(index, 1);
904
  }
905
  }
906
  }
 
914
  }
915
 
916
  private processFieldEffects(): void {
917
+ // Field effects are processed at end of turn for duration management
918
+ // Their actual mechanics are applied during relevant battle phases
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
919
 
920
+ // Decrement field effect durations and remove expired ones
921
  this.state.fieldEffects = this.state.fieldEffects.filter(effect => {
922
  effect.duration--;
923
  if (effect.duration <= 0) {
924
+ this.log(`${this.formatFieldEffectName(effect.name)} faded away!`);
925
  return false;
926
  }
927
  return true;
928
  });
929
  }
930
 
931
+ private formatFieldEffectName(effectName: string): string {
932
+ switch (effectName) {
933
+ case 'entryHazardSpikes': return 'Entry spikes';
934
+ case 'contactDamageReduction': return 'Contact damage barrier';
935
+ case 'nonContactDamageReduction': return 'Non-contact damage barrier';
936
+ case 'healingField': return 'Healing field';
937
+ case 'poisonousField': return 'Poisonous field';
938
+ default: return effectName;
939
+ }
940
+ }
941
+
942
+ private getFieldEffectDamageMultiplier(move: Move, isPlayerAttacking: boolean): number {
943
+ let multiplier = 1.0;
944
+
945
+ // Determine if this is a contact move
946
+ const isContactMove = move.flags.includes('contact');
947
+
948
+ // Check field effects that modify damage
949
+ for (const fieldEffect of this.state.fieldEffects) {
950
+ const targetSide = fieldEffect.effect.target;
951
+ // Field effects protect the side they're applied to from incoming attacks
952
+ // So if playerSide has a barrier, it protects player from opponent attacks
953
+ const protectsDefender = (!isPlayerAttacking && targetSide === 'playerSide') ||
954
+ (isPlayerAttacking && targetSide === 'opponentSide');
955
+
956
+ if (!protectsDefender) continue;
957
+
958
+ switch (fieldEffect.name) {
959
+ case 'contactDamageReduction':
960
+ if (isContactMove) {
961
+ multiplier *= 0.5; // Reduce contact move damage by 50%
962
+ }
963
+ break;
964
+ case 'nonContactDamageReduction':
965
+ if (!isContactMove) {
966
+ multiplier *= 0.5; // Reduce non-contact move damage by 50%
967
+ }
968
+ break;
969
+ }
970
+ }
971
+
972
+ return multiplier;
973
+ }
974
+
975
+ private applyEntryHazards(piclet: BattlePiclet): void {
976
+ // Apply entry hazards when a piclet enters battle (switching mechanics)
977
+ for (const fieldEffect of this.state.fieldEffects) {
978
+ if (fieldEffect.name === 'spikes' || fieldEffect.name === 'entryHazardSpikes') {
979
+ const targetSide = fieldEffect.effect.target;
980
+ const isPlayerSide = piclet === this.state.playerPiclet;
981
+ const appliesTo = (isPlayerSide && targetSide === 'playerSide') ||
982
+ (!isPlayerSide && targetSide === 'opponentSide');
983
+
984
+ if (appliesTo) {
985
+ const damage = Math.floor(piclet.maxHp * 0.125); // 12.5% max HP damage
986
+ piclet.currentHp = Math.max(0, piclet.currentHp - damage);
987
+ this.log(`${piclet.definition.name} was hurt by spikes!`);
988
+ }
989
+ } else if (fieldEffect.name === 'toxicSpikes') {
990
+ const targetSide = fieldEffect.effect.target;
991
+ const isPlayerSide = piclet === this.state.playerPiclet;
992
+ const appliesTo = (isPlayerSide && targetSide === 'playerSide') ||
993
+ (!isPlayerSide && targetSide === 'opponentSide');
994
+
995
+ if (appliesTo && !piclet.statusEffects.includes('poison')) {
996
+ piclet.statusEffects.push('poison');
997
+ this.log(`${piclet.definition.name} was poisoned by toxic spikes!`);
998
+ }
999
+ } else if (fieldEffect.name === 'poisonousField') {
1000
+ const targetSide = fieldEffect.effect.target;
1001
+ const isPlayerSide = piclet === this.state.playerPiclet;
1002
+ const appliesTo = (isPlayerSide && targetSide === 'playerSide') ||
1003
+ (!isPlayerSide && targetSide === 'opponentSide');
1004
+
1005
+ if (appliesTo && !piclet.statusEffects.includes('poison')) {
1006
+ piclet.statusEffects.push('poison');
1007
+ this.log(`${piclet.definition.name} was poisoned by toxic spikes!`);
1008
+ }
1009
+ }
1010
+ }
1011
+ }
1012
+
1013
+ private applyFieldHealingEffects(): void {
1014
+ // Apply healing field effects at end of turn
1015
+ const healingFields = this.state.fieldEffects.filter(effect => effect.name === 'healingField');
1016
+
1017
+ for (const healingField of healingFields) {
1018
+ const targetSide = healingField.effect.target;
1019
+
1020
+ if (targetSide === 'playerSide' || targetSide === 'field') {
1021
+ const healAmount = Math.floor(this.state.playerPiclet.maxHp * 0.0625); // 6.25% max HP
1022
+ if (this.state.playerPiclet.currentHp < this.state.playerPiclet.maxHp) {
1023
+ this.state.playerPiclet.currentHp = Math.min(
1024
+ this.state.playerPiclet.maxHp,
1025
+ this.state.playerPiclet.currentHp + healAmount
1026
+ );
1027
+ this.log(`${this.state.playerPiclet.definition.name} was healed by the healing field!`);
1028
+ }
1029
+ }
1030
+
1031
+ if (targetSide === 'opponentSide' || targetSide === 'field') {
1032
+ const healAmount = Math.floor(this.state.opponentPiclet.maxHp * 0.0625); // 6.25% max HP
1033
+ if (this.state.opponentPiclet.currentHp < this.state.opponentPiclet.maxHp) {
1034
+ this.state.opponentPiclet.currentHp = Math.min(
1035
+ this.state.opponentPiclet.maxHp,
1036
+ this.state.opponentPiclet.currentHp + healAmount
1037
+ );
1038
+ this.log(`${this.state.opponentPiclet.definition.name} was healed by the healing field!`);
1039
+ }
1040
+ }
1041
+ }
1042
+ }
1043
+
1044
+
1045
  private checkBattleEnd(): void {
1046
+ const playerFainted = this.state.playerPiclet.currentHp <= 0;
1047
+ const opponentFainted = this.state.opponentPiclet.currentHp <= 0;
1048
+
1049
+ // Mark fainted piclets in roster states and trigger KO events
1050
+ if (playerFainted) {
1051
+ const playerIndex = this.getCurrentPicletIndex('player');
1052
+ if (playerIndex !== -1) {
1053
+ this.playerRosterStates[playerIndex].fainted = true;
1054
+ this.triggerOnKO(this.state.playerPiclet, this.state.opponentPiclet);
1055
+ }
1056
+ }
1057
+
1058
+ if (opponentFainted) {
1059
+ const opponentIndex = this.getCurrentPicletIndex('opponent');
1060
+ if (opponentIndex !== -1) {
1061
+ this.opponentRosterStates[opponentIndex].fainted = true;
1062
+ this.triggerOnKO(this.state.opponentPiclet, this.state.playerPiclet);
1063
+ }
1064
+ }
1065
+
1066
+ // Check if any viable piclets remain
1067
+ const playerHasViablePiclets = this.playerRosterStates.some(state => !state.fainted);
1068
+ const opponentHasViablePiclets = this.opponentRosterStates.some(state => !state.fainted);
1069
+
1070
+ if (!playerHasViablePiclets && !opponentHasViablePiclets) {
1071
  this.state.winner = 'draw';
1072
  this.state.phase = 'ended';
1073
  this.log('Battle ended in a draw!');
1074
+ } else if (!playerHasViablePiclets) {
1075
  this.state.winner = 'opponent';
1076
  this.state.phase = 'ended';
1077
  this.log(`${this.state.opponentPiclet.definition.name} wins!`);
1078
+ } else if (!opponentHasViablePiclets) {
1079
  this.state.winner = 'player';
1080
  this.state.phase = 'ended';
1081
  this.log(`${this.state.playerPiclet.definition.name} wins!`);
1082
+ } else if (playerFainted || opponentFainted) {
1083
+ // Handle forced switching - at least one piclet fainted but viable alternatives exist
1084
+ this.handleForcedSwitching(playerFainted, opponentFainted);
1085
+ }
1086
+ }
1087
+
1088
+ private handleForcedSwitching(playerFainted: boolean, opponentFainted: boolean): void {
1089
+ if (playerFainted) {
1090
+ this.log(`${this.state.playerPiclet.definition.name} fainted!`);
1091
+ const viablePiclets = this.playerRosterStates.map((state, index) => ({ index, state }))
1092
+ .filter(entry => !entry.state.fainted);
1093
+
1094
+ if (viablePiclets.length === 1) {
1095
+ // Auto-switch to the only remaining piclet
1096
+ const autoSwitchIndex = viablePiclets[0].index;
1097
+ this.log(`Player must choose a new piclet! Auto-switching to ${this.playerRoster[autoSwitchIndex].name}!`);
1098
+ this.executeSwitch({ type: 'switch', piclet: 'player', newPicletIndex: autoSwitchIndex, executor: 'player' });
1099
+ } else {
1100
+ this.log(`Player must choose a new piclet from ${viablePiclets.length} remaining options!`);
1101
+ // In a real implementation, this would pause and wait for player input
1102
+ // For testing, we can simulate choosing the first available
1103
+ }
1104
+ }
1105
+
1106
+ if (opponentFainted) {
1107
+ this.log(`${this.state.opponentPiclet.definition.name} fainted!`);
1108
+ const viablePiclets = this.opponentRosterStates.map((state, index) => ({ index, state }))
1109
+ .filter(entry => !entry.state.fainted);
1110
+
1111
+ if (viablePiclets.length === 1) {
1112
+ // Auto-switch to the only remaining piclet
1113
+ const autoSwitchIndex = viablePiclets[0].index;
1114
+ this.log(`Opponent must choose a new piclet! Auto-switching to ${this.opponentRoster[autoSwitchIndex].name}!`);
1115
+ this.executeSwitch({ type: 'switch', piclet: 'opponent', newPicletIndex: autoSwitchIndex, executor: 'opponent' });
1116
+ } else {
1117
+ this.log(`Opponent must choose a new piclet from ${viablePiclets.length} remaining options!`);
1118
+ // In a real implementation, this would be AI logic
1119
+ }
1120
  }
1121
  }
1122
 
 
1180
  }
1181
 
1182
  private processFieldEffect(effect: { effect: string; target: string; stackable?: boolean }): void {
1183
+ // Map old effect names to new descriptive names
1184
+ const effectNameMap: Record<string, string> = {
1185
+ 'spikes': 'entryHazardSpikes',
1186
+ 'reflect': 'contactDamageReduction',
1187
+ 'lightScreen': 'nonContactDamageReduction',
1188
+ 'healingMist': 'healingField',
1189
+ 'toxicSpikes': 'poisonousField'
1190
+ };
1191
+
1192
+ const mappedName = effectNameMap[effect.effect] || effect.effect;
1193
+
1194
  // Add field effect to battle state
1195
  const fieldEffect = {
1196
+ name: mappedName,
1197
  duration: 5, // Default duration
1198
  effect: effect
1199
  };
1200
 
1201
  // Check if effect already exists and is not stackable
1202
  if (!effect.stackable) {
1203
+ this.state.fieldEffects = this.state.fieldEffects.filter(fe => fe.name !== mappedName);
1204
  }
1205
 
1206
  this.state.fieldEffects.push(fieldEffect);
1207
+
1208
+ // Log effect application with clear descriptions
1209
+ switch (mappedName) {
1210
+ case 'entryHazardSpikes':
1211
+ this.log('Entry spikes were scattered on the battlefield!');
1212
  break;
1213
+ case 'contactDamageReduction':
1214
+ this.log('A barrier was raised to reduce contact move damage!');
1215
  break;
1216
+ case 'nonContactDamageReduction':
1217
+ this.log('A barrier was raised to reduce non-contact move damage!');
1218
  break;
1219
+ case 'healingField':
1220
+ this.log('A healing field was created!');
1221
+ break;
1222
+ case 'poisonousField':
1223
+ this.log('A poisonous field was created!');
1224
  break;
1225
  default:
1226
+ this.log(`${mappedName} was applied to the field!`);
1227
  }
1228
  }
1229
 
 
1401
  }
1402
  }
1403
  }
1404
+
1405
+ // Advanced Status Effect Checks
1406
+ private canPicletAct(piclet: BattlePiclet): boolean {
1407
+ // Check status effects that prevent action
1408
+ for (const status of piclet.statusEffects) {
1409
+ switch (status) {
1410
+ case 'freeze':
1411
+ this.log(`${piclet.definition.name} is frozen solid and cannot move!`);
1412
+ return false;
1413
+
1414
+ case 'sleep':
1415
+ this.log(`${piclet.definition.name} is fast asleep and cannot wake up!`);
1416
+ return false;
1417
+
1418
+ case 'paralyze':
1419
+ // 25% chance to be fully paralyzed
1420
+ if (Math.random() < 0.25) {
1421
+ this.log(`${piclet.definition.name} is fully paralyzed and cannot move!`);
1422
+ return false;
1423
+ }
1424
+ break;
1425
+
1426
+ case 'confuse':
1427
+ // 33% chance to hurt self in confusion
1428
+ if (Math.random() < 0.33) {
1429
+ const selfDamage = Math.floor(piclet.maxHp * 0.125); // 12.5% max HP
1430
+ piclet.currentHp = Math.max(0, piclet.currentHp - selfDamage);
1431
+ this.log(`${piclet.definition.name} hurt itself in confusion for ${selfDamage} damage!`);
1432
+ return false;
1433
+ }
1434
+ break;
1435
+ }
1436
+ }
1437
+ return true;
1438
+ }
1439
+
1440
+ // Enhanced Status Application
1441
+ private processApplyStatusEffect(effect: { status: StatusEffect; chance?: number }, target: BattlePiclet): void {
1442
+ // Check chance if specified
1443
+ if (effect.chance !== undefined) {
1444
+ const roll = Math.random() * 100;
1445
+ if (roll >= effect.chance) {
1446
+ return; // Status effect failed to apply
1447
+ }
1448
+ }
1449
+
1450
+ // Check for status immunity
1451
+ if (this.checkStatusImmunity(target, effect.status)) {
1452
+ this.log(`${target.definition.name} is immune to ${effect.status}!`);
1453
+ return;
1454
+ }
1455
+
1456
+ // Check for major status conflicts (freeze, paralyze, sleep are mutually exclusive)
1457
+ const majorStatuses = ['freeze', 'paralyze', 'sleep'];
1458
+ if (majorStatuses.includes(effect.status)) {
1459
+ const hasMajorStatus = target.statusEffects.some(status => majorStatuses.includes(status));
1460
+ if (hasMajorStatus) {
1461
+ this.log(`${target.definition.name} is already affected by a major status condition!`);
1462
+ return;
1463
+ }
1464
+ }
1465
+
1466
+ if (!target.statusEffects.includes(effect.status)) {
1467
+ target.statusEffects.push(effect.status);
1468
+
1469
+ // Trigger status inflicted event
1470
+ this.triggerOnStatusInflicted(target, effect.status);
1471
+
1472
+ // Apply immediate effects and set durations
1473
+ switch (effect.status) {
1474
+ case 'freeze':
1475
+ this.log(`${target.definition.name} was frozen solid!`);
1476
+ // Mark as just applied to prevent immediate thawing
1477
+ (target as any).freezeJustApplied = true;
1478
+ break;
1479
+ case 'paralyze':
1480
+ this.log(`${target.definition.name} was paralyzed!`);
1481
+ // Reduce speed by 50%
1482
+ target.speed = Math.floor(target.speed * 0.5);
1483
+ break;
1484
+ case 'sleep':
1485
+ this.log(`${target.definition.name} fell asleep!`);
1486
+ // Sleep lasts 1-3 turns
1487
+ (target as any).sleepTurns = 1 + Math.floor(Math.random() * 3);
1488
+ (target as any).sleepJustApplied = true;
1489
+ break;
1490
+ case 'confuse':
1491
+ this.log(`${target.definition.name} became confused!`);
1492
+ // Confusion lasts 2-5 turns
1493
+ (target as any).confusionTurns = 2 + Math.floor(Math.random() * 4);
1494
+ break;
1495
+ default:
1496
+ this.log(`${target.definition.name} was ${effect.status}ed!`);
1497
+ }
1498
+ }
1499
+ }
1500
+
1501
+ // Wake up from sleep when damaged
1502
+ private wakeUpFromSleep(target: BattlePiclet): void {
1503
+ if (target.statusEffects.includes('sleep')) {
1504
+ const sleepIndex = target.statusEffects.indexOf('sleep');
1505
+ if (sleepIndex > -1) {
1506
+ target.statusEffects.splice(sleepIndex, 1);
1507
+ this.log(`${target.definition.name} woke up from the attack!`);
1508
+ delete (target as any).sleepTurns;
1509
+ }
1510
+ }
1511
+ }
1512
+
1513
+ // Ability Trigger System
1514
+ private triggerAbilities(event: string, piclet: BattlePiclet, context?: any): void {
1515
+ if (!piclet.definition.specialAbility?.triggers) return;
1516
+
1517
+ for (const trigger of piclet.definition.specialAbility.triggers) {
1518
+ if (trigger.event === event && this.checkTriggerCondition(trigger, piclet, context)) {
1519
+ this.log(`${piclet.definition.name}'s ${piclet.definition.specialAbility.name} triggered!`);
1520
+
1521
+ // Process all effects in the trigger
1522
+ for (const effect of trigger.effects) {
1523
+ this.processAbilityTriggerEffect(effect, piclet, context);
1524
+ }
1525
+ }
1526
+ }
1527
+ }
1528
+
1529
+ private checkTriggerCondition(trigger: Trigger, piclet: BattlePiclet, context?: any): boolean {
1530
+ if (!trigger.condition || trigger.condition === 'always') {
1531
+ return true;
1532
+ }
1533
+
1534
+ // Check various conditions
1535
+ switch (trigger.condition) {
1536
+ case 'ifLowHp':
1537
+ return (piclet.currentHp / piclet.maxHp) < 0.25;
1538
+ case 'ifHighHp':
1539
+ return piclet.currentHp === piclet.maxHp;
1540
+ case 'onCritical':
1541
+ return context?.isCriticalHit === true;
1542
+ case 'ifStatusMove':
1543
+ return context?.isStatusMove === true;
1544
+ default:
1545
+ return true;
1546
+ }
1547
+ }
1548
+
1549
+ private processAbilityTriggerEffect(effect: BattleEffect, owner: BattlePiclet, context?: any): void {
1550
+ // Determine target for the effect based on effect type
1551
+ let targetType = 'self'; // default
1552
+ if ('target' in effect) {
1553
+ targetType = effect.target;
1554
+ }
1555
+ const target = this.resolveAbilityTarget(targetType, owner);
1556
+ if (!target) return;
1557
+
1558
+ // Process the effect using existing effect processors
1559
+ switch (effect.type) {
1560
+ case 'damage':
1561
+ // Create a dummy move for damage calculation
1562
+ const dummyMove: Move = {
1563
+ name: `${owner.definition.specialAbility?.name} Effect`,
1564
+ type: 'normal' as any,
1565
+ power: 0,
1566
+ accuracy: 100,
1567
+ pp: 1,
1568
+ priority: 0,
1569
+ flags: [],
1570
+ effects: []
1571
+ };
1572
+ this.processDamageEffect(effect, owner, target, dummyMove);
1573
+ break;
1574
+ case 'modifyStats':
1575
+ this.processModifyStatsEffect(effect, target);
1576
+ break;
1577
+ case 'heal':
1578
+ this.processHealEffect(effect, target);
1579
+ break;
1580
+ case 'applyStatus':
1581
+ this.processApplyStatusEffect(effect, target);
1582
+ break;
1583
+ case 'removeStatus':
1584
+ this.processRemoveStatusEffect(effect, target);
1585
+ break;
1586
+ default:
1587
+ this.log(`Ability effect ${effect.type} not implemented yet`);
1588
+ }
1589
+ }
1590
+
1591
+ private resolveAbilityTarget(targetType: string, owner: BattlePiclet): BattlePiclet | null {
1592
+ switch (targetType) {
1593
+ case 'self':
1594
+ return owner;
1595
+ case 'opponent':
1596
+ return owner === this.state.playerPiclet ? this.state.opponentPiclet : this.state.playerPiclet;
1597
+ default:
1598
+ return null;
1599
+ }
1600
+ }
1601
+
1602
+ // Trigger Points Integration
1603
+ private triggerOnDamageTaken(piclet: BattlePiclet, damage: number, isContactMove: boolean): void {
1604
+ this.triggerAbilities('onDamageTaken', piclet, { damage, isContactMove });
1605
+ if (isContactMove) {
1606
+ this.triggerAbilities('onContactDamage', piclet, { damage });
1607
+ }
1608
+ }
1609
+
1610
+ private triggerOnDamageDealt(piclet: BattlePiclet, damage: number, target: BattlePiclet): void {
1611
+ this.triggerAbilities('onDamageDealt', piclet, { damage, target });
1612
+ }
1613
+
1614
+ private triggerOnCriticalHit(piclet: BattlePiclet, target: BattlePiclet): void {
1615
+ this.triggerAbilities('onCriticalHit', piclet, { target, isCriticalHit: true });
1616
+ }
1617
+
1618
+ private triggerOnLowHP(piclet: BattlePiclet): void {
1619
+ if ((piclet.currentHp / piclet.maxHp) < 0.25) {
1620
+ this.triggerAbilities('onLowHP', piclet);
1621
+ }
1622
+ }
1623
+
1624
+ private triggerEndOfTurn(): void {
1625
+ this.triggerAbilities('endOfTurn', this.state.playerPiclet);
1626
+ this.triggerAbilities('endOfTurn', this.state.opponentPiclet);
1627
+ }
1628
+
1629
+ private triggerOnStatusInflicted(piclet: BattlePiclet, status: string): void {
1630
+ this.triggerAbilities('onStatusInflicted', piclet, { status });
1631
+ }
1632
+
1633
+ private triggerOnHPDrained(attacker: BattlePiclet, target: BattlePiclet, drainAmount: number): void {
1634
+ this.triggerAbilities('onHPDrained', attacker, { target, drainAmount });
1635
+ }
1636
+
1637
+ private triggerOnKO(knockedOut: BattlePiclet, attacker: BattlePiclet): void {
1638
+ this.triggerAbilities('onKO', knockedOut, { attacker });
1639
+ this.triggerAbilities('onKO', attacker, { target: knockedOut, causedKO: true });
1640
+ }
1641
+
1642
+ private triggerBeforeMoveUse(piclet: BattlePiclet, move: Move): void {
1643
+ this.triggerAbilities('beforeMoveUse', piclet, { move });
1644
+ }
1645
+
1646
+ private triggerAfterMoveUse(piclet: BattlePiclet, move: Move, success: boolean): void {
1647
+ this.triggerAbilities('afterMoveUse', piclet, { move, success });
1648
+ }
1649
+
1650
+ private triggerOnFullHP(piclet: BattlePiclet): void {
1651
+ if (piclet.currentHp === piclet.maxHp) {
1652
+ this.triggerAbilities('onFullHP', piclet);
1653
+ }
1654
+ }
1655
+
1656
+ private triggerOnOpponentContactMove(defender: BattlePiclet, attacker: BattlePiclet, move: Move): void {
1657
+ if (move.flags.includes('contact')) {
1658
+ this.triggerAbilities('onOpponentContactMove', defender, { attacker, move });
1659
+ }
1660
+ }
1661
+
1662
+ private triggerOnStatChange(piclet: BattlePiclet, stat: string, change: string): void {
1663
+ this.triggerAbilities('onStatChange', piclet, { stat, change });
1664
+ }
1665
  }
src/lib/battle-engine/ability-triggers.test.ts CHANGED
@@ -1,681 +1,388 @@
1
- /**
2
- * Tests for special ability trigger system from the design document
3
- * Tests all the different trigger events and their implementations
4
- */
5
-
6
  import { describe, it, expect, beforeEach } from 'vitest';
7
  import { BattleEngine } from './BattleEngine';
8
- import { PicletDefinition, Move, SpecialAbility } from './types';
9
  import { PicletType, AttackType } from './types';
10
 
11
- const STANDARD_STATS = { hp: 100, attack: 80, defense: 70, speed: 60 };
12
-
13
- describe('Special Ability Trigger System - TDD Implementation', () => {
14
- describe('Damage Triggers', () => {
15
- it('should handle onDamageTaken triggers', () => {
16
- const berserkAbility: SpecialAbility = {
17
- name: "Berserker",
18
- description: "Attack increases when taking damage",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  triggers: [
20
  {
21
  event: 'onDamageTaken',
 
22
  effects: [
23
  {
24
  type: 'modifyStats',
25
  target: 'self',
26
- stats: { attack: 'increase' }
27
- }
28
- ]
29
- }
30
- ]
31
- };
32
-
33
- const berserkerPiclet: PicletDefinition = {
34
- name: "Rage Beast",
35
- description: "Gets stronger when hurt",
36
- tier: 'medium',
37
- primaryType: PicletType.BEAST,
38
- baseStats: STANDARD_STATS,
39
- nature: "Brave",
40
- specialAbility: berserkAbility,
41
- movepool: [{
42
- name: "Tackle", type: AttackType.NORMAL, power: 40, accuracy: 100, pp: 35,
43
- priority: 0, flags: [], effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
44
- }]
45
- };
46
-
47
- expect(berserkAbility.triggers![0].event).toBe('onDamageTaken');
48
- });
49
-
50
- it('should handle onDamageDealt triggers', () => {
51
- const lifeStealAbility: SpecialAbility = {
52
- name: "Life Steal",
53
- description: "Heals when dealing damage",
54
- triggers: [
55
- {
56
- event: 'onDamageDealt',
57
- effects: [
58
- {
59
- type: 'heal',
60
- target: 'self',
61
- amount: 'small'
62
- }
63
- ]
64
- }
65
- ]
66
- };
67
-
68
- expect(lifeStealAbility.triggers![0].event).toBe('onDamageDealt');
69
- });
70
-
71
- it('should handle onContactDamage triggers', () => {
72
- const toxicSkin: SpecialAbility = {
73
- name: "Toxic Skin",
74
- description: "Physical contact poisons the attacker",
75
- triggers: [
76
- {
77
- event: 'onContactDamage',
78
- effects: [
79
- {
80
- type: 'applyStatus',
81
- target: 'attacker',
82
- status: 'poison',
83
- chance: 50
84
  }
85
  ]
86
  }
87
  ]
88
- };
89
-
90
- expect(toxicSkin.triggers![0].event).toBe('onContactDamage');
91
- expect(toxicSkin.triggers![0].effects[0].target).toBe('attacker');
92
- });
 
 
 
 
 
 
 
 
 
93
  });
94
 
95
- describe('Status Triggers', () => {
96
- it('should handle onStatusInflicted triggers', () => {
97
- const burnBoost: SpecialAbility = {
98
- name: "Burn Boost",
99
- description: "Fire damage energizes this Piclet, increasing attack power",
100
- triggers: [
101
- {
102
- event: 'onStatusInflicted',
103
- condition: 'ifStatus:burn',
104
- effects: [
105
- {
106
- type: 'modifyStats',
107
- target: 'self',
108
- stats: { attack: 'greatly_increase' }
109
- }
110
- ]
111
- }
112
- ]
113
- };
114
 
115
- expect(burnBoost.triggers![0].event).toBe('onStatusInflicted');
116
- expect(burnBoost.triggers![0].condition).toBe('ifStatus:burn');
117
- });
 
 
118
 
119
- it('should handle onStatusMove triggers', () => {
120
- const statusReflect: SpecialAbility = {
121
- name: "Status Shield",
122
- description: "Reflects status moves back to user",
123
- triggers: [
124
- {
125
- event: 'onStatusMove',
126
- effects: [
127
- {
128
- type: 'mechanicOverride',
129
- mechanic: 'targetRedirection',
130
- value: 'reflect'
131
- }
132
- ]
133
- }
134
- ]
135
- };
136
 
137
- expect(statusReflect.triggers![0].event).toBe('onStatusMove');
138
- });
139
-
140
- it('should handle onStatusMoveTargeted triggers', () => {
141
- const statusCounter: SpecialAbility = {
142
- name: "Status Counter",
143
- description: "When targeted by status moves, counter with damage",
144
- triggers: [
145
- {
146
- event: 'onStatusMoveTargeted',
147
- effects: [
148
- {
149
- type: 'damage',
150
- target: 'attacker',
151
- formula: 'fixed',
152
- value: 30
153
- }
154
- ]
155
- }
156
- ]
157
- };
158
-
159
- expect(statusCounter.triggers![0].event).toBe('onStatusMoveTargeted');
160
  });
161
  });
162
 
163
- describe('Critical Hit Triggers', () => {
164
- it('should handle onCriticalHit triggers', () => {
165
- const criticalMomentum: SpecialAbility = {
166
- name: "Critical Momentum",
167
- description: "Critical hits increase speed",
168
- triggers: [
169
- {
170
- event: 'onCriticalHit',
171
- effects: [
172
- {
173
- type: 'modifyStats',
174
- target: 'self',
175
- stats: { speed: 'increase' }
176
- }
177
- ]
178
- }
179
- ]
 
 
 
 
180
  };
181
 
182
- expect(criticalMomentum.triggers![0].event).toBe('onCriticalHit');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  });
184
  });
185
 
186
- describe('HP Drain Triggers', () => {
187
- it('should handle onHPDrained triggers', () => {
188
- const drainPunish: SpecialAbility = {
189
- name: "Drain Punishment",
190
- description: "Damages opponents who try to drain HP",
191
- triggers: [
192
- {
193
- event: 'onHPDrained',
194
- effects: [
195
- {
196
- type: 'damage',
197
- target: 'attacker',
198
- formula: 'fixed',
199
- value: 25
200
- }
201
- ]
202
- }
203
- ]
 
 
 
 
 
204
  };
205
 
206
- expect(drainPunish.triggers![0].event).toBe('onHPDrained');
207
- });
208
- });
209
 
210
- describe('KO Triggers', () => {
211
- it('should handle onKO triggers', () => {
212
- const koBoost: SpecialAbility = {
213
- name: "Victory Rush",
214
- description: "Gets stronger after knocking out an opponent",
215
- triggers: [
216
- {
217
- event: 'onKO',
218
- effects: [
219
- {
220
- type: 'modifyStats',
221
- target: 'self',
222
- stats: { attack: 'greatly_increase', speed: 'increase' }
223
- }
224
- ]
225
- }
226
- ]
227
- };
228
 
229
- expect(koBoost.triggers![0].event).toBe('onKO');
 
230
  });
231
  });
232
 
233
- describe('Switch Triggers', () => {
234
- it('should handle onSwitchIn triggers', () => {
235
- const intimidate: SpecialAbility = {
236
- name: "Intimidate",
237
- description: "Lowers opponent's attack when entering battle",
238
- triggers: [
239
- {
240
- event: 'onSwitchIn',
241
- effects: [
242
- {
243
- type: 'modifyStats',
244
- target: 'opponent',
245
- stats: { attack: 'decrease' }
246
- }
247
- ]
248
- }
249
- ]
 
 
 
 
 
 
250
  };
251
 
252
- expect(intimidate.triggers![0].event).toBe('onSwitchIn');
253
- expect(intimidate.triggers![0].effects[0].target).toBe('opponent');
254
- });
 
 
255
 
256
- it('should handle onSwitchOut triggers', () => {
257
- const regenerator: SpecialAbility = {
258
- name: "Regenerator",
259
- description: "Restores HP when switching out",
260
- triggers: [
261
- {
262
- event: 'onSwitchOut',
263
- effects: [
264
- {
265
- type: 'heal',
266
- target: 'self',
267
- amount: 'small'
268
- }
269
- ]
270
- }
271
- ]
272
- };
273
 
274
- expect(regenerator.triggers![0].event).toBe('onSwitchOut');
275
- });
 
 
276
 
277
- it('should handle conditional switch-in triggers', () => {
278
- const stormCaller: SpecialAbility = {
279
- name: "Storm Caller",
280
- description: "Boosts attack when entering during storm weather",
281
- triggers: [
282
- {
283
- event: 'onSwitchIn',
284
- condition: 'ifWeather:storm',
285
- effects: [
286
- {
287
- type: 'modifyStats',
288
- target: 'self',
289
- stats: { attack: 'increase' }
290
- }
291
- ]
292
- }
293
- ]
294
- };
295
 
296
- expect(stormCaller.triggers![0].condition).toBe('ifWeather:storm');
297
- });
298
- });
299
 
300
- describe('Weather Triggers', () => {
301
- it('should handle onWeatherChange triggers', () => {
302
- const weatherAdapt: SpecialAbility = {
303
- name: "Weather Adaptation",
304
- description: "Adapts stats based on weather changes",
305
- triggers: [
306
- {
307
- event: 'onWeatherChange',
308
- effects: [
309
- {
310
- type: 'modifyStats',
311
- target: 'self',
312
- stats: { speed: 'increase' }
313
- }
314
- ]
315
- }
316
- ]
317
- };
318
-
319
- expect(weatherAdapt.triggers![0].event).toBe('onWeatherChange');
320
  });
321
  });
322
 
323
- describe('Move Use Triggers', () => {
324
- it('should handle beforeMoveUse triggers', () => {
325
- const movePrep: SpecialAbility = {
326
- name: "Move Preparation",
327
- description: "Boosts accuracy before using moves",
328
- triggers: [
329
- {
330
- event: 'beforeMoveUse',
331
- effects: [
332
- {
333
- type: 'modifyStats',
334
- target: 'self',
335
- stats: { accuracy: 'increase' }
336
- }
337
- ]
338
- }
339
- ]
 
 
 
 
340
  };
341
 
342
- expect(movePrep.triggers![0].event).toBe('beforeMoveUse');
343
- });
344
 
345
- it('should handle afterMoveUse triggers', () => {
346
- const moveRecovery: SpecialAbility = {
347
- name: "Move Recovery",
348
- description: "Heals slightly after using any move",
349
- triggers: [
350
- {
351
- event: 'afterMoveUse',
352
- effects: [
353
- {
354
- type: 'heal',
355
- target: 'self',
356
- formula: 'fixed',
357
- value: 5
358
- }
359
- ]
360
- }
361
- ]
362
- };
363
-
364
- expect(moveRecovery.triggers![0].event).toBe('afterMoveUse');
365
- });
366
- });
367
-
368
- describe('HP Threshold Triggers', () => {
369
- it('should handle onLowHP triggers', () => {
370
- const emergencyMode: SpecialAbility = {
371
- name: "Emergency Mode",
372
- description: "Activates emergency protocols when HP is low",
373
- triggers: [
374
- {
375
- event: 'onLowHP',
376
- effects: [
377
- {
378
- type: 'modifyStats',
379
- target: 'self',
380
- stats: { speed: 'greatly_increase', attack: 'increase' }
381
- },
382
- {
383
- type: 'mechanicOverride',
384
- mechanic: 'statusImmunity',
385
- value: ['burn', 'poison', 'paralyze']
386
- }
387
- ]
388
- }
389
- ]
390
- };
391
 
392
- expect(emergencyMode.triggers![0].event).toBe('onLowHP');
393
- expect(emergencyMode.triggers![0].effects).toHaveLength(2);
394
- });
395
-
396
- it('should handle onFullHP triggers', () => {
397
- const fullPower: SpecialAbility = {
398
- name: "Full Power",
399
- description: "Maximum power when at full health",
400
- triggers: [
401
- {
402
- event: 'onFullHP',
403
- effects: [
404
- {
405
- type: 'modifyStats',
406
- target: 'self',
407
- stats: { attack: 'greatly_increase' }
408
- }
409
- ]
410
- }
411
- ]
412
- };
413
 
414
- expect(fullPower.triggers![0].event).toBe('onFullHP');
 
 
 
 
415
  });
416
  });
417
 
418
- describe('Turn-Based Triggers', () => {
419
- it('should handle endOfTurn triggers', () => {
420
- const turnRegeneration: SpecialAbility = {
421
- name: "Slow Regeneration",
422
- description: "Heals at the end of each turn",
423
- triggers: [
424
- {
425
- event: 'endOfTurn',
426
- effects: [
427
- {
428
- type: 'heal',
429
- target: 'self',
430
- formula: 'percentage',
431
- value: 10
432
- }
433
- ]
434
- }
435
- ]
 
 
 
 
 
436
  };
437
 
438
- expect(turnRegeneration.triggers![0].event).toBe('endOfTurn');
439
- });
440
 
441
- it('should handle conditional turn triggers', () => {
442
- const sleepHeal: SpecialAbility = {
443
- name: "Slumber Heal",
444
- description: "Restores HP while sleeping instead of being unable to act",
445
- triggers: [
446
- {
447
- event: 'endOfTurn',
448
- condition: 'ifStatus:sleep',
449
- effects: [
450
- {
451
- type: 'heal',
452
- target: 'self',
453
- formula: 'percentage',
454
- value: 15
455
- }
456
- ]
457
- }
458
- ]
459
- };
460
 
461
- expect(sleepHeal.triggers![0].condition).toBe('ifStatus:sleep');
462
- });
463
- });
464
 
465
- describe('Opponent Move Triggers', () => {
466
- it('should handle onOpponentContactMove triggers', () => {
467
- const contactPunish: SpecialAbility = {
468
- name: "Contact Punishment",
469
- description: "Damages opponents who use contact moves",
470
- triggers: [
471
- {
472
- event: 'onOpponentContactMove',
473
- effects: [
474
- {
475
- type: 'damage',
476
- target: 'attacker',
477
- formula: 'fixed',
478
- value: 15
479
- }
480
- ]
481
- }
482
- ]
483
- };
484
 
485
- expect(contactPunish.triggers![0].event).toBe('onOpponentContactMove');
486
- });
 
 
487
 
488
- it('should handle wind currents ability from design doc', () => {
489
- const windCurrents: SpecialAbility = {
490
- name: "Wind Currents",
491
- description: "Gains +25% speed when opponent uses a contact move",
492
- triggers: [
493
- {
494
- event: 'onOpponentContactMove',
495
- effects: [
496
- {
497
- type: 'modifyStats',
498
- target: 'self',
499
- stats: { speed: 'increase' }
500
- }
501
- ]
502
- }
503
- ]
504
- };
505
 
506
- const zephyrSprite: PicletDefinition = {
507
- name: "Zephyr Sprite",
508
- description: "A mysterious floating creature that manipulates wind currents",
509
- tier: 'medium',
510
- primaryType: PicletType.SPACE,
511
- baseStats: { hp: 65, attack: 85, defense: 40, speed: 90 },
512
- nature: "hasty",
513
- specialAbility: windCurrents,
514
- movepool: [{
515
- name: "Tackle", type: AttackType.NORMAL, power: 40, accuracy: 100, pp: 35,
516
- priority: 0, flags: [], effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
517
- }]
518
- };
519
-
520
- expect(windCurrents.triggers![0].event).toBe('onOpponentContactMove');
521
- expect(zephyrSprite.specialAbility.name).toBe('Wind Currents');
522
  });
523
  });
524
 
525
- describe('Complex Multi-Trigger Abilities', () => {
526
- it('should handle abilities with multiple triggers', () => {
527
- const complexAbility: SpecialAbility = {
528
- name: "Adaptive Guardian",
529
- description: "Complex ability with multiple trigger conditions",
530
- triggers: [
531
- {
532
- event: 'onSwitchIn',
533
- effects: [
534
- {
535
- type: 'modifyStats',
536
- target: 'self',
537
- stats: { defense: 'increase' }
538
- }
539
- ]
540
- },
541
- {
542
- event: 'onDamageTaken',
543
- condition: 'ifLowHp',
544
- effects: [
545
- {
546
- type: 'mechanicOverride',
547
- mechanic: 'damageReflection',
548
- value: 0.3
549
- }
550
- ]
551
- },
552
- {
553
- event: 'endOfTurn',
554
- condition: 'ifStatus:burn',
555
- effects: [
556
- {
557
- type: 'removeStatus',
558
- target: 'self',
559
- status: 'burn'
560
- },
561
- {
562
- type: 'modifyStats',
563
- target: 'self',
564
- stats: { attack: 'increase' }
565
- }
566
- ]
567
- }
568
- ]
569
- };
570
-
571
- expect(complexAbility.triggers).toHaveLength(3);
572
- expect(complexAbility.triggers![1].condition).toBe('ifLowHp');
573
- expect(complexAbility.triggers![2].effects).toHaveLength(2);
574
- });
575
- });
576
-
577
- describe('Status-Specific Ability Examples', () => {
578
- it('should handle Glacial Birth - starts battle frozen', () => {
579
- const glacialBirth: SpecialAbility = {
580
- name: "Glacial Birth",
581
- description: "Enters battle in a frozen state but gains defensive bonuses",
582
- triggers: [
583
- {
584
- event: 'onSwitchIn',
585
- effects: [
586
- {
587
- type: 'applyStatus',
588
- target: 'self',
589
- status: 'freeze',
590
- chance: 100
591
- },
592
- {
593
- type: 'modifyStats',
594
- target: 'self',
595
- stats: { defense: 'greatly_increase' },
596
- condition: 'whileFrozen'
597
- }
598
- ]
599
- }
600
- ]
601
- };
602
-
603
- expect(glacialBirth.triggers![0].effects[0].status).toBe('freeze');
604
- expect(glacialBirth.triggers![0].effects[1].condition).toBe('whileFrozen');
605
- });
606
-
607
- it('should handle Cryogenic Touch - freezes on contact', () => {
608
- const cryogenicTouch: SpecialAbility = {
609
- name: "Cryogenic Touch",
610
- description: "Contact moves have a chance to freeze the attacker",
611
- triggers: [
612
- {
613
- event: 'onContactDamage',
614
- effects: [
615
- {
616
- type: 'applyStatus',
617
- target: 'attacker',
618
- status: 'freeze',
619
- chance: 30
620
- }
621
- ]
622
- }
623
- ]
624
  };
625
 
626
- expect(cryogenicTouch.triggers![0].effects[0].chance).toBe(30);
627
- });
 
628
 
629
- it('should handle Paralytic Aura - paralyzes on entry', () => {
630
- const paralyticAura: SpecialAbility = {
631
- name: "Paralytic Aura",
632
- description: "Intimidating presence paralyzes the opponent upon entry",
633
- triggers: [
634
- {
635
- event: 'onSwitchIn',
636
- effects: [
637
- {
638
- type: 'applyStatus',
639
- target: 'opponent',
640
- status: 'paralyze',
641
- chance: 75
642
- }
643
- ]
644
- }
645
- ]
646
- };
647
-
648
- expect(paralyticAura.triggers![0].effects[0].target).toBe('opponent');
649
- expect(paralyticAura.triggers![0].effects[0].chance).toBe(75);
650
- });
651
 
652
- it('should handle Confusion Clarity - team status removal', () => {
653
- const confusionClarity: SpecialAbility = {
654
- name: "Confusion Clarity",
655
- description: "Clear mind prevents confusion and helps allies focus",
656
- effects: [
657
- {
658
- type: 'mechanicOverride',
659
- mechanic: 'statusImmunity',
660
- value: ['confuse']
661
- }
662
- ],
663
- triggers: [
664
- {
665
- event: 'onSwitchIn',
666
- effects: [
667
- {
668
- type: 'removeStatus',
669
- target: 'allies',
670
- status: 'confuse'
671
- }
672
- ]
673
- }
674
- ]
675
- };
676
 
677
- expect(confusionClarity.effects![0].value).toContain('confuse');
678
- expect(confusionClarity.triggers![0].effects[0].target).toBe('allies');
 
 
679
  });
680
  });
681
  });
 
 
 
 
 
 
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
  import { BattleEngine } from './BattleEngine';
3
+ import type { PicletDefinition, SpecialAbility } from './types';
4
  import { PicletType, AttackType } from './types';
5
 
6
+ describe('Special Ability Triggers System', () => {
7
+ let basicPiclet: PicletDefinition;
8
+ let abilityPiclet: PicletDefinition;
9
+
10
+ beforeEach(() => {
11
+ // Basic piclet without special abilities
12
+ basicPiclet = {
13
+ name: "Basic Fighter",
14
+ description: "Standard test piclet",
15
+ tier: 'medium',
16
+ primaryType: PicletType.BEAST,
17
+ baseStats: { hp: 80, attack: 60, defense: 60, speed: 60 },
18
+ nature: "Hardy",
19
+ specialAbility: { name: "No Ability", description: "" },
20
+ movepool: [
21
+ {
22
+ name: "Basic Attack",
23
+ type: AttackType.BEAST,
24
+ power: 50,
25
+ accuracy: 100,
26
+ pp: 20,
27
+ priority: 0,
28
+ flags: ['contact'],
29
+ effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
30
+ }
31
+ ]
32
+ };
33
+
34
+ // Piclet with special abilities for testing
35
+ abilityPiclet = {
36
+ name: "Ability User",
37
+ description: "Has special abilities",
38
+ tier: 'medium',
39
+ primaryType: PicletType.BEAST,
40
+ baseStats: { hp: 100, attack: 70, defense: 70, speed: 50 },
41
+ nature: "Bold",
42
+ specialAbility: {
43
+ name: "Test Ability",
44
+ description: "Triggers on various events",
45
  triggers: [
46
  {
47
  event: 'onDamageTaken',
48
+ condition: 'always',
49
  effects: [
50
  {
51
  type: 'modifyStats',
52
  target: 'self',
53
+ stats: {
54
+ attack: 'increase'
55
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  }
57
  ]
58
  }
59
  ]
60
+ },
61
+ movepool: [
62
+ {
63
+ name: "Power Strike",
64
+ type: AttackType.BEAST,
65
+ power: 60,
66
+ accuracy: 100,
67
+ pp: 15,
68
+ priority: 0,
69
+ flags: ['contact'],
70
+ effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
71
+ }
72
+ ]
73
+ };
74
  });
75
 
76
+ describe('onDamageTaken Trigger', () => {
77
+ it('should trigger when piclet takes damage', () => {
78
+ const engine = new BattleEngine(abilityPiclet, basicPiclet);
79
+ const initialAttack = engine.getState().playerPiclet.attack;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
+ // Opponent attacks, should trigger onDamageTaken
82
+ engine.executeActions(
83
+ { type: 'move', piclet: 'player', moveIndex: 0 },
84
+ { type: 'move', piclet: 'opponent', moveIndex: 0 } // This should damage player and trigger ability
85
+ );
86
 
87
+ const finalAttack = engine.getState().playerPiclet.attack;
88
+ const log = engine.getLog();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
+ // Attack should have increased due to ability trigger
91
+ expect(finalAttack).toBeGreaterThan(initialAttack);
92
+ expect(log.some(msg => msg.includes('Test Ability') && msg.includes('triggered'))).toBe(true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  });
94
  });
95
 
96
+ describe('endOfTurn Trigger', () => {
97
+ it('should trigger at the end of every turn', () => {
98
+ const endTurnAbility: PicletDefinition = {
99
+ ...abilityPiclet,
100
+ specialAbility: {
101
+ name: "Regeneration",
102
+ description: "Heals at end of turn",
103
+ triggers: [
104
+ {
105
+ event: 'endOfTurn',
106
+ condition: 'always',
107
+ effects: [
108
+ {
109
+ type: 'heal',
110
+ target: 'self',
111
+ amount: 'small'
112
+ }
113
+ ]
114
+ }
115
+ ]
116
+ }
117
  };
118
 
119
+ const engine = new BattleEngine(endTurnAbility, basicPiclet);
120
+
121
+ // Damage the piclet first so healing is visible, but not too much
122
+ engine['state'].playerPiclet.currentHp = Math.floor(engine['state'].playerPiclet.maxHp * 0.9);
123
+ const initialHp = engine.getState().playerPiclet.currentHp;
124
+
125
+ engine.executeActions(
126
+ { type: 'move', piclet: 'player', moveIndex: 0 },
127
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
128
+ );
129
+
130
+ const log = engine.getLog();
131
+ console.log('Regeneration test log:', log);
132
+ console.log('Initial HP:', initialHp, 'Final HP:', engine.getState().playerPiclet.currentHp);
133
+
134
+ // The ability should trigger (check log message)
135
+ expect(log.some(msg => msg.includes('Regeneration') && msg.includes('triggered'))).toBe(true);
136
+
137
+ // HP might decrease due to damage taken, but healing should have occurred
138
+ expect(log.some(msg => msg.includes('recovered') || msg.includes('healed'))).toBe(true);
139
  });
140
  });
141
 
142
+ describe('onDamageDealt Trigger', () => {
143
+ it('should trigger when piclet deals damage to opponent', () => {
144
+ const damageDealer: PicletDefinition = {
145
+ ...abilityPiclet,
146
+ specialAbility: {
147
+ name: "Combat High",
148
+ description: "Gains speed when dealing damage",
149
+ triggers: [
150
+ {
151
+ event: 'onDamageDealt',
152
+ condition: 'always',
153
+ effects: [
154
+ {
155
+ type: 'modifyStats',
156
+ target: 'self',
157
+ stats: {
158
+ speed: 'increase'
159
+ }
160
+ }
161
+ ]
162
+ }
163
+ ]
164
+ }
165
  };
166
 
167
+ const engine = new BattleEngine(damageDealer, basicPiclet);
168
+ const initialSpeed = engine.getState().playerPiclet.speed;
 
169
 
170
+ engine.executeActions(
171
+ { type: 'move', piclet: 'player', moveIndex: 0 }, // Player deals damage
172
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
173
+ );
174
+
175
+ const finalSpeed = engine.getState().playerPiclet.speed;
176
+ const log = engine.getLog();
 
 
 
 
 
 
 
 
 
 
 
177
 
178
+ expect(finalSpeed).toBeGreaterThan(initialSpeed);
179
+ expect(log.some(msg => msg.includes('Combat High') && msg.includes('triggered'))).toBe(true);
180
  });
181
  });
182
 
183
+ describe('onCriticalHit Trigger', () => {
184
+ it('should trigger when dealing a critical hit', () => {
185
+ const criticalHitter: PicletDefinition = {
186
+ ...abilityPiclet,
187
+ specialAbility: {
188
+ name: "Critical Momentum",
189
+ description: "Gains attack on critical hits",
190
+ triggers: [
191
+ {
192
+ event: 'onCriticalHit',
193
+ condition: 'always',
194
+ effects: [
195
+ {
196
+ type: 'modifyStats',
197
+ target: 'self',
198
+ stats: {
199
+ attack: 'increase'
200
+ }
201
+ }
202
+ ]
203
+ }
204
+ ]
205
+ }
206
  };
207
 
208
+ const engine = new BattleEngine(criticalHitter, basicPiclet);
209
+
210
+ // Force a critical hit for testing
211
+ const originalRandom = Math.random;
212
+ Math.random = () => 0.01; // Force critical hit (< 0.0625)
213
 
214
+ const initialAttack = engine.getState().playerPiclet.attack;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
 
216
+ engine.executeActions(
217
+ { type: 'move', piclet: 'player', moveIndex: 0 }, // Should crit and trigger ability
218
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
219
+ );
220
 
221
+ // Restore original Math.random
222
+ Math.random = originalRandom;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
 
224
+ const finalAttack = engine.getState().playerPiclet.attack;
225
+ const log = engine.getLog();
 
226
 
227
+ expect(log.some(msg => msg.includes('A critical hit!'))).toBe(true);
228
+ expect(finalAttack).toBeGreaterThan(initialAttack);
229
+ expect(log.some(msg => msg.includes('Critical Momentum') && msg.includes('triggered'))).toBe(true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  });
231
  });
232
 
233
+ describe('onContactDamage Trigger', () => {
234
+ it('should trigger only when hit by contact moves', () => {
235
+ const contactSensitive: PicletDefinition = {
236
+ ...abilityPiclet,
237
+ specialAbility: {
238
+ name: "Spiky Skin",
239
+ description: "Hurts attackers that make contact",
240
+ triggers: [
241
+ {
242
+ event: 'onContactDamage',
243
+ condition: 'always',
244
+ effects: [
245
+ {
246
+ type: 'damage',
247
+ target: 'opponent',
248
+ amount: 'small'
249
+ }
250
+ ]
251
+ }
252
+ ]
253
+ }
254
  };
255
 
256
+ const engine = new BattleEngine(contactSensitive, basicPiclet);
257
+ const initialOpponentHp = engine.getState().opponentPiclet.currentHp;
258
 
259
+ engine.executeActions(
260
+ { type: 'move', piclet: 'player', moveIndex: 0 },
261
+ { type: 'move', piclet: 'opponent', moveIndex: 0 } // Contact move should trigger Spiky Skin
262
+ );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
 
264
+ const finalOpponentHp = engine.getState().opponentPiclet.currentHp;
265
+ const log = engine.getLog();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
 
267
+ // Opponent should take extra damage from Spiky Skin
268
+ expect(log.some(msg => msg.includes('Spiky Skin') && msg.includes('triggered'))).toBe(true);
269
+
270
+ // The opponent should have taken damage from both the regular attack and the ability
271
+ expect(finalOpponentHp).toBeLessThan(initialOpponentHp);
272
  });
273
  });
274
 
275
+ describe('Conditional Triggers', () => {
276
+ it('should respect ifLowHp condition', () => {
277
+ const conditionalAbility: PicletDefinition = {
278
+ ...abilityPiclet,
279
+ specialAbility: {
280
+ name: "Desperation",
281
+ description: "Only triggers when HP is low",
282
+ triggers: [
283
+ {
284
+ event: 'onDamageTaken',
285
+ condition: 'ifLowHp',
286
+ effects: [
287
+ {
288
+ type: 'modifyStats',
289
+ target: 'self',
290
+ stats: {
291
+ attack: 'greatly_increase'
292
+ }
293
+ }
294
+ ]
295
+ }
296
+ ]
297
+ }
298
  };
299
 
300
+ const engine = new BattleEngine(conditionalAbility, basicPiclet);
301
+ const initialAttack = engine.getState().playerPiclet.attack;
302
 
303
+ // At high HP, condition should not be met
304
+ engine.executeActions(
305
+ { type: 'move', piclet: 'player', moveIndex: 0 },
306
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
307
+ );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
 
309
+ const midAttack = engine.getState().playerPiclet.attack;
310
+ expect(midAttack).toBe(initialAttack); // No trigger due to condition
 
311
 
312
+ // Create a new engine for the low HP test
313
+ const lowHpEngine = new BattleEngine(conditionalAbility, basicPiclet);
314
+
315
+ // Set HP low and trigger the ability
316
+ lowHpEngine['state'].playerPiclet.currentHp = Math.floor(lowHpEngine['state'].playerPiclet.maxHp * 0.15);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
 
318
+ lowHpEngine.executeActions(
319
+ { type: 'move', piclet: 'player', moveIndex: 0 },
320
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
321
+ );
322
 
323
+ const finalAttack = lowHpEngine.getState().playerPiclet.attack;
324
+ const log = lowHpEngine.getLog();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
 
326
+ expect(finalAttack).toBeGreaterThan(initialAttack);
327
+ expect(log.some(msg => msg.includes('Desperation') && msg.includes('triggered'))).toBe(true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  });
329
  });
330
 
331
+ describe('Multiple Triggers on Same Ability', () => {
332
+ it('should handle multiple triggers on the same ability', () => {
333
+ const multiTriggerAbility: PicletDefinition = {
334
+ ...abilityPiclet,
335
+ specialAbility: {
336
+ name: "Adaptive Fighter",
337
+ description: "Multiple trigger conditions",
338
+ triggers: [
339
+ {
340
+ event: 'onDamageTaken',
341
+ condition: 'always',
342
+ effects: [
343
+ {
344
+ type: 'modifyStats',
345
+ target: 'self',
346
+ stats: {
347
+ defense: 'increase'
348
+ }
349
+ }
350
+ ]
351
+ },
352
+ {
353
+ event: 'onDamageDealt',
354
+ condition: 'always',
355
+ effects: [
356
+ {
357
+ type: 'modifyStats',
358
+ target: 'self',
359
+ stats: {
360
+ attack: 'increase'
361
+ }
362
+ }
363
+ ]
364
+ }
365
+ ]
366
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
367
  };
368
 
369
+ const engine = new BattleEngine(multiTriggerAbility, basicPiclet);
370
+ const initialAttack = engine.getState().playerPiclet.attack;
371
+ const initialDefense = engine.getState().playerPiclet.defense;
372
 
373
+ engine.executeActions(
374
+ { type: 'move', piclet: 'player', moveIndex: 0 }, // Should trigger onDamageDealt
375
+ { type: 'move', piclet: 'opponent', moveIndex: 0 } // Should trigger onDamageTaken
376
+ );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
 
378
+ const finalAttack = engine.getState().playerPiclet.attack;
379
+ const finalDefense = engine.getState().playerPiclet.defense;
380
+ const log = engine.getLog();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
 
382
+ // Both stats should increase
383
+ expect(finalAttack).toBeGreaterThan(initialAttack);
384
+ expect(finalDefense).toBeGreaterThan(initialDefense);
385
+ expect(log.some(msg => msg.includes('Adaptive Fighter') && msg.includes('triggered'))).toBe(true);
386
  });
387
  });
388
  });
src/lib/battle-engine/advanced-status-effects.test.ts ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { BattleEngine } from './BattleEngine';
3
+ import type { PicletDefinition } from './types';
4
+ import { PicletType, AttackType } from './types';
5
+
6
+ describe('Advanced Status Effects System', () => {
7
+ let basicPiclet: PicletDefinition;
8
+ let statusInflicter: PicletDefinition;
9
+
10
+ beforeEach(() => {
11
+ // Basic piclet without special abilities
12
+ basicPiclet = {
13
+ name: "Basic Fighter",
14
+ description: "Standard test piclet",
15
+ tier: 'medium',
16
+ primaryType: PicletType.BEAST,
17
+ baseStats: { hp: 80, attack: 60, defense: 60, speed: 60 },
18
+ nature: "Hardy",
19
+ specialAbility: { name: "No Ability", description: "" },
20
+ movepool: [
21
+ {
22
+ name: "Basic Attack",
23
+ type: AttackType.BEAST,
24
+ power: 50,
25
+ accuracy: 100,
26
+ pp: 20,
27
+ priority: 0,
28
+ flags: ['contact'],
29
+ effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
30
+ }
31
+ ]
32
+ };
33
+
34
+ // Piclet that can inflict status effects
35
+ statusInflicter = {
36
+ name: "Status Master",
37
+ description: "Can inflict various status effects",
38
+ tier: 'medium',
39
+ primaryType: PicletType.CULTURE,
40
+ baseStats: { hp: 90, attack: 50, defense: 70, speed: 80 },
41
+ nature: "Timid",
42
+ specialAbility: { name: "No Ability", description: "" },
43
+ movepool: [
44
+ {
45
+ name: "Freeze Ray",
46
+ type: AttackType.AQUATIC,
47
+ power: 40,
48
+ accuracy: 90,
49
+ pp: 15,
50
+ priority: 0,
51
+ flags: [],
52
+ effects: [
53
+ { type: 'damage', target: 'opponent', amount: 'normal' },
54
+ { type: 'applyStatus', target: 'opponent', status: 'freeze', chance: 30 }
55
+ ]
56
+ },
57
+ {
58
+ name: "Paralyzing Shock",
59
+ type: AttackType.MACHINA,
60
+ power: 45,
61
+ accuracy: 100,
62
+ pp: 20,
63
+ priority: 0,
64
+ flags: [],
65
+ effects: [
66
+ { type: 'damage', target: 'opponent', amount: 'normal' },
67
+ { type: 'applyStatus', target: 'opponent', status: 'paralyze', chance: 25 }
68
+ ]
69
+ },
70
+ {
71
+ name: "Sleep Powder",
72
+ type: AttackType.FLORA,
73
+ power: 0,
74
+ accuracy: 85,
75
+ pp: 15,
76
+ priority: 0,
77
+ flags: [],
78
+ effects: [
79
+ { type: 'applyStatus', target: 'opponent', status: 'sleep', chance: 100 }
80
+ ]
81
+ },
82
+ {
83
+ name: "Confuse Ray",
84
+ type: AttackType.SPACE,
85
+ power: 0,
86
+ accuracy: 100,
87
+ pp: 10,
88
+ priority: 0,
89
+ flags: [],
90
+ effects: [
91
+ { type: 'applyStatus', target: 'opponent', status: 'confuse', chance: 100 }
92
+ ]
93
+ }
94
+ ]
95
+ };
96
+ });
97
+
98
+ describe('Freeze Status Effect', () => {
99
+ it('should prevent the frozen piclet from acting', () => {
100
+ const engine = new BattleEngine(statusInflicter, basicPiclet);
101
+
102
+ // Force freeze to trigger by mocking Math.random
103
+ const originalRandom = Math.random;
104
+ Math.random = () => 0.1; // 10% < 30% chance, should trigger freeze
105
+
106
+ engine.executeActions(
107
+ { type: 'move', piclet: 'player', moveIndex: 0 }, // Freeze Ray
108
+ { type: 'move', piclet: 'opponent', moveIndex: 0 } // Should be prevented if frozen
109
+ );
110
+
111
+ // Restore Math.random
112
+ Math.random = originalRandom;
113
+
114
+ const log = engine.getLog();
115
+ const opponentState = engine.getState().opponentPiclet;
116
+
117
+ // Check that freeze was applied
118
+ expect(opponentState.statusEffects).toContain('freeze');
119
+ expect(log.some(msg => msg.includes('was frozen'))).toBe(true);
120
+
121
+ // Execute another turn to test freeze preventing action
122
+ if (!engine.isGameOver()) {
123
+ engine.executeActions(
124
+ { type: 'move', piclet: 'player', moveIndex: 0 },
125
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
126
+ );
127
+
128
+ const secondTurnLog = engine.getLog();
129
+ expect(secondTurnLog.some(msg => msg.includes('is frozen solid') || msg.includes('cannot move'))).toBe(true);
130
+ }
131
+ });
132
+
133
+ it('should have a chance to thaw each turn', () => {
134
+ const engine = new BattleEngine(statusInflicter, basicPiclet);
135
+
136
+ // Manually apply freeze status
137
+ engine['state'].opponentPiclet.statusEffects.push('freeze');
138
+
139
+ // Force thaw with low random number
140
+ const originalRandom = Math.random;
141
+ Math.random = () => 0.1; // Should trigger thaw (usually 20% chance)
142
+
143
+ engine.executeActions(
144
+ { type: 'move', piclet: 'player', moveIndex: 0 },
145
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
146
+ );
147
+
148
+ // Restore Math.random
149
+ Math.random = originalRandom;
150
+
151
+ const log = engine.getLog();
152
+ const opponentState = engine.getState().opponentPiclet;
153
+
154
+ // Should thaw and be able to act
155
+ expect(log.some(msg => msg.includes('thawed out') || msg.includes('is no longer frozen'))).toBe(true);
156
+ expect(opponentState.statusEffects).not.toContain('freeze');
157
+ });
158
+ });
159
+
160
+ describe('Paralysis Status Effect', () => {
161
+ it('should reduce speed by 50%', () => {
162
+ const engine = new BattleEngine(statusInflicter, basicPiclet);
163
+ const initialSpeed = engine.getState().opponentPiclet.speed;
164
+
165
+ // Force paralysis to trigger
166
+ const originalRandom = Math.random;
167
+ Math.random = () => 0.1; // 10% < 25% chance
168
+
169
+ engine.executeActions(
170
+ { type: 'move', piclet: 'player', moveIndex: 1 }, // Paralyzing Shock
171
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
172
+ );
173
+
174
+ // Restore Math.random
175
+ Math.random = originalRandom;
176
+
177
+ const finalSpeed = engine.getState().opponentPiclet.speed;
178
+ const log = engine.getLog();
179
+
180
+ expect(engine.getState().opponentPiclet.statusEffects).toContain('paralyze');
181
+ expect(log.some(msg => msg.includes('was paralyzed'))).toBe(true);
182
+ expect(finalSpeed).toBe(Math.floor(initialSpeed * 0.5)); // 50% speed reduction
183
+ });
184
+
185
+ it('should have 25% chance to prevent action', () => {
186
+ const engine = new BattleEngine(statusInflicter, basicPiclet);
187
+
188
+ // Manually apply paralysis
189
+ engine['state'].opponentPiclet.statusEffects.push('paralyze');
190
+ engine['state'].opponentPiclet.speed = Math.floor(engine['state'].opponentPiclet.speed * 0.5);
191
+
192
+ // Force paralysis to prevent action
193
+ const originalRandom = Math.random;
194
+ Math.random = () => 0.1; // Should trigger paralysis prevention (25% chance)
195
+
196
+ engine.executeActions(
197
+ { type: 'move', piclet: 'player', moveIndex: 0 },
198
+ { type: 'move', piclet: 'opponent', moveIndex: 0 } // Should be prevented
199
+ );
200
+
201
+ // Restore Math.random
202
+ Math.random = originalRandom;
203
+
204
+ const log = engine.getLog();
205
+ expect(log.some(msg =>
206
+ msg.includes('is fully paralyzed') ||
207
+ msg.includes('cannot move due to paralysis')
208
+ )).toBe(true);
209
+ });
210
+ });
211
+
212
+ describe('Sleep Status Effect', () => {
213
+ it('should prevent action and last 1-3 turns', () => {
214
+ const engine = new BattleEngine(statusInflicter, basicPiclet);
215
+
216
+ engine.executeActions(
217
+ { type: 'move', piclet: 'player', moveIndex: 2 }, // Sleep Powder
218
+ { type: 'move', piclet: 'opponent', moveIndex: 0 } // Should be prevented if asleep
219
+ );
220
+
221
+ const log = engine.getLog();
222
+ const opponentState = engine.getState().opponentPiclet;
223
+
224
+ expect(opponentState.statusEffects).toContain('sleep');
225
+ expect(log.some(msg => msg.includes('fell asleep'))).toBe(true);
226
+
227
+ // Sleep should prevent action
228
+ if (!engine.isGameOver()) {
229
+ engine.executeActions(
230
+ { type: 'move', piclet: 'player', moveIndex: 0 },
231
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
232
+ );
233
+
234
+ const secondLog = engine.getLog();
235
+ expect(secondLog.some(msg =>
236
+ msg.includes('is fast asleep') ||
237
+ msg.includes('cannot wake up')
238
+ )).toBe(true);
239
+ }
240
+ });
241
+
242
+ it('should wake up when attacked', () => {
243
+ const engine = new BattleEngine(basicPiclet, statusInflicter);
244
+
245
+ // Put player to sleep
246
+ engine['state'].playerPiclet.statusEffects.push('sleep');
247
+
248
+ engine.executeActions(
249
+ { type: 'move', piclet: 'player', moveIndex: 0 }, // Should be prevented by sleep
250
+ { type: 'move', piclet: 'opponent', moveIndex: 0 } // Attack should wake up player
251
+ );
252
+
253
+ const log = engine.getLog();
254
+ const playerState = engine.getState().playerPiclet;
255
+
256
+ // Should wake up when damaged
257
+ expect(log.some(msg => msg.includes('woke up'))).toBe(true);
258
+ expect(playerState.statusEffects).not.toContain('sleep');
259
+ });
260
+ });
261
+
262
+ describe('Confusion Status Effect', () => {
263
+ it('should last 2-5 turns and cause self-damage 33% of the time', () => {
264
+ const engine = new BattleEngine(statusInflicter, basicPiclet);
265
+
266
+ engine.executeActions(
267
+ { type: 'move', piclet: 'player', moveIndex: 3 }, // Confuse Ray
268
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
269
+ );
270
+
271
+ const log = engine.getLog();
272
+ const opponentState = engine.getState().opponentPiclet;
273
+
274
+ expect(opponentState.statusEffects).toContain('confuse');
275
+ expect(log.some(msg => msg.includes('became confused'))).toBe(true);
276
+
277
+ // Test confusion self-damage
278
+ const initialHp = engine.getState().opponentPiclet.currentHp;
279
+
280
+ // Force confusion self-damage
281
+ const originalRandom = Math.random;
282
+ Math.random = () => 0.2; // Should trigger self-damage (33% chance)
283
+
284
+ if (!engine.isGameOver()) {
285
+ engine.executeActions(
286
+ { type: 'move', piclet: 'player', moveIndex: 0 },
287
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
288
+ );
289
+
290
+ const confusedLog = engine.getLog();
291
+ const finalHp = engine.getState().opponentPiclet.currentHp;
292
+
293
+ expect(confusedLog.some(msg =>
294
+ msg.includes('hurt itself in confusion') ||
295
+ msg.includes('attacked itself')
296
+ )).toBe(true);
297
+ }
298
+
299
+ // Restore Math.random
300
+ Math.random = originalRandom;
301
+ });
302
+
303
+ it('should wear off after 2-5 turns', () => {
304
+ const engine = new BattleEngine(statusInflicter, basicPiclet);
305
+
306
+ // Manually apply confusion with duration
307
+ engine['state'].opponentPiclet.statusEffects.push('confuse');
308
+ (engine['state'].opponentPiclet as any).confusionTurns = 1; // Set to expire next turn
309
+
310
+ engine.executeActions(
311
+ { type: 'move', piclet: 'player', moveIndex: 0 },
312
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
313
+ );
314
+
315
+ const log = engine.getLog();
316
+ const opponentState = engine.getState().opponentPiclet;
317
+
318
+ expect(log.some(msg => msg.includes('is no longer confused') || msg.includes('snapped out of confusion'))).toBe(true);
319
+ expect(opponentState.statusEffects).not.toContain('confuse');
320
+ });
321
+ });
322
+
323
+ describe('Status Effect Interactions', () => {
324
+ it('should not allow multiple major status effects simultaneously', () => {
325
+ const engine = new BattleEngine(statusInflicter, basicPiclet);
326
+
327
+ // Apply freeze first
328
+ engine['state'].opponentPiclet.statusEffects.push('freeze');
329
+
330
+ // Try to apply paralysis
331
+ const originalRandom = Math.random;
332
+ Math.random = () => 0.1; // Should trigger paralysis normally
333
+
334
+ engine.executeActions(
335
+ { type: 'move', piclet: 'player', moveIndex: 1 }, // Paralyzing Shock
336
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
337
+ );
338
+
339
+ Math.random = originalRandom;
340
+
341
+ const opponentState = engine.getState().opponentPiclet;
342
+ const majorStatuses = opponentState.statusEffects.filter(status =>
343
+ ['freeze', 'paralyze', 'sleep'].includes(status)
344
+ );
345
+
346
+ // Should only have one major status effect
347
+ expect(majorStatuses.length).toBeLessThanOrEqual(1);
348
+ });
349
+
350
+ it('should allow confusion alongside other status effects', () => {
351
+ const engine = new BattleEngine(statusInflicter, basicPiclet);
352
+
353
+ // Apply paralysis first
354
+ engine['state'].opponentPiclet.statusEffects.push('paralyze');
355
+
356
+ // Apply confusion
357
+ engine.executeActions(
358
+ { type: 'move', piclet: 'player', moveIndex: 3 }, // Confuse Ray
359
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
360
+ );
361
+
362
+ const opponentState = engine.getState().opponentPiclet;
363
+
364
+ // Should have both paralysis and confusion
365
+ expect(opponentState.statusEffects).toContain('paralyze');
366
+ expect(opponentState.statusEffects).toContain('confuse');
367
+ });
368
+ });
369
+ });
src/lib/battle-engine/debug-field-effects.test.ts ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from 'vitest';
2
+ import { BattleEngine } from './BattleEngine';
3
+ import type { PicletDefinition } from './types';
4
+ import { PicletType, AttackType } from './types';
5
+
6
+ describe('Debug Field Effects', () => {
7
+ it('should debug field effect creation and damage calculation', () => {
8
+ const fieldUser: PicletDefinition = {
9
+ name: "Field User",
10
+ description: "Creates field effects",
11
+ tier: 'medium',
12
+ primaryType: PicletType.BEAST, // Changed to BEAST so opponent can damage it
13
+ baseStats: { hp: 100, attack: 60, defense: 80, speed: 60 },
14
+ nature: "Calm",
15
+ specialAbility: { name: "No Ability", description: "" },
16
+ movepool: [
17
+ {
18
+ name: "Contact Barrier",
19
+ type: AttackType.SPACE,
20
+ power: 0,
21
+ accuracy: 100,
22
+ pp: 10,
23
+ priority: 0,
24
+ flags: [],
25
+ effects: [
26
+ {
27
+ type: 'fieldEffect',
28
+ effect: 'reflect',
29
+ target: 'playerSide',
30
+ stackable: false
31
+ }
32
+ ]
33
+ }
34
+ ]
35
+ };
36
+
37
+ const attacker: PicletDefinition = {
38
+ name: "Contact Attacker",
39
+ description: "Uses contact moves",
40
+ tier: 'medium',
41
+ primaryType: PicletType.BEAST,
42
+ baseStats: { hp: 80, attack: 80, defense: 60, speed: 70 },
43
+ nature: "Adamant",
44
+ specialAbility: { name: "No Ability", description: "" },
45
+ movepool: [
46
+ {
47
+ name: "Physical Strike",
48
+ type: AttackType.BEAST,
49
+ power: 60,
50
+ accuracy: 100,
51
+ pp: 15,
52
+ priority: 0,
53
+ flags: ['contact'],
54
+ effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
55
+ }
56
+ ]
57
+ };
58
+
59
+ const engine = new BattleEngine(fieldUser, attacker);
60
+
61
+ console.log('Initial state:', {
62
+ playerHp: engine.getState().playerPiclet.currentHp,
63
+ opponentHp: engine.getState().opponentPiclet.currentHp,
64
+ fieldEffects: engine.getState().fieldEffects
65
+ });
66
+
67
+ // First turn: create barrier and get attacked
68
+ engine.executeActions(
69
+ { type: 'move', piclet: 'player', moveIndex: 0 }, // Contact Barrier
70
+ { type: 'move', piclet: 'opponent', moveIndex: 0 } // Physical Strike
71
+ );
72
+
73
+ console.log('After first turn:', {
74
+ playerHp: engine.getState().playerPiclet.currentHp,
75
+ opponentHp: engine.getState().opponentPiclet.currentHp,
76
+ fieldEffects: engine.getState().fieldEffects,
77
+ log: engine.getLog()
78
+ });
79
+
80
+ // Second turn: opponent attacks again (should be reduced)
81
+ const hpBeforeSecondAttack = engine.getState().playerPiclet.currentHp;
82
+
83
+ engine.executeActions(
84
+ { type: 'move', piclet: 'player', moveIndex: 0 }, // Contact Barrier (no effect)
85
+ { type: 'move', piclet: 'opponent', moveIndex: 0 } // Physical Strike (should be reduced)
86
+ );
87
+
88
+ const hpAfterSecondAttack = engine.getState().playerPiclet.currentHp;
89
+ const damage = hpBeforeSecondAttack - hpAfterSecondAttack;
90
+
91
+ console.log('After second turn:', {
92
+ playerHp: hpAfterSecondAttack,
93
+ damage: damage,
94
+ fieldEffects: engine.getState().fieldEffects,
95
+ log: engine.getLog()
96
+ });
97
+
98
+ expect(engine.getState().fieldEffects.length).toBeGreaterThan(0);
99
+ expect(damage).toBeGreaterThan(0); // Some damage should occur
100
+ });
101
+ });
src/lib/battle-engine/field-effects.test.ts ADDED
@@ -0,0 +1,493 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { BattleEngine } from './BattleEngine';
3
+ import type { PicletDefinition } from './types';
4
+ import { PicletType, AttackType } from './types';
5
+
6
+ describe('Field Effects System', () => {
7
+ let contactAttacker: PicletDefinition;
8
+ let nonContactAttacker: PicletDefinition;
9
+ let fieldEffectUser: PicletDefinition;
10
+ let basicOpponent: PicletDefinition;
11
+
12
+ beforeEach(() => {
13
+ // Piclet that uses contact moves
14
+ contactAttacker = {
15
+ name: "Contact Fighter",
16
+ description: "Uses contact moves",
17
+ tier: 'medium',
18
+ primaryType: PicletType.BEAST,
19
+ baseStats: { hp: 80, attack: 80, defense: 60, speed: 70 },
20
+ nature: "Adamant",
21
+ specialAbility: { name: "No Ability", description: "" },
22
+ movepool: [
23
+ {
24
+ name: "Physical Strike",
25
+ type: AttackType.BEAST,
26
+ power: 60,
27
+ accuracy: 100,
28
+ pp: 15,
29
+ priority: 0,
30
+ flags: ['contact'],
31
+ effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
32
+ }
33
+ ]
34
+ };
35
+
36
+ // Piclet that uses non-contact moves
37
+ nonContactAttacker = {
38
+ name: "Ranged Fighter",
39
+ description: "Uses non-contact moves",
40
+ tier: 'medium',
41
+ primaryType: PicletType.SPACE,
42
+ baseStats: { hp: 80, attack: 80, defense: 60, speed: 70 },
43
+ nature: "Modest",
44
+ specialAbility: { name: "No Ability", description: "" },
45
+ movepool: [
46
+ {
47
+ name: "Energy Blast",
48
+ type: AttackType.SPACE,
49
+ power: 60,
50
+ accuracy: 100,
51
+ pp: 15,
52
+ priority: 0,
53
+ flags: [],
54
+ effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
55
+ }
56
+ ]
57
+ };
58
+
59
+ // Piclet that can create field effects
60
+ fieldEffectUser = {
61
+ name: "Field Controller",
62
+ description: "Controls battlefield effects",
63
+ tier: 'medium',
64
+ primaryType: PicletType.BEAST, // Changed to BEAST so opponent can damage it
65
+ baseStats: { hp: 100, attack: 60, defense: 80, speed: 60 },
66
+ nature: "Calm",
67
+ specialAbility: { name: "No Ability", description: "" },
68
+ movepool: [
69
+ {
70
+ name: "Contact Barrier",
71
+ type: AttackType.SPACE,
72
+ power: 0,
73
+ accuracy: 100,
74
+ pp: 10,
75
+ priority: 0,
76
+ flags: [],
77
+ effects: [
78
+ {
79
+ type: 'fieldEffect',
80
+ effect: 'reflect',
81
+ target: 'playerSide',
82
+ stackable: false
83
+ }
84
+ ]
85
+ },
86
+ {
87
+ name: "Non-Contact Barrier",
88
+ type: AttackType.SPACE,
89
+ power: 0,
90
+ accuracy: 100,
91
+ pp: 10,
92
+ priority: 0,
93
+ flags: [],
94
+ effects: [
95
+ {
96
+ type: 'fieldEffect',
97
+ effect: 'lightScreen',
98
+ target: 'playerSide',
99
+ stackable: false
100
+ }
101
+ ]
102
+ },
103
+ {
104
+ name: "Entry Spikes",
105
+ type: AttackType.MINERAL,
106
+ power: 0,
107
+ accuracy: 100,
108
+ pp: 10,
109
+ priority: 0,
110
+ flags: [],
111
+ effects: [
112
+ {
113
+ type: 'fieldEffect',
114
+ effect: 'spikes',
115
+ target: 'opponentSide',
116
+ stackable: true
117
+ }
118
+ ]
119
+ },
120
+ {
121
+ name: "Healing Field",
122
+ type: AttackType.FLORA,
123
+ power: 0,
124
+ accuracy: 100,
125
+ pp: 10,
126
+ priority: 0,
127
+ flags: [],
128
+ effects: [
129
+ {
130
+ type: 'fieldEffect',
131
+ effect: 'healingMist',
132
+ target: 'field',
133
+ stackable: false
134
+ }
135
+ ]
136
+ }
137
+ ]
138
+ };
139
+
140
+ // Basic opponent for testing
141
+ basicOpponent = {
142
+ name: "Basic Opponent",
143
+ description: "Standard test opponent",
144
+ tier: 'medium',
145
+ primaryType: PicletType.BEAST,
146
+ baseStats: { hp: 80, attack: 60, defense: 60, speed: 60 },
147
+ nature: "Hardy",
148
+ specialAbility: { name: "No Ability", description: "" },
149
+ movepool: [
150
+ {
151
+ name: "Basic Attack",
152
+ type: AttackType.NORMAL,
153
+ power: 50,
154
+ accuracy: 100,
155
+ pp: 20,
156
+ priority: 0,
157
+ flags: ['contact'],
158
+ effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
159
+ }
160
+ ]
161
+ };
162
+ });
163
+
164
+ describe('Contact Damage Reduction (Reflect)', () => {
165
+ it('should reduce contact move damage by 50%', () => {
166
+ const engine = new BattleEngine(fieldEffectUser, contactAttacker);
167
+
168
+ // Set up barrier first
169
+ engine.executeActions(
170
+ { type: 'move', piclet: 'player', moveIndex: 0 }, // Contact Barrier
171
+ { type: 'move', piclet: 'opponent', moveIndex: 0 } // Basic Attack (will be reduced)
172
+ );
173
+
174
+ const log = engine.getLog();
175
+ expect(log.some(msg => msg.includes('barrier was raised to reduce contact move damage'))).toBe(true);
176
+
177
+ // Test that subsequent contact moves are reduced
178
+ const initialHp = engine.getState().playerPiclet.currentHp;
179
+
180
+ engine.executeActions(
181
+ { type: 'move', piclet: 'player', moveIndex: 0 }, // Contact Barrier (no effect, already active)
182
+ { type: 'move', piclet: 'opponent', moveIndex: 0 } // Basic Attack (should be reduced)
183
+ );
184
+
185
+ const finalHp = engine.getState().playerPiclet.currentHp;
186
+ const damage = initialHp - finalHp;
187
+
188
+ // Damage should be significantly reduced (less than normal ~30-40 damage)
189
+ expect(damage).toBeLessThan(25);
190
+ expect(damage).toBeGreaterThan(0); // But still some damage
191
+ });
192
+
193
+ it('should not reduce non-contact move damage', () => {
194
+ const engine = new BattleEngine(fieldEffectUser, nonContactAttacker);
195
+
196
+ // Set up contact barrier
197
+ engine.executeActions(
198
+ { type: 'move', piclet: 'player', moveIndex: 0 }, // Contact Barrier
199
+ { type: 'move', piclet: 'opponent', moveIndex: 0 } // Energy Blast (non-contact)
200
+ );
201
+
202
+ // Test that non-contact moves are NOT reduced
203
+ const initialHp = engine.getState().playerPiclet.currentHp;
204
+
205
+ engine.executeActions(
206
+ { type: 'move', piclet: 'player', moveIndex: 0 },
207
+ { type: 'move', piclet: 'opponent', moveIndex: 0 } // Energy Blast (should not be reduced)
208
+ );
209
+
210
+ const finalHp = engine.getState().playerPiclet.currentHp;
211
+ const damage = initialHp - finalHp;
212
+
213
+ // Damage should be normal (around 30-50)
214
+ expect(damage).toBeGreaterThan(25);
215
+ });
216
+ });
217
+
218
+ describe('Non-Contact Damage Reduction (Light Screen)', () => {
219
+ it('should reduce non-contact move damage by 50%', () => {
220
+ const engine = new BattleEngine(fieldEffectUser, nonContactAttacker);
221
+
222
+ // Set up non-contact barrier
223
+ engine.executeActions(
224
+ { type: 'move', piclet: 'player', moveIndex: 1 }, // Non-Contact Barrier
225
+ { type: 'move', piclet: 'opponent', moveIndex: 0 } // Energy Blast (should be reduced)
226
+ );
227
+
228
+ const log = engine.getLog();
229
+ expect(log.some(msg => msg.includes('barrier was raised to reduce non-contact move damage'))).toBe(true);
230
+
231
+ // Test reduction on subsequent turn
232
+ const initialHp = engine.getState().playerPiclet.currentHp;
233
+
234
+ engine.executeActions(
235
+ { type: 'move', piclet: 'player', moveIndex: 1 },
236
+ { type: 'move', piclet: 'opponent', moveIndex: 0 } // Energy Blast (should be reduced)
237
+ );
238
+
239
+ const finalHp = engine.getState().playerPiclet.currentHp;
240
+ const damage = initialHp - finalHp;
241
+
242
+ // Damage should be reduced
243
+ expect(damage).toBeLessThan(25);
244
+ expect(damage).toBeGreaterThan(0);
245
+ });
246
+
247
+ it('should not reduce contact move damage', () => {
248
+ const engine = new BattleEngine(fieldEffectUser, contactAttacker);
249
+
250
+ // Set up non-contact barrier
251
+ engine.executeActions(
252
+ { type: 'move', piclet: 'player', moveIndex: 1 }, // Non-Contact Barrier
253
+ { type: 'move', piclet: 'opponent', moveIndex: 0 } // Physical Strike (contact)
254
+ );
255
+
256
+ // Test that contact moves are NOT reduced
257
+ const initialHp = engine.getState().playerPiclet.currentHp;
258
+
259
+ engine.executeActions(
260
+ { type: 'move', piclet: 'player', moveIndex: 1 },
261
+ { type: 'move', piclet: 'opponent', moveIndex: 0 } // Physical Strike (should not be reduced)
262
+ );
263
+
264
+ const finalHp = engine.getState().playerPiclet.currentHp;
265
+ const damage = initialHp - finalHp;
266
+
267
+ // Damage should be normal
268
+ expect(damage).toBeGreaterThan(25);
269
+ });
270
+ });
271
+
272
+ describe('Entry Hazards (Spikes)', () => {
273
+ it('should set up entry spikes on opponent side', () => {
274
+ const engine = new BattleEngine(fieldEffectUser, basicOpponent);
275
+
276
+ engine.executeActions(
277
+ { type: 'move', piclet: 'player', moveIndex: 2 }, // Entry Spikes
278
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
279
+ );
280
+
281
+ const log = engine.getLog();
282
+ expect(log.some(msg => msg.includes('Entry spikes were scattered'))).toBe(true);
283
+
284
+ // Check that field effect was applied
285
+ const state = engine.getState();
286
+ expect(state.fieldEffects.some(effect => effect.name === 'entryHazardSpikes')).toBe(true);
287
+ });
288
+
289
+ it('should be stackable', () => {
290
+ const engine = new BattleEngine(fieldEffectUser, basicOpponent);
291
+
292
+ // Apply spikes twice
293
+ engine.executeActions(
294
+ { type: 'move', piclet: 'player', moveIndex: 2 }, // Entry Spikes
295
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
296
+ );
297
+
298
+ engine.executeActions(
299
+ { type: 'move', piclet: 'player', moveIndex: 2 }, // Entry Spikes again
300
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
301
+ );
302
+
303
+ const state = engine.getState();
304
+ const spikeEffects = state.fieldEffects.filter(effect => effect.name === 'entryHazardSpikes');
305
+ expect(spikeEffects.length).toBe(2); // Should stack
306
+ });
307
+ });
308
+
309
+ describe('Healing Field', () => {
310
+ it('should create a healing field that affects both sides', () => {
311
+ const engine = new BattleEngine(fieldEffectUser, basicOpponent);
312
+
313
+ // Damage both piclets first
314
+ engine['state'].playerPiclet.currentHp = Math.floor(engine['state'].playerPiclet.maxHp * 0.5);
315
+ engine['state'].opponentPiclet.currentHp = Math.floor(engine['state'].opponentPiclet.maxHp * 0.5);
316
+
317
+ const playerInitialHp = engine.getState().playerPiclet.currentHp;
318
+ const opponentInitialHp = engine.getState().opponentPiclet.currentHp;
319
+
320
+ engine.executeActions(
321
+ { type: 'move', piclet: 'player', moveIndex: 3 }, // Healing Field
322
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
323
+ );
324
+
325
+ const log = engine.getLog();
326
+ expect(log.some(msg => msg.includes('healing field was created'))).toBe(true);
327
+
328
+ // Both piclets should be healed at end of turn
329
+ const playerFinalHp = engine.getState().playerPiclet.currentHp;
330
+ const opponentFinalHp = engine.getState().opponentPiclet.currentHp;
331
+
332
+ expect(playerFinalHp).toBeGreaterThan(playerInitialHp);
333
+ expect(opponentFinalHp).toBeGreaterThan(opponentInitialHp);
334
+ expect(log.some(msg => msg.includes('healed by the healing field'))).toBe(true);
335
+ });
336
+
337
+ it('should not heal piclets at full HP', () => {
338
+ const engine = new BattleEngine(fieldEffectUser, basicOpponent);
339
+
340
+ // Both piclets at full HP
341
+ const playerInitialHp = engine.getState().playerPiclet.currentHp;
342
+ const opponentInitialHp = engine.getState().opponentPiclet.currentHp;
343
+
344
+ engine.executeActions(
345
+ { type: 'move', piclet: 'player', moveIndex: 3 }, // Healing Field
346
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
347
+ );
348
+
349
+ // HP should remain the same
350
+ const playerFinalHp = engine.getState().playerPiclet.currentHp;
351
+ const opponentFinalHp = engine.getState().opponentPiclet.currentHp;
352
+
353
+ expect(playerFinalHp).toBe(playerInitialHp);
354
+ expect(opponentFinalHp).toBe(opponentInitialHp);
355
+ });
356
+ });
357
+
358
+ describe('Field Effect Duration and Management', () => {
359
+ it('should expire field effects after 5 turns', () => {
360
+ const engine = new BattleEngine(fieldEffectUser, basicOpponent);
361
+
362
+ // Create a barrier
363
+ engine.executeActions(
364
+ { type: 'move', piclet: 'player', moveIndex: 0 }, // Contact Barrier
365
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
366
+ );
367
+
368
+ // Verify it exists
369
+ expect(engine.getState().fieldEffects.length).toBe(1);
370
+
371
+ // Pass 5 turns
372
+ for (let i = 0; i < 5; i++) {
373
+ engine.executeActions(
374
+ { type: 'move', piclet: 'player', moveIndex: 0 },
375
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
376
+ );
377
+ }
378
+
379
+ // Effect should have expired
380
+ const log = engine.getLog();
381
+ expect(log.some(msg => msg.includes('faded away'))).toBe(true);
382
+ expect(engine.getState().fieldEffects.length).toBe(0);
383
+ });
384
+
385
+ it('should not stack non-stackable effects', () => {
386
+ const engine = new BattleEngine(fieldEffectUser, basicOpponent);
387
+
388
+ // Apply same barrier twice
389
+ engine.executeActions(
390
+ { type: 'move', piclet: 'player', moveIndex: 0 }, // Contact Barrier
391
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
392
+ );
393
+
394
+ engine.executeActions(
395
+ { type: 'move', piclet: 'player', moveIndex: 0 }, // Contact Barrier again
396
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
397
+ );
398
+
399
+ // Should only have one effect (refreshed duration)
400
+ const contactBarriers = engine.getState().fieldEffects.filter(
401
+ effect => effect.name === 'contactDamageReduction'
402
+ );
403
+ expect(contactBarriers.length).toBe(1);
404
+ });
405
+
406
+ it('should properly format field effect names in logs', () => {
407
+ const engine = new BattleEngine(fieldEffectUser, basicOpponent);
408
+
409
+ engine.executeActions(
410
+ { type: 'move', piclet: 'player', moveIndex: 0 }, // Contact Barrier
411
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
412
+ );
413
+
414
+ // Pass enough turns for effect to fade
415
+ for (let i = 0; i < 5; i++) {
416
+ engine.executeActions(
417
+ { type: 'move', piclet: 'player', moveIndex: 0 },
418
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
419
+ );
420
+ }
421
+
422
+ const log = engine.getLog();
423
+ expect(log.some(msg =>
424
+ msg.includes('Contact damage barrier faded away') ||
425
+ msg.includes('contact damage barrier faded away')
426
+ )).toBe(true);
427
+ });
428
+ });
429
+
430
+ describe('Field Effect Integration with Battle Flow', () => {
431
+ it('should apply field effects during damage calculation', () => {
432
+ const engine = new BattleEngine(fieldEffectUser, contactAttacker);
433
+
434
+ // Measure baseline damage first
435
+ const baselineEngine = new BattleEngine(fieldEffectUser, contactAttacker);
436
+ const baselineInitialHp = baselineEngine.getState().playerPiclet.currentHp;
437
+
438
+ baselineEngine.executeActions(
439
+ { type: 'move', piclet: 'player', moveIndex: 0 },
440
+ { type: 'move', piclet: 'opponent', moveIndex: 0 } // No barrier
441
+ );
442
+
443
+ const baselineDamage = baselineInitialHp - baselineEngine.getState().playerPiclet.currentHp;
444
+
445
+ // Now test with barrier
446
+ engine.executeActions(
447
+ { type: 'move', piclet: 'player', moveIndex: 0 }, // Contact Barrier
448
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
449
+ );
450
+
451
+ const protectedInitialHp = engine.getState().playerPiclet.currentHp;
452
+
453
+ engine.executeActions(
454
+ { type: 'move', piclet: 'player', moveIndex: 0 },
455
+ { type: 'move', piclet: 'opponent', moveIndex: 0 } // Attack against barrier
456
+ );
457
+
458
+ const protectedDamage = protectedInitialHp - engine.getState().playerPiclet.currentHp;
459
+
460
+ // Protected damage should be significantly less
461
+ expect(protectedDamage).toBeLessThan(baselineDamage * 0.75);
462
+ });
463
+
464
+ it('should handle multiple field effects simultaneously', () => {
465
+ const engine = new BattleEngine(fieldEffectUser, basicOpponent);
466
+
467
+ // Apply multiple field effects
468
+ engine.executeActions(
469
+ { type: 'move', piclet: 'player', moveIndex: 0 }, // Contact Barrier
470
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
471
+ );
472
+
473
+ engine.executeActions(
474
+ { type: 'move', piclet: 'player', moveIndex: 3 }, // Healing Field
475
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
476
+ );
477
+
478
+ engine.executeActions(
479
+ { type: 'move', piclet: 'player', moveIndex: 2 }, // Entry Spikes
480
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
481
+ );
482
+
483
+ // Should have 3 different field effects
484
+ const state = engine.getState();
485
+ expect(state.fieldEffects.length).toBe(3);
486
+
487
+ const effectNames = state.fieldEffects.map(effect => effect.name);
488
+ expect(effectNames).toContain('contactDamageReduction');
489
+ expect(effectNames).toContain('healingField');
490
+ expect(effectNames).toContain('entryHazardSpikes');
491
+ });
492
+ });
493
+ });
src/lib/battle-engine/remaining-triggers.test.ts ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { BattleEngine } from './BattleEngine';
3
+ import type { PicletDefinition } from './types';
4
+ import { PicletType, AttackType } from './types';
5
+
6
+ describe('Remaining Trigger Events', () => {
7
+ let basicPiclet: PicletDefinition;
8
+ let triggerPiclet: PicletDefinition;
9
+
10
+ beforeEach(() => {
11
+ basicPiclet = {
12
+ name: "Basic Fighter",
13
+ description: "Standard test piclet",
14
+ tier: 'medium',
15
+ primaryType: PicletType.BEAST,
16
+ baseStats: { hp: 80, attack: 60, defense: 60, speed: 60 },
17
+ nature: "Hardy",
18
+ specialAbility: { name: "No Ability", description: "" },
19
+ movepool: [
20
+ {
21
+ name: "Basic Attack",
22
+ type: AttackType.BEAST,
23
+ power: 50,
24
+ accuracy: 100,
25
+ pp: 20,
26
+ priority: 0,
27
+ flags: ['contact'],
28
+ effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
29
+ }
30
+ ]
31
+ };
32
+
33
+ triggerPiclet = {
34
+ name: "Trigger Test",
35
+ description: "Tests all trigger events",
36
+ tier: 'medium',
37
+ primaryType: PicletType.CULTURE,
38
+ baseStats: { hp: 100, attack: 70, defense: 70, speed: 50 },
39
+ nature: "Bold",
40
+ specialAbility: {
41
+ name: "Multi Trigger",
42
+ description: "Triggers on various events",
43
+ triggers: []
44
+ },
45
+ movepool: [
46
+ {
47
+ name: "Drain Punch",
48
+ type: AttackType.BEAST,
49
+ power: 60,
50
+ accuracy: 100,
51
+ pp: 15,
52
+ priority: 0,
53
+ flags: ['contact'],
54
+ effects: [
55
+ { type: 'damage', target: 'opponent', amount: 'normal', formula: 'drain', value: 0.5 }
56
+ ]
57
+ },
58
+ {
59
+ name: "Status Move",
60
+ type: AttackType.CULTURE,
61
+ power: 0,
62
+ accuracy: 100,
63
+ pp: 20,
64
+ priority: 0,
65
+ flags: [],
66
+ effects: [
67
+ { type: 'applyStatus', target: 'opponent', status: 'paralyze', chance: 100 }
68
+ ]
69
+ },
70
+ {
71
+ name: "Stat Boost",
72
+ type: AttackType.CULTURE,
73
+ power: 0,
74
+ accuracy: 100,
75
+ pp: 20,
76
+ priority: 0,
77
+ flags: [],
78
+ effects: [
79
+ { type: 'modifyStats', target: 'self', stats: { attack: 'increase' } }
80
+ ]
81
+ },
82
+ {
83
+ name: "Heal Move",
84
+ type: AttackType.CULTURE,
85
+ power: 0,
86
+ accuracy: 100,
87
+ pp: 15,
88
+ priority: 0,
89
+ flags: [],
90
+ effects: [
91
+ { type: 'heal', target: 'self', amount: 'large' }
92
+ ]
93
+ }
94
+ ]
95
+ };
96
+ });
97
+
98
+ describe('onStatusInflicted Trigger', () => {
99
+ it('should trigger when status effect is applied', () => {
100
+ const statusTriggerPiclet = {
101
+ ...triggerPiclet,
102
+ specialAbility: {
103
+ name: "Status Aware",
104
+ description: "Triggers when status is inflicted",
105
+ triggers: [
106
+ {
107
+ event: 'onStatusInflicted',
108
+ condition: 'always',
109
+ effects: [
110
+ {
111
+ type: 'modifyStats',
112
+ target: 'self',
113
+ stats: { attack: 'increase' }
114
+ }
115
+ ]
116
+ }
117
+ ]
118
+ }
119
+ };
120
+
121
+ const engine = new BattleEngine(basicPiclet, statusTriggerPiclet);
122
+ const initialAttack = engine.getState().opponentPiclet.attack;
123
+
124
+ // Use status move to trigger the ability
125
+ engine.executeActions(
126
+ { type: 'move', piclet: 'player', moveIndex: 0 },
127
+ { type: 'move', piclet: 'opponent', moveIndex: 1 } // Status Move
128
+ );
129
+
130
+ const finalAttack = engine.getState().opponentPiclet.attack;
131
+ const log = engine.getLog();
132
+
133
+ console.log('Status inflicted test log:', log);
134
+ console.log('Initial attack:', initialAttack, 'Final attack:', finalAttack);
135
+
136
+ expect(finalAttack).toBeGreaterThan(initialAttack);
137
+ expect(log.some(msg => msg.includes('Status Aware') && msg.includes('triggered'))).toBe(true);
138
+ });
139
+ });
140
+
141
+ describe('onHPDrained Trigger', () => {
142
+ it('should trigger when HP is drained from opponent', () => {
143
+ const drainTriggerPiclet = {
144
+ ...triggerPiclet,
145
+ specialAbility: {
146
+ name: "Life Stealer",
147
+ description: "Triggers when draining HP",
148
+ triggers: [
149
+ {
150
+ event: 'onHPDrained',
151
+ condition: 'always',
152
+ effects: [
153
+ {
154
+ type: 'modifyStats',
155
+ target: 'self',
156
+ stats: { speed: 'increase' }
157
+ }
158
+ ]
159
+ }
160
+ ]
161
+ }
162
+ };
163
+
164
+ const engine = new BattleEngine(basicPiclet, drainTriggerPiclet);
165
+ const initialSpeed = engine.getState().opponentPiclet.speed;
166
+
167
+ // Use drain move to trigger the ability
168
+ engine.executeActions(
169
+ { type: 'move', piclet: 'player', moveIndex: 0 },
170
+ { type: 'move', piclet: 'opponent', moveIndex: 0 } // Drain Punch
171
+ );
172
+
173
+ const finalSpeed = engine.getState().opponentPiclet.speed;
174
+ const log = engine.getLog();
175
+
176
+ expect(finalSpeed).toBeGreaterThan(initialSpeed);
177
+ expect(log.some(msg => msg.includes('Life Stealer') && msg.includes('triggered'))).toBe(true);
178
+ });
179
+ });
180
+
181
+ describe('beforeMoveUse/afterMoveUse Triggers', () => {
182
+ it('should trigger before and after move use', () => {
183
+ const moveTriggerPiclet = {
184
+ ...triggerPiclet,
185
+ specialAbility: {
186
+ name: "Move Monitor",
187
+ description: "Triggers before and after moves",
188
+ triggers: [
189
+ {
190
+ event: 'beforeMoveUse',
191
+ condition: 'always',
192
+ effects: [
193
+ {
194
+ type: 'modifyStats',
195
+ target: 'self',
196
+ stats: { defense: 'increase' }
197
+ }
198
+ ]
199
+ },
200
+ {
201
+ event: 'afterMoveUse',
202
+ condition: 'always',
203
+ effects: [
204
+ {
205
+ type: 'modifyStats',
206
+ target: 'self',
207
+ stats: { accuracy: 'increase' }
208
+ }
209
+ ]
210
+ }
211
+ ]
212
+ }
213
+ };
214
+
215
+ const engine = new BattleEngine(basicPiclet, moveTriggerPiclet);
216
+ const initialDefense = engine.getState().opponentPiclet.defense;
217
+ const initialAccuracy = engine.getState().opponentPiclet.accuracy;
218
+
219
+ engine.executeActions(
220
+ { type: 'move', piclet: 'player', moveIndex: 0 },
221
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
222
+ );
223
+
224
+ const finalDefense = engine.getState().opponentPiclet.defense;
225
+ const finalAccuracy = engine.getState().opponentPiclet.accuracy;
226
+ const log = engine.getLog();
227
+
228
+ expect(finalDefense).toBeGreaterThan(initialDefense);
229
+ expect(finalAccuracy).toBeGreaterThan(initialAccuracy);
230
+ expect(log.some(msg => msg.includes('Move Monitor') && msg.includes('triggered'))).toBe(true);
231
+ });
232
+ });
233
+
234
+ describe('onFullHP Trigger', () => {
235
+ it('should trigger when HP reaches maximum', () => {
236
+ const fullHpTriggerPiclet = {
237
+ ...triggerPiclet,
238
+ specialAbility: {
239
+ name: "Full Power",
240
+ description: "Triggers when at full HP",
241
+ triggers: [
242
+ {
243
+ event: 'onFullHP',
244
+ condition: 'always',
245
+ effects: [
246
+ {
247
+ type: 'modifyStats',
248
+ target: 'self',
249
+ stats: { attack: 'greatly_increase' }
250
+ }
251
+ ]
252
+ }
253
+ ]
254
+ }
255
+ };
256
+
257
+ const engine = new BattleEngine(basicPiclet, fullHpTriggerPiclet);
258
+
259
+ // Damage the piclet first
260
+ engine['state'].opponentPiclet.currentHp = Math.floor(engine['state'].opponentPiclet.maxHp * 0.5);
261
+ const initialAttack = engine.getState().opponentPiclet.attack;
262
+
263
+ // Use heal move to reach full HP
264
+ engine.executeActions(
265
+ { type: 'move', piclet: 'player', moveIndex: 0 },
266
+ { type: 'move', piclet: 'opponent', moveIndex: 3 } // Heal Move
267
+ );
268
+
269
+ const finalAttack = engine.getState().opponentPiclet.attack;
270
+ const log = engine.getLog();
271
+
272
+ // Should trigger if healed to full HP
273
+ if (engine.getState().opponentPiclet.currentHp === engine.getState().opponentPiclet.maxHp) {
274
+ expect(finalAttack).toBeGreaterThan(initialAttack);
275
+ expect(log.some(msg => msg.includes('Full Power') && msg.includes('triggered'))).toBe(true);
276
+ }
277
+ });
278
+ });
279
+
280
+ describe('onOpponentContactMove Trigger', () => {
281
+ it('should trigger when opponent uses contact move', () => {
282
+ const contactTriggerPiclet = {
283
+ ...triggerPiclet,
284
+ specialAbility: {
285
+ name: "Rough Skin",
286
+ description: "Triggers when hit by contact moves",
287
+ triggers: [
288
+ {
289
+ event: 'onOpponentContactMove',
290
+ condition: 'always',
291
+ effects: [
292
+ {
293
+ type: 'damage',
294
+ target: 'opponent',
295
+ amount: 'weak'
296
+ }
297
+ ]
298
+ }
299
+ ]
300
+ }
301
+ };
302
+
303
+ const engine = new BattleEngine(basicPiclet, contactTriggerPiclet);
304
+ const initialPlayerHp = engine.getState().playerPiclet.currentHp;
305
+
306
+ // Player uses contact move
307
+ engine.executeActions(
308
+ { type: 'move', piclet: 'player', moveIndex: 0 }, // Basic Attack (contact)
309
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
310
+ );
311
+
312
+ const finalPlayerHp = engine.getState().playerPiclet.currentHp;
313
+ const log = engine.getLog();
314
+
315
+ // Player should take damage from contact
316
+ expect(finalPlayerHp).toBeLessThan(initialPlayerHp);
317
+ expect(log.some(msg => msg.includes('Rough Skin') && msg.includes('triggered'))).toBe(true);
318
+ });
319
+ });
320
+
321
+ describe('onStatChange Trigger', () => {
322
+ it('should trigger when stats are modified', () => {
323
+ const statTriggerPiclet = {
324
+ ...triggerPiclet,
325
+ specialAbility: {
326
+ name: "Stat Monitor",
327
+ description: "Triggers when stats change",
328
+ triggers: [
329
+ {
330
+ event: 'onStatChange',
331
+ condition: 'always',
332
+ effects: [
333
+ {
334
+ type: 'heal',
335
+ target: 'self',
336
+ amount: 'small'
337
+ }
338
+ ]
339
+ }
340
+ ]
341
+ }
342
+ };
343
+
344
+ const engine = new BattleEngine(basicPiclet, statTriggerPiclet);
345
+
346
+ // Damage the piclet first so healing is visible
347
+ engine['state'].opponentPiclet.currentHp = Math.floor(engine['state'].opponentPiclet.maxHp * 0.8);
348
+ const initialHp = engine.getState().opponentPiclet.currentHp;
349
+
350
+ // Use stat boost move to trigger the ability
351
+ engine.executeActions(
352
+ { type: 'move', piclet: 'player', moveIndex: 0 },
353
+ { type: 'move', piclet: 'opponent', moveIndex: 2 } // Stat Boost
354
+ );
355
+
356
+ const finalHp = engine.getState().opponentPiclet.currentHp;
357
+ const log = engine.getLog();
358
+
359
+ expect(finalHp).toBeGreaterThan(initialHp);
360
+ expect(log.some(msg => msg.includes('Stat Monitor') && msg.includes('triggered'))).toBe(true);
361
+ });
362
+ });
363
+ });
src/lib/battle-engine/switching-system.test.ts ADDED
@@ -0,0 +1,445 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { BattleEngine } from './BattleEngine';
3
+ import type { PicletDefinition } from './types';
4
+ import { PicletType, AttackType } from './types';
5
+
6
+ describe('Switching System', () => {
7
+ let basicPiclet: PicletDefinition;
8
+ let reservePiclet: PicletDefinition;
9
+ let hazardSetter: PicletDefinition;
10
+ let switchTriggerPiclet: PicletDefinition;
11
+
12
+ beforeEach(() => {
13
+ // Basic piclet for primary battles
14
+ basicPiclet = {
15
+ name: "Basic Fighter",
16
+ description: "Standard test piclet",
17
+ tier: 'medium',
18
+ primaryType: PicletType.BEAST,
19
+ baseStats: { hp: 80, attack: 60, defense: 60, speed: 60 },
20
+ nature: "Hardy",
21
+ specialAbility: { name: "No Ability", description: "" },
22
+ movepool: [
23
+ {
24
+ name: "Basic Attack",
25
+ type: AttackType.BEAST,
26
+ power: 50,
27
+ accuracy: 100,
28
+ pp: 20,
29
+ priority: 0,
30
+ flags: ['contact'],
31
+ effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
32
+ }
33
+ ]
34
+ };
35
+
36
+ // Reserve piclet for switching
37
+ reservePiclet = {
38
+ name: "Reserve Fighter",
39
+ description: "Backup piclet",
40
+ tier: 'medium',
41
+ primaryType: PicletType.FLORA,
42
+ baseStats: { hp: 90, attack: 50, defense: 70, speed: 40 },
43
+ nature: "Calm",
44
+ specialAbility: { name: "No Ability", description: "" },
45
+ movepool: [
46
+ {
47
+ name: "Leaf Strike",
48
+ type: AttackType.FLORA,
49
+ power: 45,
50
+ accuracy: 100,
51
+ pp: 25,
52
+ priority: 0,
53
+ flags: [],
54
+ effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
55
+ }
56
+ ]
57
+ };
58
+
59
+ // Piclet that can set entry hazards
60
+ hazardSetter = {
61
+ name: "Hazard Master",
62
+ description: "Sets entry hazards",
63
+ tier: 'medium',
64
+ primaryType: PicletType.MINERAL,
65
+ baseStats: { hp: 70, attack: 40, defense: 80, speed: 50 },
66
+ nature: "Bold",
67
+ specialAbility: { name: "No Ability", description: "" },
68
+ movepool: [
69
+ {
70
+ name: "Spike Trap",
71
+ type: AttackType.MINERAL,
72
+ power: 0,
73
+ accuracy: 100,
74
+ pp: 20,
75
+ priority: 0,
76
+ flags: [],
77
+ effects: [
78
+ {
79
+ type: 'fieldEffect',
80
+ effect: 'spikes',
81
+ target: 'opponentSide',
82
+ stackable: true
83
+ }
84
+ ]
85
+ },
86
+ {
87
+ name: "Toxic Spikes",
88
+ type: AttackType.MINERAL,
89
+ power: 0,
90
+ accuracy: 100,
91
+ pp: 20,
92
+ priority: 0,
93
+ flags: [],
94
+ effects: [
95
+ {
96
+ type: 'fieldEffect',
97
+ effect: 'toxicSpikes',
98
+ target: 'opponentSide',
99
+ stackable: true
100
+ }
101
+ ]
102
+ }
103
+ ]
104
+ };
105
+
106
+ // Piclet with switch-triggered abilities
107
+ switchTriggerPiclet = {
108
+ name: "Switch Specialist",
109
+ description: "Has switch-in/out abilities",
110
+ tier: 'medium',
111
+ primaryType: PicletType.CULTURE,
112
+ baseStats: { hp: 85, attack: 55, defense: 65, speed: 75 },
113
+ nature: "Timid",
114
+ specialAbility: {
115
+ name: "Intimidate",
116
+ description: "Lowers opponent's attack on switch-in",
117
+ triggers: [
118
+ {
119
+ event: 'onSwitchIn',
120
+ condition: 'always',
121
+ effects: [
122
+ {
123
+ type: 'modifyStats',
124
+ target: 'opponent',
125
+ stats: {
126
+ attack: 'decrease'
127
+ }
128
+ }
129
+ ]
130
+ }
131
+ ]
132
+ },
133
+ movepool: [
134
+ {
135
+ name: "Quick Strike",
136
+ type: AttackType.CULTURE,
137
+ power: 40,
138
+ accuracy: 100,
139
+ pp: 30,
140
+ priority: 1,
141
+ flags: [],
142
+ effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
143
+ }
144
+ ]
145
+ };
146
+ });
147
+
148
+ describe('Basic Switch Actions', () => {
149
+ it('should allow switching to a different piclet', () => {
150
+ // Create engine with rosters
151
+ const engine = new BattleEngine([basicPiclet, reservePiclet], [basicPiclet]);
152
+
153
+ const initialPlayerName = engine.getState().playerPiclet.definition.name;
154
+ expect(initialPlayerName).toBe("Basic Fighter");
155
+
156
+ // Execute switch action
157
+ engine.executeActions(
158
+ { type: 'switch', piclet: 'player', newPicletIndex: 1 },
159
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
160
+ );
161
+
162
+ const finalPlayerName = engine.getState().playerPiclet.definition.name;
163
+ const log = engine.getLog();
164
+
165
+ expect(finalPlayerName).toBe("Reserve Fighter");
166
+ expect(log.some(msg => msg.includes('switched') && msg.includes('Reserve Fighter'))).toBe(true);
167
+ });
168
+
169
+ it('should handle switch action priority correctly', () => {
170
+ const engine = new BattleEngine([basicPiclet, reservePiclet], [basicPiclet]);
171
+
172
+ // Switch actions should have higher priority than moves
173
+ engine.executeActions(
174
+ { type: 'switch', piclet: 'player', newPicletIndex: 1 },
175
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
176
+ );
177
+
178
+ const log = engine.getLog();
179
+
180
+ // Switch should happen before the opponent's move
181
+ const switchIndex = log.findIndex(msg => msg.includes('switched'));
182
+ const moveIndex = log.findIndex(msg => msg.includes('used') && msg.includes('Basic Attack'));
183
+
184
+ expect(switchIndex).toBeLessThan(moveIndex);
185
+ });
186
+
187
+ it('should not allow switching to same piclet', () => {
188
+ const engine = new BattleEngine([basicPiclet, reservePiclet], [basicPiclet]);
189
+
190
+ // Try to switch to the same piclet (index 0)
191
+ engine.executeActions(
192
+ { type: 'switch', piclet: 'player', newPicletIndex: 0 },
193
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
194
+ );
195
+
196
+ const log = engine.getLog();
197
+ expect(log.some(msg => msg.includes('already active') || msg.includes('cannot switch'))).toBe(true);
198
+ });
199
+
200
+ it('should not allow switching to fainted piclet', () => {
201
+ const faintedPiclet = { ...reservePiclet };
202
+ const engine = new BattleEngine([basicPiclet, faintedPiclet], [basicPiclet]);
203
+
204
+ // Mock fainted piclet by accessing private roster states
205
+ (engine as any).playerRosterStates[1].fainted = true;
206
+ (engine as any).playerRosterStates[1].currentHp = 0;
207
+
208
+ engine.executeActions(
209
+ { type: 'switch', piclet: 'player', newPicletIndex: 1 },
210
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
211
+ );
212
+
213
+ const log = engine.getLog();
214
+ expect(log.some(msg => msg.includes('fainted') || msg.includes('unable to battle'))).toBe(true);
215
+ });
216
+ });
217
+
218
+ describe('Entry Hazards', () => {
219
+ it('should apply spikes damage on switch-in', () => {
220
+ const engine = new BattleEngine([hazardSetter, basicPiclet], [basicPiclet, reservePiclet]);
221
+
222
+ // Set up spikes
223
+ engine.executeActions(
224
+ { type: 'move', piclet: 'player', moveIndex: 0 }, // Spike Trap
225
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
226
+ );
227
+
228
+ const log = engine.getLog();
229
+ expect(log.some(msg => msg.includes('spikes') || msg.includes('hazard'))).toBe(true);
230
+
231
+ // Switch opponent to trigger spikes
232
+ const initialHp = engine.getState().opponentPiclet.currentHp;
233
+
234
+ engine.executeActions(
235
+ { type: 'move', piclet: 'player', moveIndex: 0 },
236
+ { type: 'switch', piclet: 'opponent', newPicletIndex: 1 }
237
+ );
238
+
239
+ const finalLog = engine.getLog();
240
+ expect(finalLog.some(msg => msg.includes('hurt by spikes') || msg.includes('stepped on spikes'))).toBe(true);
241
+ });
242
+
243
+ it('should apply toxic spikes status on switch-in', () => {
244
+ const engine = new BattleEngine([hazardSetter], [basicPiclet, reservePiclet]);
245
+
246
+ // Set up toxic spikes
247
+ engine.executeActions(
248
+ { type: 'move', piclet: 'player', moveIndex: 1 }, // Toxic Spikes
249
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
250
+ );
251
+
252
+ // Switch opponent to trigger toxic spikes
253
+ engine.executeActions(
254
+ { type: 'move', piclet: 'player', moveIndex: 0 },
255
+ { type: 'switch', piclet: 'opponent', newPicletIndex: 1 }
256
+ );
257
+
258
+ const finalState = engine.getState().opponentPiclet;
259
+ const log = engine.getLog();
260
+
261
+ expect(finalState.statusEffects).toContain('poison');
262
+ expect(log.some(msg => msg.includes('poisoned by toxic spikes'))).toBe(true);
263
+ });
264
+
265
+ it('should stack multiple layers of spikes', () => {
266
+ const engine = new BattleEngine([hazardSetter], [basicPiclet, reservePiclet]);
267
+
268
+ // Set up multiple spike layers
269
+ engine.executeActions(
270
+ { type: 'move', piclet: 'player', moveIndex: 0 }, // First Spike Trap
271
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
272
+ );
273
+
274
+ engine.executeActions(
275
+ { type: 'move', piclet: 'player', moveIndex: 0 }, // Second Spike Trap
276
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
277
+ );
278
+
279
+ const fieldEffects = engine.getState().fieldEffects;
280
+ const spikeCount = fieldEffects.filter(effect => effect.name === 'entryHazardSpikes').length;
281
+
282
+ expect(spikeCount).toBeGreaterThan(1);
283
+ });
284
+ });
285
+
286
+ describe('Switch-In/Out Ability Triggers', () => {
287
+ it('should trigger onSwitchIn ability when piclet enters battle', () => {
288
+ const engine = new BattleEngine([basicPiclet, switchTriggerPiclet], [basicPiclet]);
289
+
290
+ const initialOpponentAttack = engine.getState().opponentPiclet.attack;
291
+
292
+ // Switch in the intimidate piclet
293
+ engine.executeActions(
294
+ { type: 'switch', piclet: 'player', newPicletIndex: 1 },
295
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
296
+ );
297
+
298
+ const finalOpponentAttack = engine.getState().opponentPiclet.attack;
299
+ const log = engine.getLog();
300
+
301
+ expect(finalOpponentAttack).toBeLessThan(initialOpponentAttack);
302
+ expect(log.some(msg => msg.includes('Intimidate') && msg.includes('triggered'))).toBe(true);
303
+ });
304
+
305
+ it('should trigger onSwitchOut ability when piclet leaves battle', () => {
306
+ const switchOutPiclet: PicletDefinition = {
307
+ ...switchTriggerPiclet,
308
+ specialAbility: {
309
+ name: "Parting Shot",
310
+ description: "Lowers opponent's stats on switch-out",
311
+ triggers: [
312
+ {
313
+ event: 'onSwitchOut',
314
+ condition: 'always',
315
+ effects: [
316
+ {
317
+ type: 'modifyStats',
318
+ target: 'opponent',
319
+ stats: {
320
+ attack: 'decrease',
321
+ defense: 'decrease'
322
+ }
323
+ }
324
+ ]
325
+ }
326
+ ]
327
+ }
328
+ };
329
+
330
+ const engine = new BattleEngine([switchOutPiclet, reservePiclet], [basicPiclet]);
331
+
332
+ const initialOpponentAttack = engine.getState().opponentPiclet.attack;
333
+ const initialOpponentDefense = engine.getState().opponentPiclet.defense;
334
+
335
+ // Switch out the parting shot piclet
336
+ engine.executeActions(
337
+ { type: 'switch', piclet: 'player', newPicletIndex: 1 },
338
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
339
+ );
340
+
341
+ const finalOpponentAttack = engine.getState().opponentPiclet.attack;
342
+ const finalOpponentDefense = engine.getState().opponentPiclet.defense;
343
+ const log = engine.getLog();
344
+
345
+ expect(finalOpponentAttack).toBeLessThan(initialOpponentAttack);
346
+ expect(finalOpponentDefense).toBeLessThan(initialOpponentDefense);
347
+ expect(log.some(msg => msg.includes('Parting Shot') && msg.includes('triggered'))).toBe(true);
348
+ });
349
+ });
350
+
351
+ describe('Forced Switching', () => {
352
+ it('should handle forced switch when active piclet faints', () => {
353
+ const engine = new BattleEngine([basicPiclet, reservePiclet], [basicPiclet]);
354
+
355
+ // Damage player piclet to near-faint
356
+ engine['state'].playerPiclet.currentHp = 1;
357
+
358
+ engine.executeActions(
359
+ { type: 'move', piclet: 'player', moveIndex: 0 },
360
+ { type: 'move', piclet: 'opponent', moveIndex: 0 } // Should KO player
361
+ );
362
+
363
+ const log = engine.getLog();
364
+
365
+ // Should prompt for forced switch or auto-switch if only one option
366
+ expect(log.some(msg =>
367
+ msg.includes('fainted') ||
368
+ msg.includes('must choose') ||
369
+ msg.includes('forced switch')
370
+ )).toBe(true);
371
+ });
372
+
373
+ it('should end battle if no valid switches remain', () => {
374
+ const engine = new BattleEngine([basicPiclet], [basicPiclet]); // Only one piclet each
375
+
376
+ // KO the only piclet
377
+ engine['state'].playerPiclet.currentHp = 1;
378
+
379
+ engine.executeActions(
380
+ { type: 'move', piclet: 'player', moveIndex: 0 },
381
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
382
+ );
383
+
384
+ expect(engine.isGameOver()).toBe(true);
385
+ expect(engine.getState().winner).toBe('opponent');
386
+ });
387
+ });
388
+
389
+ describe('Switch Action Integration', () => {
390
+ it('should preserve PP and status when switching back', () => {
391
+ const engine = new BattleEngine([basicPiclet, reservePiclet], [basicPiclet]);
392
+
393
+ // Use a move to reduce PP
394
+ engine.executeActions(
395
+ { type: 'move', piclet: 'player', moveIndex: 0 },
396
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
397
+ );
398
+
399
+ const ppAfterMove = engine.getState().playerPiclet.moves[0].currentPP;
400
+
401
+ // Switch out and back
402
+ engine.executeActions(
403
+ { type: 'switch', piclet: 'player', newPicletIndex: 1 },
404
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
405
+ );
406
+
407
+ engine.executeActions(
408
+ { type: 'switch', piclet: 'player', newPicletIndex: 0 },
409
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
410
+ );
411
+
412
+ const ppAfterReturn = engine.getState().playerPiclet.moves[0].currentPP;
413
+
414
+ // PP should be preserved
415
+ expect(ppAfterReturn).toBe(ppAfterMove);
416
+ });
417
+
418
+ it('should reset stat modifications when switching', () => {
419
+ const engine = new BattleEngine([basicPiclet, reservePiclet], [basicPiclet]);
420
+
421
+ // Apply stat modification
422
+ engine['state'].playerPiclet.attack += 20; // Simulate boost
423
+ engine['state'].playerPiclet.statModifiers.attack = 1;
424
+
425
+ const boostedAttack = engine.getState().playerPiclet.attack;
426
+
427
+ // Switch out and back
428
+ engine.executeActions(
429
+ { type: 'switch', piclet: 'player', newPicletIndex: 1 },
430
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
431
+ );
432
+
433
+ engine.executeActions(
434
+ { type: 'switch', piclet: 'player', newPicletIndex: 0 },
435
+ { type: 'move', piclet: 'opponent', moveIndex: 0 }
436
+ );
437
+
438
+ const finalAttack = engine.getState().playerPiclet.attack;
439
+
440
+ // Attack should be reset to base value
441
+ expect(finalAttack).toBeLessThan(boostedAttack);
442
+ expect(engine.getState().playerPiclet.statModifiers.attack).toBeFalsy();
443
+ });
444
+ });
445
+ });
src/lib/battle-engine/types.ts CHANGED
@@ -157,7 +157,8 @@ export interface Trigger {
157
  event: 'onDamageTaken' | 'onDamageDealt' | 'onContactDamage' | 'onStatusInflicted'
158
  | 'onStatusMove' | 'onStatusMoveTargeted' | 'onCriticalHit' | 'onHPDrained'
159
  | 'onKO' | 'onSwitchIn' | 'onSwitchOut' | 'onWeatherChange' | 'beforeMoveUse'
160
- | 'afterMoveUse' | 'onLowHP' | 'onFullHP' | 'endOfTurn' | 'onOpponentContactMove';
 
161
  condition?: EffectCondition;
162
  effects: BattleEffect[];
163
  }
 
157
  event: 'onDamageTaken' | 'onDamageDealt' | 'onContactDamage' | 'onStatusInflicted'
158
  | 'onStatusMove' | 'onStatusMoveTargeted' | 'onCriticalHit' | 'onHPDrained'
159
  | 'onKO' | 'onSwitchIn' | 'onSwitchOut' | 'onWeatherChange' | 'beforeMoveUse'
160
+ | 'afterMoveUse' | 'onLowHP' | 'onFullHP' | 'endOfTurn' | 'onOpponentContactMove'
161
+ | 'onStatChange' | 'onTypeChange';
162
  condition?: EffectCondition;
163
  effects: BattleEffect[];
164
  }