pre ide
Browse files
src/lib/battle-engine/BattleEngine.ts
CHANGED
@@ -310,6 +310,19 @@ export class BattleEngine {
|
|
310 |
private processDamageEffect(effect: { amount?: DamageAmount; formula?: string; value?: number; multiplier?: number }, attacker: BattlePiclet, target: BattlePiclet, move: Move): void {
|
311 |
let damage = 0;
|
312 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
313 |
// Handle different damage formulas
|
314 |
if (effect.formula) {
|
315 |
damage = this.calculateDamageByFormula(effect, attacker, target, move);
|
@@ -317,6 +330,26 @@ export class BattleEngine {
|
|
317 |
damage = this.calculateStandardDamage(effect.amount, attacker, target, move);
|
318 |
}
|
319 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
320 |
// Apply damage
|
321 |
if (damage > 0) {
|
322 |
target.currentHp = Math.max(0, target.currentHp - damage);
|
@@ -420,6 +453,12 @@ export class BattleEngine {
|
|
420 |
}
|
421 |
}
|
422 |
|
|
|
|
|
|
|
|
|
|
|
|
|
423 |
if (!target.statusEffects.includes(effect.status)) {
|
424 |
target.statusEffects.push(effect.status);
|
425 |
this.log(`${target.definition.name} was ${effect.status}ed!`);
|
@@ -590,8 +629,86 @@ export class BattleEngine {
|
|
590 |
}
|
591 |
|
592 |
private processMechanicOverrideEffect(effect: { mechanic: string; value: any; condition?: string }, target: BattlePiclet): void {
|
593 |
-
// Store mechanic override for processing
|
594 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
595 |
this.log(`Mechanic override '${effect.mechanic}' applied to ${target.definition.name}!`);
|
596 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
597 |
}
|
|
|
310 |
private processDamageEffect(effect: { amount?: DamageAmount; formula?: string; value?: number; multiplier?: number }, attacker: BattlePiclet, target: BattlePiclet, move: Move): void {
|
311 |
let damage = 0;
|
312 |
|
313 |
+
// Check type immunity first
|
314 |
+
if (this.checkTypeImmunity(target, move.type)) {
|
315 |
+
this.log(`${target.definition.name} is immune to ${move.type} type moves!`);
|
316 |
+
return;
|
317 |
+
}
|
318 |
+
|
319 |
+
// Check flag interactions
|
320 |
+
const flagInteraction = this.checkFlagInteraction(target, move.flags);
|
321 |
+
if (flagInteraction === 'immune') {
|
322 |
+
this.log(`${target.definition.name} is immune to this move!`);
|
323 |
+
return;
|
324 |
+
}
|
325 |
+
|
326 |
// Handle different damage formulas
|
327 |
if (effect.formula) {
|
328 |
damage = this.calculateDamageByFormula(effect, attacker, target, move);
|
|
|
330 |
damage = this.calculateStandardDamage(effect.amount, attacker, target, move);
|
331 |
}
|
332 |
|
333 |
+
// Apply flag interaction modifiers
|
334 |
+
if (flagInteraction === 'weak') {
|
335 |
+
damage = Math.floor(damage * 1.5);
|
336 |
+
this.log("It's super effective!");
|
337 |
+
} else if (flagInteraction === 'resist') {
|
338 |
+
damage = Math.floor(damage * 0.5);
|
339 |
+
this.log("It's not very effective...");
|
340 |
+
}
|
341 |
+
|
342 |
+
// Apply damage multiplier from abilities
|
343 |
+
const damageMultiplier = this.getDamageMultiplier(attacker);
|
344 |
+
damage = Math.floor(damage * damageMultiplier);
|
345 |
+
|
346 |
+
// Check for critical hits
|
347 |
+
const critMod = this.checkCriticalHitModification(attacker, target);
|
348 |
+
if (critMod === 'always' || (critMod === 'normal' && Math.random() < 0.0625)) { // 1/16 base crit rate
|
349 |
+
damage = Math.floor(damage * 1.5);
|
350 |
+
this.log("A critical hit!");
|
351 |
+
}
|
352 |
+
|
353 |
// Apply damage
|
354 |
if (damage > 0) {
|
355 |
target.currentHp = Math.max(0, target.currentHp - damage);
|
|
|
453 |
}
|
454 |
}
|
455 |
|
456 |
+
// Check for status immunity
|
457 |
+
if (this.checkStatusImmunity(target, effect.status)) {
|
458 |
+
this.log(`${target.definition.name} is immune to ${effect.status}!`);
|
459 |
+
return;
|
460 |
+
}
|
461 |
+
|
462 |
if (!target.statusEffects.includes(effect.status)) {
|
463 |
target.statusEffects.push(effect.status);
|
464 |
this.log(`${target.definition.name} was ${effect.status}ed!`);
|
|
|
629 |
}
|
630 |
|
631 |
private processMechanicOverrideEffect(effect: { mechanic: string; value: any; condition?: string }, target: BattlePiclet): void {
|
632 |
+
// Store mechanic override as temporary effect for processing during relevant calculations
|
633 |
+
target.temporaryEffects.push({
|
634 |
+
effect: {
|
635 |
+
type: 'mechanicOverride',
|
636 |
+
mechanic: effect.mechanic,
|
637 |
+
value: effect.value,
|
638 |
+
condition: effect.condition,
|
639 |
+
target: 'self'
|
640 |
+
} as any,
|
641 |
+
duration: effect.condition === 'restOfBattle' ? 999 : 1
|
642 |
+
});
|
643 |
+
|
644 |
this.log(`Mechanic override '${effect.mechanic}' applied to ${target.definition.name}!`);
|
645 |
}
|
646 |
+
|
647 |
+
// Helper methods for checking mechanic overrides
|
648 |
+
private hasMechanicOverride(piclet: BattlePiclet, mechanic: string): any {
|
649 |
+
const override = piclet.temporaryEffects.find(
|
650 |
+
effect => effect.effect.type === 'mechanicOverride' &&
|
651 |
+
(effect.effect as any).mechanic === mechanic
|
652 |
+
);
|
653 |
+
return override ? (override.effect as any).value : null;
|
654 |
+
}
|
655 |
+
|
656 |
+
private checkCriticalHitModification(attacker: BattlePiclet, target: BattlePiclet): 'always' | 'never' | 'normal' {
|
657 |
+
// Check attacker's critical hit modifiers
|
658 |
+
const attackerOverride = this.hasMechanicOverride(attacker, 'criticalHits');
|
659 |
+
if (attackerOverride === true) return 'always';
|
660 |
+
|
661 |
+
// Check target's critical hit immunity
|
662 |
+
const targetOverride = this.hasMechanicOverride(target, 'criticalHits');
|
663 |
+
if (targetOverride === false) return 'never';
|
664 |
+
|
665 |
+
return 'normal';
|
666 |
+
}
|
667 |
+
|
668 |
+
private checkStatusImmunity(target: BattlePiclet, status: string): boolean {
|
669 |
+
const immunity = this.hasMechanicOverride(target, 'statusImmunity');
|
670 |
+
if (Array.isArray(immunity)) {
|
671 |
+
return immunity.includes(status);
|
672 |
+
}
|
673 |
+
return false;
|
674 |
+
}
|
675 |
+
|
676 |
+
private checkTypeImmunity(target: BattlePiclet, attackType: string): boolean {
|
677 |
+
const immunity = this.hasMechanicOverride(target, 'typeImmunity');
|
678 |
+
if (Array.isArray(immunity)) {
|
679 |
+
return immunity.includes(attackType);
|
680 |
+
}
|
681 |
+
return false;
|
682 |
+
}
|
683 |
+
|
684 |
+
private checkFlagInteraction(target: BattlePiclet, flags: string[]): 'immune' | 'weak' | 'resist' | 'normal' {
|
685 |
+
// Check immunities first
|
686 |
+
const immunity = this.hasMechanicOverride(target, 'flagImmunity');
|
687 |
+
if (Array.isArray(immunity) && flags.some(flag => immunity.includes(flag))) {
|
688 |
+
return 'immune';
|
689 |
+
}
|
690 |
+
|
691 |
+
// Check weaknesses
|
692 |
+
const weakness = this.hasMechanicOverride(target, 'flagWeakness');
|
693 |
+
if (Array.isArray(weakness) && flags.some(flag => weakness.includes(flag))) {
|
694 |
+
return 'weak';
|
695 |
+
}
|
696 |
+
|
697 |
+
// Check resistances
|
698 |
+
const resistance = this.hasMechanicOverride(target, 'flagResistance');
|
699 |
+
if (Array.isArray(resistance) && flags.some(flag => resistance.includes(flag))) {
|
700 |
+
return 'resist';
|
701 |
+
}
|
702 |
+
|
703 |
+
return 'normal';
|
704 |
+
}
|
705 |
+
|
706 |
+
private getDamageMultiplier(piclet: BattlePiclet): number {
|
707 |
+
const multiplier = this.hasMechanicOverride(piclet, 'damageMultiplier');
|
708 |
+
return typeof multiplier === 'number' ? multiplier : 1.0;
|
709 |
+
}
|
710 |
+
|
711 |
+
private shouldInvertHealing(target: BattlePiclet): boolean {
|
712 |
+
return !!this.hasMechanicOverride(target, 'healingInversion');
|
713 |
+
}
|
714 |
}
|
src/lib/battle-engine/advanced-mechanic-overrides.test.ts
ADDED
@@ -0,0 +1,348 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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('Advanced Mechanic Override System', () => {
|
7 |
+
describe('Critical Hit Mechanics', () => {
|
8 |
+
it('should prevent critical hits with Shell Armor ability', () => {
|
9 |
+
const shellArmor: SpecialAbility = {
|
10 |
+
name: "Shell Armor",
|
11 |
+
description: "Hard shell prevents critical hits",
|
12 |
+
effects: [
|
13 |
+
{
|
14 |
+
type: 'mechanicOverride',
|
15 |
+
mechanic: 'criticalHits',
|
16 |
+
condition: 'always',
|
17 |
+
value: false
|
18 |
+
}
|
19 |
+
]
|
20 |
+
};
|
21 |
+
|
22 |
+
const defender: PicletDefinition = {
|
23 |
+
name: "Shell Defender",
|
24 |
+
description: "Protected by hard shell",
|
25 |
+
tier: 'medium',
|
26 |
+
primaryType: PicletType.MINERAL,
|
27 |
+
baseStats: { hp: 80, attack: 60, defense: 80, speed: 40 },
|
28 |
+
nature: "Impish",
|
29 |
+
specialAbility: shellArmor,
|
30 |
+
movepool: [
|
31 |
+
{
|
32 |
+
name: "Defense Curl",
|
33 |
+
type: AttackType.NORMAL,
|
34 |
+
power: 0,
|
35 |
+
accuracy: 100,
|
36 |
+
pp: 10,
|
37 |
+
priority: 0,
|
38 |
+
flags: [],
|
39 |
+
effects: [
|
40 |
+
{
|
41 |
+
type: 'modifyStats',
|
42 |
+
target: 'self',
|
43 |
+
stats: { defense: 'increase' }
|
44 |
+
}
|
45 |
+
]
|
46 |
+
}
|
47 |
+
]
|
48 |
+
};
|
49 |
+
|
50 |
+
const attacker: PicletDefinition = {
|
51 |
+
name: "High Crit Attacker",
|
52 |
+
description: "Has high critical hit rate",
|
53 |
+
tier: 'medium',
|
54 |
+
primaryType: PicletType.BEAST,
|
55 |
+
baseStats: { hp: 70, attack: 90, defense: 50, speed: 80 },
|
56 |
+
nature: "Adamant",
|
57 |
+
specialAbility: { name: "No Ability", description: "" },
|
58 |
+
movepool: [
|
59 |
+
{
|
60 |
+
name: "Slash",
|
61 |
+
type: AttackType.BEAST,
|
62 |
+
power: 70,
|
63 |
+
accuracy: 100,
|
64 |
+
pp: 10,
|
65 |
+
priority: 0,
|
66 |
+
flags: ['contact'],
|
67 |
+
effects: [
|
68 |
+
{
|
69 |
+
type: 'damage',
|
70 |
+
target: 'opponent',
|
71 |
+
amount: 'normal'
|
72 |
+
}
|
73 |
+
]
|
74 |
+
}
|
75 |
+
]
|
76 |
+
};
|
77 |
+
|
78 |
+
const engine = new BattleEngine(defender, attacker);
|
79 |
+
|
80 |
+
// Force high crit rate for testing
|
81 |
+
const originalCritRate = (engine as any).calculateCriticalChance;
|
82 |
+
(engine as any).calculateCriticalChance = () => 1.0; // 100% crit rate normally
|
83 |
+
|
84 |
+
let criticalHitOccurred = false;
|
85 |
+
for (let i = 0; i < 10; i++) {
|
86 |
+
const initialHp = engine.getState().playerPiclet.currentHp;
|
87 |
+
|
88 |
+
engine.executeActions(
|
89 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
90 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 }
|
91 |
+
);
|
92 |
+
|
93 |
+
const log = engine.getLog();
|
94 |
+
if (log.some(msg => msg.includes('critical') || msg.includes('Critical'))) {
|
95 |
+
criticalHitOccurred = true;
|
96 |
+
break;
|
97 |
+
}
|
98 |
+
|
99 |
+
if (engine.isGameOver()) break;
|
100 |
+
}
|
101 |
+
|
102 |
+
// Shell Armor should prevent ALL critical hits
|
103 |
+
expect(criticalHitOccurred).toBe(false);
|
104 |
+
|
105 |
+
// Restore original function
|
106 |
+
(engine as any).calculateCriticalChance = originalCritRate;
|
107 |
+
});
|
108 |
+
|
109 |
+
it('should guarantee critical hits with certain abilities', () => {
|
110 |
+
const alwaysCrit: SpecialAbility = {
|
111 |
+
name: "Super Luck",
|
112 |
+
description: "Always lands critical hits",
|
113 |
+
effects: [
|
114 |
+
{
|
115 |
+
type: 'mechanicOverride',
|
116 |
+
mechanic: 'criticalHits',
|
117 |
+
condition: 'always',
|
118 |
+
value: true
|
119 |
+
}
|
120 |
+
]
|
121 |
+
};
|
122 |
+
|
123 |
+
const critUser: PicletDefinition = {
|
124 |
+
name: "Lucky Fighter",
|
125 |
+
description: "Always gets critical hits",
|
126 |
+
tier: 'medium',
|
127 |
+
primaryType: PicletType.BEAST,
|
128 |
+
baseStats: { hp: 70, attack: 80, defense: 60, speed: 90 },
|
129 |
+
nature: "Hasty",
|
130 |
+
specialAbility: alwaysCrit,
|
131 |
+
movepool: [
|
132 |
+
{
|
133 |
+
name: "Strike",
|
134 |
+
type: AttackType.BEAST,
|
135 |
+
power: 60,
|
136 |
+
accuracy: 100,
|
137 |
+
pp: 10,
|
138 |
+
priority: 0,
|
139 |
+
flags: ['contact'],
|
140 |
+
effects: [
|
141 |
+
{
|
142 |
+
type: 'damage',
|
143 |
+
target: 'opponent',
|
144 |
+
amount: 'normal'
|
145 |
+
}
|
146 |
+
]
|
147 |
+
}
|
148 |
+
]
|
149 |
+
};
|
150 |
+
|
151 |
+
const opponent: PicletDefinition = {
|
152 |
+
name: "Opponent",
|
153 |
+
description: "Standard opponent",
|
154 |
+
tier: 'medium',
|
155 |
+
primaryType: PicletType.BEAST,
|
156 |
+
baseStats: { hp: 80, attack: 60, defense: 60, speed: 70 },
|
157 |
+
nature: "Hardy",
|
158 |
+
specialAbility: { name: "No Ability", description: "" },
|
159 |
+
movepool: [
|
160 |
+
{
|
161 |
+
name: "Tackle",
|
162 |
+
type: AttackType.NORMAL,
|
163 |
+
power: 40,
|
164 |
+
accuracy: 100,
|
165 |
+
pp: 10,
|
166 |
+
priority: 0,
|
167 |
+
flags: ['contact'],
|
168 |
+
effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
|
169 |
+
}
|
170 |
+
]
|
171 |
+
};
|
172 |
+
|
173 |
+
const engine = new BattleEngine(critUser, opponent);
|
174 |
+
|
175 |
+
engine.executeActions(
|
176 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
177 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 }
|
178 |
+
);
|
179 |
+
|
180 |
+
const log = engine.getLog();
|
181 |
+
expect(log.some(msg => msg.includes('critical') || msg.includes('Critical'))).toBe(true);
|
182 |
+
});
|
183 |
+
});
|
184 |
+
|
185 |
+
describe('Status Immunity', () => {
|
186 |
+
it('should provide immunity to specific status effects', () => {
|
187 |
+
const insomnia: SpecialAbility = {
|
188 |
+
name: "Insomnia",
|
189 |
+
description: "Prevents sleep status",
|
190 |
+
effects: [
|
191 |
+
{
|
192 |
+
type: 'mechanicOverride',
|
193 |
+
mechanic: 'statusImmunity',
|
194 |
+
value: ['sleep']
|
195 |
+
}
|
196 |
+
]
|
197 |
+
};
|
198 |
+
|
199 |
+
const insomniac: PicletDefinition = {
|
200 |
+
name: "Sleepless Fighter",
|
201 |
+
description: "Cannot be put to sleep",
|
202 |
+
tier: 'medium',
|
203 |
+
primaryType: PicletType.CULTURE,
|
204 |
+
baseStats: { hp: 70, attack: 60, defense: 60, speed: 80 },
|
205 |
+
nature: "Timid",
|
206 |
+
specialAbility: insomnia,
|
207 |
+
movepool: [
|
208 |
+
{
|
209 |
+
name: "Tackle",
|
210 |
+
type: AttackType.NORMAL,
|
211 |
+
power: 40,
|
212 |
+
accuracy: 100,
|
213 |
+
pp: 10,
|
214 |
+
priority: 0,
|
215 |
+
flags: ['contact'],
|
216 |
+
effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
|
217 |
+
}
|
218 |
+
]
|
219 |
+
};
|
220 |
+
|
221 |
+
const sleepUser: PicletDefinition = {
|
222 |
+
name: "Sleep Inducer",
|
223 |
+
description: "Puts opponents to sleep",
|
224 |
+
tier: 'medium',
|
225 |
+
primaryType: PicletType.CULTURE,
|
226 |
+
baseStats: { hp: 80, attack: 50, defense: 70, speed: 60 },
|
227 |
+
nature: "Calm",
|
228 |
+
specialAbility: { name: "No Ability", description: "" },
|
229 |
+
movepool: [
|
230 |
+
{
|
231 |
+
name: "Sleep Powder",
|
232 |
+
type: AttackType.FLORA,
|
233 |
+
power: 0,
|
234 |
+
accuracy: 75,
|
235 |
+
pp: 10,
|
236 |
+
priority: 0,
|
237 |
+
flags: [],
|
238 |
+
effects: [
|
239 |
+
{
|
240 |
+
type: 'applyStatus',
|
241 |
+
target: 'opponent',
|
242 |
+
status: 'sleep'
|
243 |
+
}
|
244 |
+
]
|
245 |
+
}
|
246 |
+
]
|
247 |
+
};
|
248 |
+
|
249 |
+
const engine = new BattleEngine(insomniac, sleepUser);
|
250 |
+
|
251 |
+
engine.executeActions(
|
252 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
253 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 }
|
254 |
+
);
|
255 |
+
|
256 |
+
const log = engine.getLog();
|
257 |
+
expect(log.some(msg => msg.includes('immune') || msg.includes('had no effect'))).toBe(true);
|
258 |
+
expect(log.some(msg => msg.includes('fell asleep'))).toBe(false);
|
259 |
+
});
|
260 |
+
});
|
261 |
+
|
262 |
+
describe('Type Immunity', () => {
|
263 |
+
it('should provide immunity to specific attack types', () => {
|
264 |
+
const levitate: SpecialAbility = {
|
265 |
+
name: "Levitate",
|
266 |
+
description: "Floating ability makes ground moves miss",
|
267 |
+
effects: [
|
268 |
+
{
|
269 |
+
type: 'mechanicOverride',
|
270 |
+
mechanic: 'typeImmunity',
|
271 |
+
value: ['ground']
|
272 |
+
}
|
273 |
+
]
|
274 |
+
};
|
275 |
+
|
276 |
+
const levitator: PicletDefinition = {
|
277 |
+
name: "Floating Fighter",
|
278 |
+
description: "Levitates above ground attacks",
|
279 |
+
tier: 'medium',
|
280 |
+
primaryType: PicletType.SPACE,
|
281 |
+
baseStats: { hp: 75, attack: 70, defense: 60, speed: 85 },
|
282 |
+
nature: "Timid",
|
283 |
+
specialAbility: levitate,
|
284 |
+
movepool: [
|
285 |
+
{
|
286 |
+
name: "Air Slash",
|
287 |
+
type: AttackType.SPACE,
|
288 |
+
power: 60,
|
289 |
+
accuracy: 95,
|
290 |
+
pp: 10,
|
291 |
+
priority: 0,
|
292 |
+
flags: [],
|
293 |
+
effects: [
|
294 |
+
{
|
295 |
+
type: 'damage',
|
296 |
+
target: 'opponent',
|
297 |
+
amount: 'normal'
|
298 |
+
}
|
299 |
+
]
|
300 |
+
}
|
301 |
+
]
|
302 |
+
};
|
303 |
+
|
304 |
+
const groundUser: PicletDefinition = {
|
305 |
+
name: "Ground Attacker",
|
306 |
+
description: "Uses ground-based attacks",
|
307 |
+
tier: 'medium',
|
308 |
+
primaryType: PicletType.MINERAL,
|
309 |
+
baseStats: { hp: 80, attack: 80, defense: 70, speed: 60 },
|
310 |
+
nature: "Adamant",
|
311 |
+
specialAbility: { name: "No Ability", description: "" },
|
312 |
+
movepool: [
|
313 |
+
{
|
314 |
+
name: "Earthquake",
|
315 |
+
type: AttackType.MINERAL,
|
316 |
+
power: 100,
|
317 |
+
accuracy: 100,
|
318 |
+
pp: 10,
|
319 |
+
priority: 0,
|
320 |
+
flags: ['ground'],
|
321 |
+
effects: [
|
322 |
+
{
|
323 |
+
type: 'damage',
|
324 |
+
target: 'opponent',
|
325 |
+
amount: 'strong'
|
326 |
+
}
|
327 |
+
]
|
328 |
+
}
|
329 |
+
]
|
330 |
+
};
|
331 |
+
|
332 |
+
const engine = new BattleEngine(levitator, groundUser);
|
333 |
+
const initialHp = engine.getState().playerPiclet.currentHp;
|
334 |
+
|
335 |
+
engine.executeActions(
|
336 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
337 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 }
|
338 |
+
);
|
339 |
+
|
340 |
+
const finalHp = engine.getState().playerPiclet.currentHp;
|
341 |
+
const log = engine.getLog();
|
342 |
+
|
343 |
+
// Ground move should have no effect due to Levitate
|
344 |
+
expect(finalHp).toBe(initialHp);
|
345 |
+
expect(log.some(msg => msg.includes('had no effect') || msg.includes('immune'))).toBe(true);
|
346 |
+
});
|
347 |
+
});
|
348 |
+
});
|
src/lib/battle-engine/extreme-risk-reward.test.ts
ADDED
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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('Extreme Risk-Reward Moves', () => {
|
7 |
+
describe('Self-Destruct Moves', () => {
|
8 |
+
it('should deal massive damage to all but KO the user', () => {
|
9 |
+
const bomber: PicletDefinition = {
|
10 |
+
name: "Suicide Bomber",
|
11 |
+
description: "Sacrifices itself for massive damage",
|
12 |
+
tier: 'medium',
|
13 |
+
primaryType: PicletType.MACHINA,
|
14 |
+
baseStats: { hp: 60, attack: 40, defense: 60, speed: 50 },
|
15 |
+
nature: "Brave",
|
16 |
+
specialAbility: { name: "No Ability", description: "" },
|
17 |
+
movepool: [
|
18 |
+
{
|
19 |
+
name: "Self Destruct",
|
20 |
+
type: AttackType.MACHINA,
|
21 |
+
power: 200,
|
22 |
+
accuracy: 100,
|
23 |
+
pp: 1,
|
24 |
+
priority: 0,
|
25 |
+
flags: ['explosive', 'contact'],
|
26 |
+
effects: [
|
27 |
+
{
|
28 |
+
type: 'damage',
|
29 |
+
target: 'all',
|
30 |
+
formula: 'standard',
|
31 |
+
multiplier: 1.5
|
32 |
+
},
|
33 |
+
{
|
34 |
+
type: 'damage',
|
35 |
+
target: 'self',
|
36 |
+
formula: 'fixed',
|
37 |
+
value: 9999,
|
38 |
+
condition: 'afterUse'
|
39 |
+
}
|
40 |
+
]
|
41 |
+
}
|
42 |
+
]
|
43 |
+
};
|
44 |
+
|
45 |
+
const opponent: PicletDefinition = {
|
46 |
+
name: "Sturdy Opponent",
|
47 |
+
description: "Tanky opponent",
|
48 |
+
tier: 'high',
|
49 |
+
primaryType: PicletType.MINERAL,
|
50 |
+
baseStats: { hp: 100, attack: 60, defense: 100, speed: 40 },
|
51 |
+
nature: "Impish",
|
52 |
+
specialAbility: { name: "No Ability", description: "" },
|
53 |
+
movepool: [
|
54 |
+
{
|
55 |
+
name: "Tackle",
|
56 |
+
type: AttackType.NORMAL,
|
57 |
+
power: 40,
|
58 |
+
accuracy: 100,
|
59 |
+
pp: 10,
|
60 |
+
priority: 0,
|
61 |
+
flags: ['contact'],
|
62 |
+
effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
|
63 |
+
}
|
64 |
+
]
|
65 |
+
};
|
66 |
+
|
67 |
+
const engine = new BattleEngine(bomber, opponent);
|
68 |
+
const initialOpponentHp = engine.getState().opponentPiclet.currentHp;
|
69 |
+
|
70 |
+
engine.executeActions(
|
71 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 }, // Self Destruct
|
72 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 }
|
73 |
+
);
|
74 |
+
|
75 |
+
const finalOpponentHp = engine.getState().opponentPiclet.currentHp;
|
76 |
+
const playerHp = engine.getState().playerPiclet.currentHp;
|
77 |
+
|
78 |
+
// User should be KO'd
|
79 |
+
expect(playerHp).toBe(0);
|
80 |
+
|
81 |
+
// Opponent should take massive damage
|
82 |
+
expect(finalOpponentHp).toBeLessThan(initialOpponentHp);
|
83 |
+
const damage = initialOpponentHp - finalOpponentHp;
|
84 |
+
expect(damage).toBeGreaterThan(80); // Should be very high damage
|
85 |
+
|
86 |
+
const log = engine.getLog();
|
87 |
+
expect(log.some(msg => msg.includes('Self Destruct') || msg.includes('exploded'))).toBe(true);
|
88 |
+
expect(engine.isGameOver()).toBe(true);
|
89 |
+
});
|
90 |
+
});
|
91 |
+
|
92 |
+
describe('Gambling Moves', () => {
|
93 |
+
it('should have random success/failure outcomes', () => {
|
94 |
+
const gambler: PicletDefinition = {
|
95 |
+
name: "Lucky Gambler",
|
96 |
+
description: "Relies on luck for power",
|
97 |
+
tier: 'medium',
|
98 |
+
primaryType: PicletType.CULTURE,
|
99 |
+
baseStats: { hp: 70, attack: 60, defense: 60, speed: 80 },
|
100 |
+
nature: "Hasty",
|
101 |
+
specialAbility: { name: "No Ability", description: "" },
|
102 |
+
movepool: [
|
103 |
+
{
|
104 |
+
name: "Cursed Gambit",
|
105 |
+
type: AttackType.CULTURE,
|
106 |
+
power: 0,
|
107 |
+
accuracy: 100,
|
108 |
+
pp: 1,
|
109 |
+
priority: 0,
|
110 |
+
flags: ['gambling', 'cursed'],
|
111 |
+
effects: [
|
112 |
+
{
|
113 |
+
type: 'heal',
|
114 |
+
target: 'self',
|
115 |
+
amount: 'percentage',
|
116 |
+
value: 100,
|
117 |
+
condition: 'ifLucky50'
|
118 |
+
},
|
119 |
+
{
|
120 |
+
type: 'damage',
|
121 |
+
target: 'self',
|
122 |
+
formula: 'fixed',
|
123 |
+
value: 9999,
|
124 |
+
condition: 'ifUnlucky50'
|
125 |
+
}
|
126 |
+
]
|
127 |
+
}
|
128 |
+
]
|
129 |
+
};
|
130 |
+
|
131 |
+
const opponent: PicletDefinition = {
|
132 |
+
name: "Opponent",
|
133 |
+
description: "Standard opponent",
|
134 |
+
tier: 'medium',
|
135 |
+
primaryType: PicletType.BEAST,
|
136 |
+
baseStats: { hp: 80, attack: 60, defense: 60, speed: 70 },
|
137 |
+
nature: "Hardy",
|
138 |
+
specialAbility: { name: "No Ability", description: "" },
|
139 |
+
movepool: [
|
140 |
+
{
|
141 |
+
name: "Tackle",
|
142 |
+
type: AttackType.NORMAL,
|
143 |
+
power: 40,
|
144 |
+
accuracy: 100,
|
145 |
+
pp: 10,
|
146 |
+
priority: 0,
|
147 |
+
flags: ['contact'],
|
148 |
+
effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
|
149 |
+
}
|
150 |
+
]
|
151 |
+
};
|
152 |
+
|
153 |
+
// Test multiple times to check for randomness
|
154 |
+
let healedCount = 0;
|
155 |
+
let faintedCount = 0;
|
156 |
+
|
157 |
+
for (let i = 0; i < 20; i++) {
|
158 |
+
const engine = new BattleEngine(gambler, opponent);
|
159 |
+
// Damage the gambler first
|
160 |
+
engine['state'].playerPiclet.currentHp = Math.floor(engine['state'].playerPiclet.maxHp * 0.5);
|
161 |
+
const preGambitHp = engine.getState().playerPiclet.currentHp;
|
162 |
+
|
163 |
+
engine.executeActions(
|
164 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
165 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 }
|
166 |
+
);
|
167 |
+
|
168 |
+
const postGambitHp = engine.getState().playerPiclet.currentHp;
|
169 |
+
|
170 |
+
if (postGambitHp === 0) {
|
171 |
+
faintedCount++;
|
172 |
+
} else if (postGambitHp > preGambitHp) {
|
173 |
+
healedCount++;
|
174 |
+
}
|
175 |
+
}
|
176 |
+
|
177 |
+
// Should have some of each outcome (allowing for randomness)
|
178 |
+
expect(healedCount + faintedCount).toBeGreaterThan(0);
|
179 |
+
expect(healedCount).toBeGreaterThan(0);
|
180 |
+
expect(faintedCount).toBeGreaterThan(0);
|
181 |
+
});
|
182 |
+
});
|
183 |
+
|
184 |
+
describe('Sacrifice Moves', () => {
|
185 |
+
it('should provide powerful effects at great personal cost', () => {
|
186 |
+
const sacrificer: PicletDefinition = {
|
187 |
+
name: "Blood Warrior",
|
188 |
+
description: "Sacrifices HP for power",
|
189 |
+
tier: 'medium',
|
190 |
+
primaryType: PicletType.BEAST,
|
191 |
+
baseStats: { hp: 100, attack: 70, defense: 60, speed: 60 },
|
192 |
+
nature: "Brave",
|
193 |
+
specialAbility: { name: "No Ability", description: "" },
|
194 |
+
movepool: [
|
195 |
+
{
|
196 |
+
name: "Blood Pact",
|
197 |
+
type: AttackType.BEAST,
|
198 |
+
power: 0,
|
199 |
+
accuracy: 100,
|
200 |
+
pp: 3,
|
201 |
+
priority: 0,
|
202 |
+
flags: ['sacrifice'],
|
203 |
+
effects: [
|
204 |
+
{
|
205 |
+
type: 'damage',
|
206 |
+
target: 'self',
|
207 |
+
formula: 'percentage',
|
208 |
+
value: 50
|
209 |
+
},
|
210 |
+
{
|
211 |
+
type: 'mechanicOverride',
|
212 |
+
target: 'self',
|
213 |
+
mechanic: 'damageMultiplier',
|
214 |
+
value: 2.0,
|
215 |
+
condition: 'restOfBattle'
|
216 |
+
}
|
217 |
+
]
|
218 |
+
},
|
219 |
+
{
|
220 |
+
name: "Strike",
|
221 |
+
type: AttackType.BEAST,
|
222 |
+
power: 60,
|
223 |
+
accuracy: 100,
|
224 |
+
pp: 10,
|
225 |
+
priority: 0,
|
226 |
+
flags: ['contact'],
|
227 |
+
effects: [
|
228 |
+
{
|
229 |
+
type: 'damage',
|
230 |
+
target: 'opponent',
|
231 |
+
amount: 'normal'
|
232 |
+
}
|
233 |
+
]
|
234 |
+
}
|
235 |
+
]
|
236 |
+
};
|
237 |
+
|
238 |
+
const opponent: PicletDefinition = {
|
239 |
+
name: "Opponent",
|
240 |
+
description: "Standard opponent",
|
241 |
+
tier: 'medium',
|
242 |
+
primaryType: PicletType.BEAST,
|
243 |
+
baseStats: { hp: 80, attack: 60, defense: 60, speed: 70 },
|
244 |
+
nature: "Hardy",
|
245 |
+
specialAbility: { name: "No Ability", description: "" },
|
246 |
+
movepool: [
|
247 |
+
{
|
248 |
+
name: "Tackle",
|
249 |
+
type: AttackType.NORMAL,
|
250 |
+
power: 40,
|
251 |
+
accuracy: 100,
|
252 |
+
pp: 10,
|
253 |
+
priority: 0,
|
254 |
+
flags: ['contact'],
|
255 |
+
effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
|
256 |
+
}
|
257 |
+
]
|
258 |
+
};
|
259 |
+
|
260 |
+
const engine = new BattleEngine(sacrificer, opponent);
|
261 |
+
const initialHp = engine.getState().playerPiclet.currentHp;
|
262 |
+
|
263 |
+
// Use Blood Pact
|
264 |
+
engine.executeActions(
|
265 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 }, // Blood Pact
|
266 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 }
|
267 |
+
);
|
268 |
+
|
269 |
+
const hpAfterSacrifice = engine.getState().playerPiclet.currentHp;
|
270 |
+
expect(hpAfterSacrifice).toBeLessThan(initialHp);
|
271 |
+
|
272 |
+
// Now attack should do double damage
|
273 |
+
const initialOpponentHp = engine.getState().opponentPiclet.currentHp;
|
274 |
+
engine.executeActions(
|
275 |
+
{ type: 'move', piclet: 'player', moveIndex: 1 }, // Strike (should be doubled)
|
276 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 }
|
277 |
+
);
|
278 |
+
|
279 |
+
const finalOpponentHp = engine.getState().opponentPiclet.currentHp;
|
280 |
+
const damage = initialOpponentHp - finalOpponentHp;
|
281 |
+
|
282 |
+
// Should do significantly more damage than normal (doubled)
|
283 |
+
expect(damage).toBeGreaterThan(60); // Normal would be ~30-40
|
284 |
+
|
285 |
+
const log = engine.getLog();
|
286 |
+
expect(log.some(msg => msg.includes('Blood Pact') || msg.includes('sacrifice'))).toBe(true);
|
287 |
+
});
|
288 |
+
});
|
289 |
+
|
290 |
+
describe('Conditional Power Scaling', () => {
|
291 |
+
it('should scale damage based on conditions', () => {
|
292 |
+
const revengeUser: PicletDefinition = {
|
293 |
+
name: "Revenge Fighter",
|
294 |
+
description: "Gets stronger when damaged",
|
295 |
+
tier: 'medium',
|
296 |
+
primaryType: PicletType.BEAST,
|
297 |
+
baseStats: { hp: 90, attack: 70, defense: 80, speed: 50 },
|
298 |
+
nature: "Brave",
|
299 |
+
specialAbility: { name: "No Ability", description: "" },
|
300 |
+
movepool: [
|
301 |
+
{
|
302 |
+
name: "Revenge Strike",
|
303 |
+
type: AttackType.BEAST,
|
304 |
+
power: 60,
|
305 |
+
accuracy: 100,
|
306 |
+
pp: 10,
|
307 |
+
priority: 0,
|
308 |
+
flags: ['contact'],
|
309 |
+
effects: [
|
310 |
+
{
|
311 |
+
type: 'damage',
|
312 |
+
target: 'opponent',
|
313 |
+
amount: 'normal'
|
314 |
+
},
|
315 |
+
{
|
316 |
+
type: 'damage',
|
317 |
+
target: 'opponent',
|
318 |
+
amount: 'strong',
|
319 |
+
condition: 'ifDamagedThisTurn'
|
320 |
+
}
|
321 |
+
]
|
322 |
+
}
|
323 |
+
]
|
324 |
+
};
|
325 |
+
|
326 |
+
const attacker: PicletDefinition = {
|
327 |
+
name: "Fast Attacker",
|
328 |
+
description: "Quick attacker",
|
329 |
+
tier: 'medium',
|
330 |
+
primaryType: PicletType.BEAST,
|
331 |
+
baseStats: { hp: 70, attack: 80, defense: 60, speed: 100 },
|
332 |
+
nature: "Hasty",
|
333 |
+
specialAbility: { name: "No Ability", description: "" },
|
334 |
+
movepool: [
|
335 |
+
{
|
336 |
+
name: "Quick Strike",
|
337 |
+
type: AttackType.BEAST,
|
338 |
+
power: 50,
|
339 |
+
accuracy: 100,
|
340 |
+
pp: 10,
|
341 |
+
priority: 1,
|
342 |
+
flags: ['contact', 'priority'],
|
343 |
+
effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
|
344 |
+
}
|
345 |
+
]
|
346 |
+
};
|
347 |
+
|
348 |
+
const engine = new BattleEngine(revengeUser, attacker);
|
349 |
+
|
350 |
+
// Test revenge without being damaged first
|
351 |
+
const initialOpponentHp = engine.getState().opponentPiclet.currentHp;
|
352 |
+
engine.executeActions(
|
353 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
354 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 }
|
355 |
+
);
|
356 |
+
|
357 |
+
const hpAfterNormalRevenge = engine.getState().opponentPiclet.currentHp;
|
358 |
+
const normalRevengeDamage = initialOpponentHp - hpAfterNormalRevenge;
|
359 |
+
|
360 |
+
// Test revenge when damaged this turn (opponent should go first due to priority)
|
361 |
+
const preRevengeHp = engine.getState().opponentPiclet.currentHp;
|
362 |
+
engine.executeActions(
|
363 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
364 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 }
|
365 |
+
);
|
366 |
+
|
367 |
+
const hpAfterPoweredRevenge = engine.getState().opponentPiclet.currentHp;
|
368 |
+
const poweredRevengeDamage = preRevengeHp - hpAfterPoweredRevenge;
|
369 |
+
|
370 |
+
// Powered revenge should do more damage
|
371 |
+
expect(poweredRevengeDamage).toBeGreaterThan(normalRevengeDamage);
|
372 |
+
expect(engine.getLog().some(msg => msg.includes('revenge') || msg.includes('retaliate'))).toBe(true);
|
373 |
+
});
|
374 |
+
});
|
375 |
+
});
|
src/lib/battle-engine/missing-features.test.ts
ADDED
@@ -0,0 +1,511 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { describe, it, expect, beforeEach } from 'vitest';
|
2 |
+
import { BattleEngine } from './BattleEngine';
|
3 |
+
import type { PicletDefinition, Move, SpecialAbility } from './types';
|
4 |
+
import { PicletType, AttackType } from './types';
|
5 |
+
|
6 |
+
describe('Missing Battle System Features', () => {
|
7 |
+
describe('manipulatePP Effects', () => {
|
8 |
+
it('should drain opponent PP', () => {
|
9 |
+
const ppDrainer: PicletDefinition = {
|
10 |
+
name: "PP Drainer",
|
11 |
+
description: "Drains opponent's PP",
|
12 |
+
tier: 'medium',
|
13 |
+
primaryType: PicletType.CULTURE,
|
14 |
+
baseStats: { hp: 80, attack: 60, defense: 60, speed: 70 },
|
15 |
+
nature: "Calm",
|
16 |
+
specialAbility: { name: "No Ability", description: "" },
|
17 |
+
movepool: [
|
18 |
+
{
|
19 |
+
name: "Mind Drain",
|
20 |
+
type: AttackType.CULTURE,
|
21 |
+
power: 0,
|
22 |
+
accuracy: 100,
|
23 |
+
pp: 10,
|
24 |
+
priority: 0,
|
25 |
+
flags: [],
|
26 |
+
effects: [
|
27 |
+
{
|
28 |
+
type: 'manipulatePP',
|
29 |
+
target: 'opponent',
|
30 |
+
action: 'drain',
|
31 |
+
amount: 'medium'
|
32 |
+
}
|
33 |
+
]
|
34 |
+
}
|
35 |
+
]
|
36 |
+
};
|
37 |
+
|
38 |
+
const opponent: PicletDefinition = {
|
39 |
+
name: "Opponent",
|
40 |
+
description: "Standard opponent",
|
41 |
+
tier: 'medium',
|
42 |
+
primaryType: PicletType.BEAST,
|
43 |
+
baseStats: { hp: 80, attack: 60, defense: 60, speed: 70 },
|
44 |
+
nature: "Hardy",
|
45 |
+
specialAbility: { name: "No Ability", description: "" },
|
46 |
+
movepool: [
|
47 |
+
{
|
48 |
+
name: "Tackle",
|
49 |
+
type: AttackType.NORMAL,
|
50 |
+
power: 40,
|
51 |
+
accuracy: 100,
|
52 |
+
pp: 10,
|
53 |
+
priority: 0,
|
54 |
+
flags: ['contact'],
|
55 |
+
effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
|
56 |
+
}
|
57 |
+
]
|
58 |
+
};
|
59 |
+
|
60 |
+
const engine = new BattleEngine(ppDrainer, opponent);
|
61 |
+
const initialPP = engine.getState().opponentPiclet.moves[0].currentPP;
|
62 |
+
|
63 |
+
engine.executeActions(
|
64 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
65 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 }
|
66 |
+
);
|
67 |
+
|
68 |
+
const finalPP = engine.getState().opponentPiclet.moves[0].currentPP;
|
69 |
+
expect(finalPP).toBeLessThan(initialPP);
|
70 |
+
expect(engine.getLog().some(msg => msg.includes('PP was drained'))).toBe(true);
|
71 |
+
});
|
72 |
+
|
73 |
+
it('should restore own PP', () => {
|
74 |
+
const ppRestorer: PicletDefinition = {
|
75 |
+
name: "PP Restorer",
|
76 |
+
description: "Restores own PP",
|
77 |
+
tier: 'medium',
|
78 |
+
primaryType: PicletType.FLORA,
|
79 |
+
baseStats: { hp: 80, attack: 60, defense: 60, speed: 70 },
|
80 |
+
nature: "Calm",
|
81 |
+
specialAbility: { name: "No Ability", description: "" },
|
82 |
+
movepool: [
|
83 |
+
{
|
84 |
+
name: "PP Restore",
|
85 |
+
type: AttackType.FLORA,
|
86 |
+
power: 0,
|
87 |
+
accuracy: 100,
|
88 |
+
pp: 5,
|
89 |
+
priority: 0,
|
90 |
+
flags: [],
|
91 |
+
effects: [
|
92 |
+
{
|
93 |
+
type: 'manipulatePP',
|
94 |
+
target: 'self',
|
95 |
+
action: 'restore',
|
96 |
+
amount: 'large'
|
97 |
+
}
|
98 |
+
]
|
99 |
+
}
|
100 |
+
]
|
101 |
+
};
|
102 |
+
|
103 |
+
const opponent: PicletDefinition = {
|
104 |
+
name: "Opponent",
|
105 |
+
description: "Standard opponent",
|
106 |
+
tier: 'medium',
|
107 |
+
primaryType: PicletType.BEAST,
|
108 |
+
baseStats: { hp: 80, attack: 60, defense: 60, speed: 70 },
|
109 |
+
nature: "Hardy",
|
110 |
+
specialAbility: { name: "No Ability", description: "" },
|
111 |
+
movepool: [
|
112 |
+
{
|
113 |
+
name: "Tackle",
|
114 |
+
type: AttackType.NORMAL,
|
115 |
+
power: 40,
|
116 |
+
accuracy: 100,
|
117 |
+
pp: 10,
|
118 |
+
priority: 0,
|
119 |
+
flags: ['contact'],
|
120 |
+
effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
|
121 |
+
}
|
122 |
+
]
|
123 |
+
};
|
124 |
+
|
125 |
+
const engine = new BattleEngine(ppRestorer, opponent);
|
126 |
+
|
127 |
+
// Use the PP restore move multiple times to drain it
|
128 |
+
engine['state'].playerPiclet.moves[0].currentPP = 1;
|
129 |
+
const initialPP = engine['state'].playerPiclet.moves[0].currentPP;
|
130 |
+
|
131 |
+
engine.executeActions(
|
132 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
133 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 }
|
134 |
+
);
|
135 |
+
|
136 |
+
const finalPP = engine.getState().playerPiclet.moves[0].currentPP;
|
137 |
+
expect(finalPP).toBeGreaterThan(initialPP);
|
138 |
+
expect(engine.getLog().some(msg => msg.includes('PP was restored'))).toBe(true);
|
139 |
+
});
|
140 |
+
});
|
141 |
+
|
142 |
+
describe('fieldEffect System', () => {
|
143 |
+
it('should apply field effects that persist across turns', () => {
|
144 |
+
const fieldEffectUser: PicletDefinition = {
|
145 |
+
name: "Field Controller",
|
146 |
+
description: "Controls battlefield effects",
|
147 |
+
tier: 'medium',
|
148 |
+
primaryType: PicletType.SPACE,
|
149 |
+
baseStats: { hp: 80, attack: 60, defense: 60, speed: 70 },
|
150 |
+
nature: "Calm",
|
151 |
+
specialAbility: { name: "No Ability", description: "" },
|
152 |
+
movepool: [
|
153 |
+
{
|
154 |
+
name: "Reflect",
|
155 |
+
type: AttackType.SPACE,
|
156 |
+
power: 0,
|
157 |
+
accuracy: 100,
|
158 |
+
pp: 10,
|
159 |
+
priority: 0,
|
160 |
+
flags: [],
|
161 |
+
effects: [
|
162 |
+
{
|
163 |
+
type: 'fieldEffect',
|
164 |
+
effect: 'reflect',
|
165 |
+
target: 'playerSide',
|
166 |
+
stackable: false
|
167 |
+
}
|
168 |
+
]
|
169 |
+
}
|
170 |
+
]
|
171 |
+
};
|
172 |
+
|
173 |
+
const opponent: PicletDefinition = {
|
174 |
+
name: "Opponent",
|
175 |
+
description: "Standard opponent",
|
176 |
+
tier: 'medium',
|
177 |
+
primaryType: PicletType.BEAST,
|
178 |
+
baseStats: { hp: 80, attack: 60, defense: 60, speed: 70 },
|
179 |
+
nature: "Hardy",
|
180 |
+
specialAbility: { name: "No Ability", description: "" },
|
181 |
+
movepool: [
|
182 |
+
{
|
183 |
+
name: "Physical Attack",
|
184 |
+
type: AttackType.BEAST,
|
185 |
+
power: 60,
|
186 |
+
accuracy: 100,
|
187 |
+
pp: 10,
|
188 |
+
priority: 0,
|
189 |
+
flags: ['contact'],
|
190 |
+
effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
|
191 |
+
}
|
192 |
+
]
|
193 |
+
};
|
194 |
+
|
195 |
+
const engine = new BattleEngine(fieldEffectUser, opponent);
|
196 |
+
|
197 |
+
// Apply reflect
|
198 |
+
engine.executeActions(
|
199 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
200 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 }
|
201 |
+
);
|
202 |
+
|
203 |
+
expect(engine.getLog().some(msg => msg.includes('Reflect') && msg.includes('applied'))).toBe(true);
|
204 |
+
|
205 |
+
// Check if reflect reduces physical damage in subsequent turns
|
206 |
+
const initialHp = engine.getState().playerPiclet.currentHp;
|
207 |
+
|
208 |
+
engine.executeActions(
|
209 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
210 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 }
|
211 |
+
);
|
212 |
+
|
213 |
+
const finalHp = engine.getState().playerPiclet.currentHp;
|
214 |
+
const damage = initialHp - finalHp;
|
215 |
+
|
216 |
+
// Reflect should reduce physical damage
|
217 |
+
expect(damage).toBeLessThan(30); // Should be reduced from normal ~40-50 damage
|
218 |
+
});
|
219 |
+
|
220 |
+
it('should handle spikes field effect', () => {
|
221 |
+
const spikesUser: PicletDefinition = {
|
222 |
+
name: "Spikes User",
|
223 |
+
description: "Sets entry hazards",
|
224 |
+
tier: 'medium',
|
225 |
+
primaryType: PicletType.MINERAL,
|
226 |
+
baseStats: { hp: 80, attack: 60, defense: 60, speed: 70 },
|
227 |
+
nature: "Impish",
|
228 |
+
specialAbility: { name: "No Ability", description: "" },
|
229 |
+
movepool: [
|
230 |
+
{
|
231 |
+
name: "Spikes",
|
232 |
+
type: AttackType.MINERAL,
|
233 |
+
power: 0,
|
234 |
+
accuracy: 100,
|
235 |
+
pp: 10,
|
236 |
+
priority: 0,
|
237 |
+
flags: [],
|
238 |
+
effects: [
|
239 |
+
{
|
240 |
+
type: 'fieldEffect',
|
241 |
+
effect: 'spikes',
|
242 |
+
target: 'opponentSide',
|
243 |
+
stackable: true
|
244 |
+
}
|
245 |
+
]
|
246 |
+
}
|
247 |
+
]
|
248 |
+
};
|
249 |
+
|
250 |
+
const opponent: PicletDefinition = {
|
251 |
+
name: "Opponent",
|
252 |
+
description: "Standard opponent",
|
253 |
+
tier: 'medium',
|
254 |
+
primaryType: PicletType.BEAST,
|
255 |
+
baseStats: { hp: 80, attack: 60, defense: 60, speed: 70 },
|
256 |
+
nature: "Hardy",
|
257 |
+
specialAbility: { name: "No Ability", description: "" },
|
258 |
+
movepool: [
|
259 |
+
{
|
260 |
+
name: "Tackle",
|
261 |
+
type: AttackType.NORMAL,
|
262 |
+
power: 40,
|
263 |
+
accuracy: 100,
|
264 |
+
pp: 10,
|
265 |
+
priority: 0,
|
266 |
+
flags: ['contact'],
|
267 |
+
effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
|
268 |
+
}
|
269 |
+
]
|
270 |
+
};
|
271 |
+
|
272 |
+
const engine = new BattleEngine(spikesUser, opponent);
|
273 |
+
|
274 |
+
engine.executeActions(
|
275 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
276 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 }
|
277 |
+
);
|
278 |
+
|
279 |
+
expect(engine.getLog().some(msg => msg.includes('Spikes') && msg.includes('set'))).toBe(true);
|
280 |
+
|
281 |
+
// TODO: Test spikes damage when switching (requires switching mechanics)
|
282 |
+
});
|
283 |
+
});
|
284 |
+
|
285 |
+
describe('counter Effects', () => {
|
286 |
+
it('should counter physical attacks', () => {
|
287 |
+
const counterUser: PicletDefinition = {
|
288 |
+
name: "Counter Fighter",
|
289 |
+
description: "Counters physical attacks",
|
290 |
+
tier: 'medium',
|
291 |
+
primaryType: PicletType.BEAST,
|
292 |
+
baseStats: { hp: 100, attack: 60, defense: 80, speed: 50 },
|
293 |
+
nature: "Brave",
|
294 |
+
specialAbility: { name: "No Ability", description: "" },
|
295 |
+
movepool: [
|
296 |
+
{
|
297 |
+
name: "Counter",
|
298 |
+
type: AttackType.BEAST,
|
299 |
+
power: 0,
|
300 |
+
accuracy: 100,
|
301 |
+
pp: 10,
|
302 |
+
priority: -5,
|
303 |
+
flags: ['lowPriority'],
|
304 |
+
effects: [
|
305 |
+
{
|
306 |
+
type: 'counter',
|
307 |
+
counterType: 'physical',
|
308 |
+
strength: 'strong'
|
309 |
+
}
|
310 |
+
]
|
311 |
+
}
|
312 |
+
]
|
313 |
+
};
|
314 |
+
|
315 |
+
const opponent: PicletDefinition = {
|
316 |
+
name: "Physical Attacker",
|
317 |
+
description: "Uses physical moves",
|
318 |
+
tier: 'medium',
|
319 |
+
primaryType: PicletType.BEAST,
|
320 |
+
baseStats: { hp: 80, attack: 80, defense: 60, speed: 70 },
|
321 |
+
nature: "Adamant",
|
322 |
+
specialAbility: { name: "No Ability", description: "" },
|
323 |
+
movepool: [
|
324 |
+
{
|
325 |
+
name: "Physical Strike",
|
326 |
+
type: AttackType.BEAST,
|
327 |
+
power: 80,
|
328 |
+
accuracy: 100,
|
329 |
+
pp: 10,
|
330 |
+
priority: 0,
|
331 |
+
flags: ['contact'],
|
332 |
+
effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
|
333 |
+
}
|
334 |
+
]
|
335 |
+
};
|
336 |
+
|
337 |
+
const engine = new BattleEngine(counterUser, opponent);
|
338 |
+
const initialOpponentHp = engine.getState().opponentPiclet.currentHp;
|
339 |
+
|
340 |
+
engine.executeActions(
|
341 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
342 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 }
|
343 |
+
);
|
344 |
+
|
345 |
+
const finalOpponentHp = engine.getState().opponentPiclet.currentHp;
|
346 |
+
expect(finalOpponentHp).toBeLessThan(initialOpponentHp);
|
347 |
+
expect(engine.getLog().some(msg => msg.includes('countered') || msg.includes('Counter'))).toBe(true);
|
348 |
+
});
|
349 |
+
});
|
350 |
+
|
351 |
+
describe('priority Effects', () => {
|
352 |
+
it('should modify move priority conditionally', () => {
|
353 |
+
const priorityUser: PicletDefinition = {
|
354 |
+
name: "Priority User",
|
355 |
+
description: "Uses priority moves based on conditions",
|
356 |
+
tier: 'medium',
|
357 |
+
primaryType: PicletType.SPACE,
|
358 |
+
baseStats: { hp: 60, attack: 70, defense: 50, speed: 40 },
|
359 |
+
nature: "Quiet",
|
360 |
+
specialAbility: { name: "No Ability", description: "" },
|
361 |
+
movepool: [
|
362 |
+
{
|
363 |
+
name: "Desperation Strike",
|
364 |
+
type: AttackType.SPACE,
|
365 |
+
power: 60,
|
366 |
+
accuracy: 100,
|
367 |
+
pp: 10,
|
368 |
+
priority: 0,
|
369 |
+
flags: [],
|
370 |
+
effects: [
|
371 |
+
{
|
372 |
+
type: 'damage',
|
373 |
+
target: 'opponent',
|
374 |
+
amount: 'normal'
|
375 |
+
},
|
376 |
+
{
|
377 |
+
type: 'priority',
|
378 |
+
target: 'self',
|
379 |
+
value: 1,
|
380 |
+
condition: 'ifLowHp'
|
381 |
+
}
|
382 |
+
]
|
383 |
+
}
|
384 |
+
]
|
385 |
+
};
|
386 |
+
|
387 |
+
const fastOpponent: PicletDefinition = {
|
388 |
+
name: "Fast Opponent",
|
389 |
+
description: "Very fast opponent",
|
390 |
+
tier: 'medium',
|
391 |
+
primaryType: PicletType.BEAST,
|
392 |
+
baseStats: { hp: 80, attack: 60, defense: 60, speed: 100 },
|
393 |
+
nature: "Timid",
|
394 |
+
specialAbility: { name: "No Ability", description: "" },
|
395 |
+
movepool: [
|
396 |
+
{
|
397 |
+
name: "Quick Attack",
|
398 |
+
type: AttackType.NORMAL,
|
399 |
+
power: 40,
|
400 |
+
accuracy: 100,
|
401 |
+
pp: 10,
|
402 |
+
priority: 0,
|
403 |
+
flags: [],
|
404 |
+
effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }]
|
405 |
+
}
|
406 |
+
]
|
407 |
+
};
|
408 |
+
|
409 |
+
const engine = new BattleEngine(priorityUser, fastOpponent);
|
410 |
+
|
411 |
+
// Damage the priority user to trigger low HP condition
|
412 |
+
engine['state'].playerPiclet.currentHp = Math.floor(engine['state'].playerPiclet.maxHp * 0.2);
|
413 |
+
|
414 |
+
engine.executeActions(
|
415 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
416 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 }
|
417 |
+
);
|
418 |
+
|
419 |
+
const log = engine.getLog();
|
420 |
+
const playerMoveIndex = log.findIndex(msg => msg.includes('Priority User used Desperation Strike'));
|
421 |
+
const opponentMoveIndex = log.findIndex(msg => msg.includes('Fast Opponent used Quick Attack'));
|
422 |
+
|
423 |
+
// When at low HP, priority user should go first despite lower speed
|
424 |
+
expect(playerMoveIndex).toBeLessThan(opponentMoveIndex);
|
425 |
+
});
|
426 |
+
});
|
427 |
+
|
428 |
+
describe('removeStatus Effects', () => {
|
429 |
+
it('should remove status effects from target', () => {
|
430 |
+
const statusClearer: PicletDefinition = {
|
431 |
+
name: "Status Clearer",
|
432 |
+
description: "Removes status effects",
|
433 |
+
tier: 'medium',
|
434 |
+
primaryType: PicletType.FLORA,
|
435 |
+
baseStats: { hp: 90, attack: 50, defense: 70, speed: 60 },
|
436 |
+
nature: "Calm",
|
437 |
+
specialAbility: { name: "No Ability", description: "" },
|
438 |
+
movepool: [
|
439 |
+
{
|
440 |
+
name: "Cleanse",
|
441 |
+
type: AttackType.FLORA,
|
442 |
+
power: 0,
|
443 |
+
accuracy: 100,
|
444 |
+
pp: 10,
|
445 |
+
priority: 0,
|
446 |
+
flags: [],
|
447 |
+
effects: [
|
448 |
+
{
|
449 |
+
type: 'removeStatus',
|
450 |
+
target: 'self',
|
451 |
+
status: 'poison'
|
452 |
+
}
|
453 |
+
]
|
454 |
+
}
|
455 |
+
]
|
456 |
+
};
|
457 |
+
|
458 |
+
const opponent: PicletDefinition = {
|
459 |
+
name: "Poisoner",
|
460 |
+
description: "Inflicts poison",
|
461 |
+
tier: 'medium',
|
462 |
+
primaryType: PicletType.BUG,
|
463 |
+
baseStats: { hp: 70, attack: 60, defense: 60, speed: 70 },
|
464 |
+
nature: "Modest",
|
465 |
+
specialAbility: { name: "No Ability", description: "" },
|
466 |
+
movepool: [
|
467 |
+
{
|
468 |
+
name: "Poison Sting",
|
469 |
+
type: AttackType.BUG,
|
470 |
+
power: 30,
|
471 |
+
accuracy: 100,
|
472 |
+
pp: 10,
|
473 |
+
priority: 0,
|
474 |
+
flags: [],
|
475 |
+
effects: [
|
476 |
+
{
|
477 |
+
type: 'damage',
|
478 |
+
target: 'opponent',
|
479 |
+
amount: 'weak'
|
480 |
+
},
|
481 |
+
{
|
482 |
+
type: 'applyStatus',
|
483 |
+
target: 'opponent',
|
484 |
+
status: 'poison'
|
485 |
+
}
|
486 |
+
]
|
487 |
+
}
|
488 |
+
]
|
489 |
+
};
|
490 |
+
|
491 |
+
const engine = new BattleEngine(statusClearer, opponent);
|
492 |
+
|
493 |
+
// First, get poisoned
|
494 |
+
engine.executeActions(
|
495 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
496 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 }
|
497 |
+
);
|
498 |
+
|
499 |
+
// Check if poisoned
|
500 |
+
expect(engine.getLog().some(msg => msg.includes('poisoned') || msg.includes('poison'))).toBe(true);
|
501 |
+
|
502 |
+
// Then cleanse the poison
|
503 |
+
engine.executeActions(
|
504 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
505 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 }
|
506 |
+
);
|
507 |
+
|
508 |
+
expect(engine.getLog().some(msg => msg.includes('cured') || msg.includes('cleansed'))).toBe(true);
|
509 |
+
});
|
510 |
+
});
|
511 |
+
});
|
src/lib/battle-engine/move-flags.test.ts
ADDED
@@ -0,0 +1,688 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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('Move Flags and Flag-Based Interactions', () => {
|
7 |
+
describe('Flag-Based Immunities', () => {
|
8 |
+
it('should provide immunity to contact moves with Ethereal Form', () => {
|
9 |
+
const etherealForm: SpecialAbility = {
|
10 |
+
name: "Ethereal Form",
|
11 |
+
description: "Ghostly body cannot be touched by physical contact",
|
12 |
+
effects: [
|
13 |
+
{
|
14 |
+
type: 'mechanicOverride',
|
15 |
+
mechanic: 'flagImmunity',
|
16 |
+
value: ['contact']
|
17 |
+
}
|
18 |
+
]
|
19 |
+
};
|
20 |
+
|
21 |
+
const ghostly: PicletDefinition = {
|
22 |
+
name: "Ghost Fighter",
|
23 |
+
description: "Ethereal being immune to contact",
|
24 |
+
tier: 'medium',
|
25 |
+
primaryType: PicletType.CULTURE,
|
26 |
+
baseStats: { hp: 70, attack: 80, defense: 50, speed: 90 },
|
27 |
+
nature: "Timid",
|
28 |
+
specialAbility: etherealForm,
|
29 |
+
movepool: [
|
30 |
+
{
|
31 |
+
name: "Shadow Ball",
|
32 |
+
type: AttackType.CULTURE,
|
33 |
+
power: 80,
|
34 |
+
accuracy: 100,
|
35 |
+
pp: 10,
|
36 |
+
priority: 0,
|
37 |
+
flags: [],
|
38 |
+
effects: [
|
39 |
+
{
|
40 |
+
type: 'damage',
|
41 |
+
target: 'opponent',
|
42 |
+
amount: 'normal'
|
43 |
+
}
|
44 |
+
]
|
45 |
+
}
|
46 |
+
]
|
47 |
+
};
|
48 |
+
|
49 |
+
const contactUser: PicletDefinition = {
|
50 |
+
name: "Physical Fighter",
|
51 |
+
description: "Uses contact moves",
|
52 |
+
tier: 'medium',
|
53 |
+
primaryType: PicletType.BEAST,
|
54 |
+
baseStats: { hp: 80, attack: 90, defense: 70, speed: 60 },
|
55 |
+
nature: "Adamant",
|
56 |
+
specialAbility: { name: "No Ability", description: "" },
|
57 |
+
movepool: [
|
58 |
+
{
|
59 |
+
name: "Punch",
|
60 |
+
type: AttackType.BEAST,
|
61 |
+
power: 75,
|
62 |
+
accuracy: 100,
|
63 |
+
pp: 10,
|
64 |
+
priority: 0,
|
65 |
+
flags: ['contact', 'punch'],
|
66 |
+
effects: [
|
67 |
+
{
|
68 |
+
type: 'damage',
|
69 |
+
target: 'opponent',
|
70 |
+
amount: 'normal'
|
71 |
+
}
|
72 |
+
]
|
73 |
+
},
|
74 |
+
{
|
75 |
+
name: "Energy Blast",
|
76 |
+
type: AttackType.SPACE,
|
77 |
+
power: 75,
|
78 |
+
accuracy: 100,
|
79 |
+
pp: 10,
|
80 |
+
priority: 0,
|
81 |
+
flags: [], // No contact flag
|
82 |
+
effects: [
|
83 |
+
{
|
84 |
+
type: 'damage',
|
85 |
+
target: 'opponent',
|
86 |
+
amount: 'normal'
|
87 |
+
}
|
88 |
+
]
|
89 |
+
}
|
90 |
+
]
|
91 |
+
};
|
92 |
+
|
93 |
+
const engine = new BattleEngine(ghostly, contactUser);
|
94 |
+
|
95 |
+
// Test contact move immunity
|
96 |
+
const initialHp = engine.getState().playerPiclet.currentHp;
|
97 |
+
engine.executeActions(
|
98 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
99 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 } // Contact move
|
100 |
+
);
|
101 |
+
|
102 |
+
const hpAfterContact = engine.getState().playerPiclet.currentHp;
|
103 |
+
expect(hpAfterContact).toBe(initialHp); // No damage from contact move
|
104 |
+
expect(engine.getLog().some(msg => msg.includes('had no effect') || msg.includes('immune'))).toBe(true);
|
105 |
+
|
106 |
+
// Test non-contact move still works
|
107 |
+
engine.executeActions(
|
108 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
109 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 1 } // Non-contact move
|
110 |
+
);
|
111 |
+
|
112 |
+
const hpAfterNonContact = engine.getState().playerPiclet.currentHp;
|
113 |
+
expect(hpAfterNonContact).toBeLessThan(hpAfterContact); // Should take damage
|
114 |
+
});
|
115 |
+
|
116 |
+
it('should provide immunity to sound moves with Sound Barrier', () => {
|
117 |
+
const soundBarrier: SpecialAbility = {
|
118 |
+
name: "Sound Barrier",
|
119 |
+
description: "Natural sound dampening prevents sound-based moves",
|
120 |
+
effects: [
|
121 |
+
{
|
122 |
+
type: 'mechanicOverride',
|
123 |
+
mechanic: 'flagImmunity',
|
124 |
+
value: ['sound']
|
125 |
+
}
|
126 |
+
]
|
127 |
+
};
|
128 |
+
|
129 |
+
const soundProof: PicletDefinition = {
|
130 |
+
name: "Silent Fighter",
|
131 |
+
description: "Cannot be affected by sound attacks",
|
132 |
+
tier: 'medium',
|
133 |
+
primaryType: PicletType.MACHINA,
|
134 |
+
baseStats: { hp: 85, attack: 70, defense: 85, speed: 50 },
|
135 |
+
nature: "Bold",
|
136 |
+
specialAbility: soundBarrier,
|
137 |
+
movepool: [
|
138 |
+
{
|
139 |
+
name: "Laser Beam",
|
140 |
+
type: AttackType.MACHINA,
|
141 |
+
power: 70,
|
142 |
+
accuracy: 100,
|
143 |
+
pp: 10,
|
144 |
+
priority: 0,
|
145 |
+
flags: [],
|
146 |
+
effects: [
|
147 |
+
{
|
148 |
+
type: 'damage',
|
149 |
+
target: 'opponent',
|
150 |
+
amount: 'normal'
|
151 |
+
}
|
152 |
+
]
|
153 |
+
}
|
154 |
+
]
|
155 |
+
};
|
156 |
+
|
157 |
+
const soundUser: PicletDefinition = {
|
158 |
+
name: "Sound Fighter",
|
159 |
+
description: "Uses sound-based attacks",
|
160 |
+
tier: 'medium',
|
161 |
+
primaryType: PicletType.CULTURE,
|
162 |
+
baseStats: { hp: 75, attack: 80, defense: 60, speed: 85 },
|
163 |
+
nature: "Modest",
|
164 |
+
specialAbility: { name: "No Ability", description: "" },
|
165 |
+
movepool: [
|
166 |
+
{
|
167 |
+
name: "Sonic Boom",
|
168 |
+
type: AttackType.CULTURE,
|
169 |
+
power: 80,
|
170 |
+
accuracy: 100,
|
171 |
+
pp: 10,
|
172 |
+
priority: 0,
|
173 |
+
flags: ['sound'],
|
174 |
+
effects: [
|
175 |
+
{
|
176 |
+
type: 'damage',
|
177 |
+
target: 'opponent',
|
178 |
+
amount: 'normal'
|
179 |
+
}
|
180 |
+
]
|
181 |
+
}
|
182 |
+
]
|
183 |
+
};
|
184 |
+
|
185 |
+
const engine = new BattleEngine(soundProof, soundUser);
|
186 |
+
const initialHp = engine.getState().playerPiclet.currentHp;
|
187 |
+
|
188 |
+
engine.executeActions(
|
189 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
190 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 }
|
191 |
+
);
|
192 |
+
|
193 |
+
const finalHp = engine.getState().playerPiclet.currentHp;
|
194 |
+
expect(finalHp).toBe(initialHp);
|
195 |
+
expect(engine.getLog().some(msg => msg.includes('had no effect') || msg.includes('immune'))).toBe(true);
|
196 |
+
});
|
197 |
+
|
198 |
+
it('should provide immunity to explosive moves with Soft Body', () => {
|
199 |
+
const softBody: SpecialAbility = {
|
200 |
+
name: "Soft Body",
|
201 |
+
description: "Gelatinous form absorbs explosions but vulnerable to direct hits",
|
202 |
+
effects: [
|
203 |
+
{
|
204 |
+
type: 'mechanicOverride',
|
205 |
+
mechanic: 'flagImmunity',
|
206 |
+
value: ['explosive']
|
207 |
+
},
|
208 |
+
{
|
209 |
+
type: 'mechanicOverride',
|
210 |
+
mechanic: 'flagWeakness',
|
211 |
+
value: ['punch']
|
212 |
+
}
|
213 |
+
]
|
214 |
+
};
|
215 |
+
|
216 |
+
const gelatinous: PicletDefinition = {
|
217 |
+
name: "Gel Fighter",
|
218 |
+
description: "Soft gelatinous body",
|
219 |
+
tier: 'medium',
|
220 |
+
primaryType: PicletType.AQUATIC,
|
221 |
+
baseStats: { hp: 90, attack: 60, defense: 80, speed: 60 },
|
222 |
+
nature: "Bold",
|
223 |
+
specialAbility: softBody,
|
224 |
+
movepool: [
|
225 |
+
{
|
226 |
+
name: "Water Gun",
|
227 |
+
type: AttackType.AQUATIC,
|
228 |
+
power: 60,
|
229 |
+
accuracy: 100,
|
230 |
+
pp: 10,
|
231 |
+
priority: 0,
|
232 |
+
flags: [],
|
233 |
+
effects: [
|
234 |
+
{
|
235 |
+
type: 'damage',
|
236 |
+
target: 'opponent',
|
237 |
+
amount: 'normal'
|
238 |
+
}
|
239 |
+
]
|
240 |
+
}
|
241 |
+
]
|
242 |
+
};
|
243 |
+
|
244 |
+
const explosiveUser: PicletDefinition = {
|
245 |
+
name: "Bomber",
|
246 |
+
description: "Uses explosive attacks",
|
247 |
+
tier: 'medium',
|
248 |
+
primaryType: PicletType.MACHINA,
|
249 |
+
baseStats: { hp: 70, attack: 90, defense: 60, speed: 70 },
|
250 |
+
nature: "Hasty",
|
251 |
+
specialAbility: { name: "No Ability", description: "" },
|
252 |
+
movepool: [
|
253 |
+
{
|
254 |
+
name: "Explosion",
|
255 |
+
type: AttackType.MACHINA,
|
256 |
+
power: 120,
|
257 |
+
accuracy: 100,
|
258 |
+
pp: 5,
|
259 |
+
priority: 0,
|
260 |
+
flags: ['explosive'],
|
261 |
+
effects: [
|
262 |
+
{
|
263 |
+
type: 'damage',
|
264 |
+
target: 'opponent',
|
265 |
+
amount: 'strong'
|
266 |
+
}
|
267 |
+
]
|
268 |
+
},
|
269 |
+
{
|
270 |
+
name: "Mega Punch",
|
271 |
+
type: AttackType.BEAST,
|
272 |
+
power: 80,
|
273 |
+
accuracy: 85,
|
274 |
+
pp: 10,
|
275 |
+
priority: 0,
|
276 |
+
flags: ['contact', 'punch'],
|
277 |
+
effects: [
|
278 |
+
{
|
279 |
+
type: 'damage',
|
280 |
+
target: 'opponent',
|
281 |
+
amount: 'normal'
|
282 |
+
}
|
283 |
+
]
|
284 |
+
}
|
285 |
+
]
|
286 |
+
};
|
287 |
+
|
288 |
+
const engine = new BattleEngine(gelatinous, explosiveUser);
|
289 |
+
const initialHp = engine.getState().playerPiclet.currentHp;
|
290 |
+
|
291 |
+
// Test explosive immunity
|
292 |
+
engine.executeActions(
|
293 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
294 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 } // Explosive move
|
295 |
+
);
|
296 |
+
|
297 |
+
const hpAfterExplosive = engine.getState().playerPiclet.currentHp;
|
298 |
+
expect(hpAfterExplosive).toBe(initialHp);
|
299 |
+
expect(engine.getLog().some(msg => msg.includes('had no effect') || msg.includes('absorbed'))).toBe(true);
|
300 |
+
|
301 |
+
// Test punch weakness (should take extra damage)
|
302 |
+
engine.executeActions(
|
303 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
304 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 1 } // Punch move
|
305 |
+
);
|
306 |
+
|
307 |
+
const hpAfterPunch = engine.getState().playerPiclet.currentHp;
|
308 |
+
expect(hpAfterPunch).toBeLessThan(hpAfterExplosive);
|
309 |
+
// Should take more damage than normal due to weakness
|
310 |
+
});
|
311 |
+
});
|
312 |
+
|
313 |
+
describe('Flag-Based Weaknesses', () => {
|
314 |
+
it('should take extra damage from specific flagged moves', () => {
|
315 |
+
const fragileShell: SpecialAbility = {
|
316 |
+
name: "Fragile Shell",
|
317 |
+
description: "Hard shell provides defense but shatters from explosions",
|
318 |
+
effects: [
|
319 |
+
{
|
320 |
+
type: 'modifyStats',
|
321 |
+
target: 'self',
|
322 |
+
stats: { defense: 'increase' }
|
323 |
+
},
|
324 |
+
{
|
325 |
+
type: 'mechanicOverride',
|
326 |
+
mechanic: 'flagWeakness',
|
327 |
+
value: ['explosive']
|
328 |
+
}
|
329 |
+
]
|
330 |
+
};
|
331 |
+
|
332 |
+
const shelledCreature: PicletDefinition = {
|
333 |
+
name: "Shell Fighter",
|
334 |
+
description: "Protected by fragile shell",
|
335 |
+
tier: 'medium',
|
336 |
+
primaryType: PicletType.MINERAL,
|
337 |
+
baseStats: { hp: 80, attack: 60, defense: 90, speed: 50 },
|
338 |
+
nature: "Impish",
|
339 |
+
specialAbility: fragileShell,
|
340 |
+
movepool: [
|
341 |
+
{
|
342 |
+
name: "Rock Throw",
|
343 |
+
type: AttackType.MINERAL,
|
344 |
+
power: 50,
|
345 |
+
accuracy: 90,
|
346 |
+
pp: 10,
|
347 |
+
priority: 0,
|
348 |
+
flags: [],
|
349 |
+
effects: [
|
350 |
+
{
|
351 |
+
type: 'damage',
|
352 |
+
target: 'opponent',
|
353 |
+
amount: 'normal'
|
354 |
+
}
|
355 |
+
]
|
356 |
+
}
|
357 |
+
]
|
358 |
+
};
|
359 |
+
|
360 |
+
const explosiveUser: PicletDefinition = {
|
361 |
+
name: "Bomber",
|
362 |
+
description: "Uses explosive attacks",
|
363 |
+
tier: 'medium',
|
364 |
+
primaryType: PicletType.MACHINA,
|
365 |
+
baseStats: { hp: 70, attack: 80, defense: 60, speed: 70 },
|
366 |
+
nature: "Modest",
|
367 |
+
specialAbility: { name: "No Ability", description: "" },
|
368 |
+
movepool: [
|
369 |
+
{
|
370 |
+
name: "Normal Attack",
|
371 |
+
type: AttackType.NORMAL,
|
372 |
+
power: 60,
|
373 |
+
accuracy: 100,
|
374 |
+
pp: 10,
|
375 |
+
priority: 0,
|
376 |
+
flags: [],
|
377 |
+
effects: [
|
378 |
+
{
|
379 |
+
type: 'damage',
|
380 |
+
target: 'opponent',
|
381 |
+
amount: 'normal'
|
382 |
+
}
|
383 |
+
]
|
384 |
+
},
|
385 |
+
{
|
386 |
+
name: "Bomb Blast",
|
387 |
+
type: AttackType.MACHINA,
|
388 |
+
power: 60,
|
389 |
+
accuracy: 100,
|
390 |
+
pp: 10,
|
391 |
+
priority: 0,
|
392 |
+
flags: ['explosive'],
|
393 |
+
effects: [
|
394 |
+
{
|
395 |
+
type: 'damage',
|
396 |
+
target: 'opponent',
|
397 |
+
amount: 'normal'
|
398 |
+
}
|
399 |
+
]
|
400 |
+
}
|
401 |
+
]
|
402 |
+
};
|
403 |
+
|
404 |
+
const engine = new BattleEngine(shelledCreature, explosiveUser);
|
405 |
+
|
406 |
+
// Test normal damage
|
407 |
+
const initialHp = engine.getState().playerPiclet.currentHp;
|
408 |
+
engine.executeActions(
|
409 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
410 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 } // Normal attack
|
411 |
+
);
|
412 |
+
|
413 |
+
const hpAfterNormal = engine.getState().playerPiclet.currentHp;
|
414 |
+
const normalDamage = initialHp - hpAfterNormal;
|
415 |
+
|
416 |
+
// Test explosive weakness (should do more damage)
|
417 |
+
const preExplosiveHp = engine.getState().playerPiclet.currentHp;
|
418 |
+
engine.executeActions(
|
419 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
420 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 1 } // Explosive attack
|
421 |
+
);
|
422 |
+
|
423 |
+
const hpAfterExplosive = engine.getState().playerPiclet.currentHp;
|
424 |
+
const explosiveDamage = preExplosiveHp - hpAfterExplosive;
|
425 |
+
|
426 |
+
// Explosive should do more damage due to weakness
|
427 |
+
expect(explosiveDamage).toBeGreaterThan(normalDamage);
|
428 |
+
expect(engine.getLog().some(msg => msg.includes('It\'s super effective') || msg.includes('weakness'))).toBe(true);
|
429 |
+
});
|
430 |
+
});
|
431 |
+
|
432 |
+
describe('Flag-Based Resistances', () => {
|
433 |
+
it('should take reduced damage from specific flagged moves', () => {
|
434 |
+
const thickHide: SpecialAbility = {
|
435 |
+
name: "Thick Hide",
|
436 |
+
description: "Tough skin reduces impact from physical contact",
|
437 |
+
effects: [
|
438 |
+
{
|
439 |
+
type: 'mechanicOverride',
|
440 |
+
mechanic: 'flagResistance',
|
441 |
+
value: ['contact']
|
442 |
+
}
|
443 |
+
]
|
444 |
+
};
|
445 |
+
|
446 |
+
const toughCreature: PicletDefinition = {
|
447 |
+
name: "Tough Fighter",
|
448 |
+
description: "Has thick, resistant hide",
|
449 |
+
tier: 'medium',
|
450 |
+
primaryType: PicletType.BEAST,
|
451 |
+
baseStats: { hp: 90, attack: 70, defense: 90, speed: 40 },
|
452 |
+
nature: "Impish",
|
453 |
+
specialAbility: thickHide,
|
454 |
+
movepool: [
|
455 |
+
{
|
456 |
+
name: "Bite",
|
457 |
+
type: AttackType.BEAST,
|
458 |
+
power: 60,
|
459 |
+
accuracy: 100,
|
460 |
+
pp: 10,
|
461 |
+
priority: 0,
|
462 |
+
flags: ['contact', 'bite'],
|
463 |
+
effects: [
|
464 |
+
{
|
465 |
+
type: 'damage',
|
466 |
+
target: 'opponent',
|
467 |
+
amount: 'normal'
|
468 |
+
}
|
469 |
+
]
|
470 |
+
}
|
471 |
+
]
|
472 |
+
};
|
473 |
+
|
474 |
+
const attacker: PicletDefinition = {
|
475 |
+
name: "Mixed Attacker",
|
476 |
+
description: "Uses various attack types",
|
477 |
+
tier: 'medium',
|
478 |
+
primaryType: PicletType.BEAST,
|
479 |
+
baseStats: { hp: 75, attack: 85, defense: 60, speed: 70 },
|
480 |
+
nature: "Adamant",
|
481 |
+
specialAbility: { name: "No Ability", description: "" },
|
482 |
+
movepool: [
|
483 |
+
{
|
484 |
+
name: "Scratch",
|
485 |
+
type: AttackType.BEAST,
|
486 |
+
power: 60,
|
487 |
+
accuracy: 100,
|
488 |
+
pp: 10,
|
489 |
+
priority: 0,
|
490 |
+
flags: ['contact'],
|
491 |
+
effects: [
|
492 |
+
{
|
493 |
+
type: 'damage',
|
494 |
+
target: 'opponent',
|
495 |
+
amount: 'normal'
|
496 |
+
}
|
497 |
+
]
|
498 |
+
},
|
499 |
+
{
|
500 |
+
name: "Energy Beam",
|
501 |
+
type: AttackType.SPACE,
|
502 |
+
power: 60,
|
503 |
+
accuracy: 100,
|
504 |
+
pp: 10,
|
505 |
+
priority: 0,
|
506 |
+
flags: [], // No contact
|
507 |
+
effects: [
|
508 |
+
{
|
509 |
+
type: 'damage',
|
510 |
+
target: 'opponent',
|
511 |
+
amount: 'normal'
|
512 |
+
}
|
513 |
+
]
|
514 |
+
}
|
515 |
+
]
|
516 |
+
};
|
517 |
+
|
518 |
+
const engine = new BattleEngine(toughCreature, attacker);
|
519 |
+
|
520 |
+
// Test contact move (should be resisted)
|
521 |
+
const initialHp = engine.getState().playerPiclet.currentHp;
|
522 |
+
engine.executeActions(
|
523 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
524 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 } // Contact move
|
525 |
+
);
|
526 |
+
|
527 |
+
const hpAfterContact = engine.getState().playerPiclet.currentHp;
|
528 |
+
const contactDamage = initialHp - hpAfterContact;
|
529 |
+
|
530 |
+
// Test non-contact move (normal damage)
|
531 |
+
const preBeamHp = engine.getState().playerPiclet.currentHp;
|
532 |
+
engine.executeActions(
|
533 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
534 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 1 } // Non-contact move
|
535 |
+
);
|
536 |
+
|
537 |
+
const hpAfterBeam = engine.getState().playerPiclet.currentHp;
|
538 |
+
const beamDamage = preBeamHp - hpAfterBeam;
|
539 |
+
|
540 |
+
// Contact damage should be less due to resistance
|
541 |
+
expect(contactDamage).toBeLessThan(beamDamage);
|
542 |
+
expect(engine.getLog().some(msg => msg.includes('not very effective') || msg.includes('resisted'))).toBe(true);
|
543 |
+
});
|
544 |
+
});
|
545 |
+
|
546 |
+
describe('Multi-Flag Interactions', () => {
|
547 |
+
it('should handle creatures with multiple flag interactions', () => {
|
548 |
+
const liquidBody: SpecialAbility = {
|
549 |
+
name: "Liquid Body",
|
550 |
+
description: "Fluid form flows around physical attacks but resonates with sound",
|
551 |
+
effects: [
|
552 |
+
{
|
553 |
+
type: 'mechanicOverride',
|
554 |
+
mechanic: 'flagImmunity',
|
555 |
+
value: ['punch', 'bite']
|
556 |
+
},
|
557 |
+
{
|
558 |
+
type: 'mechanicOverride',
|
559 |
+
mechanic: 'flagWeakness',
|
560 |
+
value: ['sound']
|
561 |
+
}
|
562 |
+
]
|
563 |
+
};
|
564 |
+
|
565 |
+
const liquidCreature: PicletDefinition = {
|
566 |
+
name: "Liquid Fighter",
|
567 |
+
description: "Made of flowing liquid",
|
568 |
+
tier: 'medium',
|
569 |
+
primaryType: PicletType.AQUATIC,
|
570 |
+
baseStats: { hp: 85, attack: 70, defense: 60, speed: 75 },
|
571 |
+
nature: "Calm",
|
572 |
+
specialAbility: liquidBody,
|
573 |
+
movepool: [
|
574 |
+
{
|
575 |
+
name: "Water Pulse",
|
576 |
+
type: AttackType.AQUATIC,
|
577 |
+
power: 60,
|
578 |
+
accuracy: 100,
|
579 |
+
pp: 10,
|
580 |
+
priority: 0,
|
581 |
+
flags: [],
|
582 |
+
effects: [
|
583 |
+
{
|
584 |
+
type: 'damage',
|
585 |
+
target: 'opponent',
|
586 |
+
amount: 'normal'
|
587 |
+
}
|
588 |
+
]
|
589 |
+
}
|
590 |
+
]
|
591 |
+
};
|
592 |
+
|
593 |
+
const multiAttacker: PicletDefinition = {
|
594 |
+
name: "Multi Attacker",
|
595 |
+
description: "Uses different types of attacks",
|
596 |
+
tier: 'medium',
|
597 |
+
primaryType: PicletType.BEAST,
|
598 |
+
baseStats: { hp: 75, attack: 80, defense: 65, speed: 70 },
|
599 |
+
nature: "Hardy",
|
600 |
+
specialAbility: { name: "No Ability", description: "" },
|
601 |
+
movepool: [
|
602 |
+
{
|
603 |
+
name: "Punch",
|
604 |
+
type: AttackType.BEAST,
|
605 |
+
power: 70,
|
606 |
+
accuracy: 100,
|
607 |
+
pp: 10,
|
608 |
+
priority: 0,
|
609 |
+
flags: ['contact', 'punch'],
|
610 |
+
effects: [
|
611 |
+
{
|
612 |
+
type: 'damage',
|
613 |
+
target: 'opponent',
|
614 |
+
amount: 'normal'
|
615 |
+
}
|
616 |
+
]
|
617 |
+
},
|
618 |
+
{
|
619 |
+
name: "Bite",
|
620 |
+
type: AttackType.BEAST,
|
621 |
+
power: 70,
|
622 |
+
accuracy: 100,
|
623 |
+
pp: 10,
|
624 |
+
priority: 0,
|
625 |
+
flags: ['contact', 'bite'],
|
626 |
+
effects: [
|
627 |
+
{
|
628 |
+
type: 'damage',
|
629 |
+
target: 'opponent',
|
630 |
+
amount: 'normal'
|
631 |
+
}
|
632 |
+
]
|
633 |
+
},
|
634 |
+
{
|
635 |
+
name: "Sonic Roar",
|
636 |
+
type: AttackType.CULTURE,
|
637 |
+
power: 70,
|
638 |
+
accuracy: 100,
|
639 |
+
pp: 10,
|
640 |
+
priority: 0,
|
641 |
+
flags: ['sound'],
|
642 |
+
effects: [
|
643 |
+
{
|
644 |
+
type: 'damage',
|
645 |
+
target: 'opponent',
|
646 |
+
amount: 'normal'
|
647 |
+
}
|
648 |
+
]
|
649 |
+
}
|
650 |
+
]
|
651 |
+
};
|
652 |
+
|
653 |
+
const engine = new BattleEngine(liquidCreature, multiAttacker);
|
654 |
+
const initialHp = engine.getState().playerPiclet.currentHp;
|
655 |
+
|
656 |
+
// Test punch immunity
|
657 |
+
engine.executeActions(
|
658 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
659 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 0 } // Punch (should be immune)
|
660 |
+
);
|
661 |
+
|
662 |
+
const hpAfterPunch = engine.getState().playerPiclet.currentHp;
|
663 |
+
expect(hpAfterPunch).toBe(initialHp);
|
664 |
+
|
665 |
+
// Test bite immunity
|
666 |
+
engine.executeActions(
|
667 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
668 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 1 } // Bite (should be immune)
|
669 |
+
);
|
670 |
+
|
671 |
+
const hpAfterBite = engine.getState().playerPiclet.currentHp;
|
672 |
+
expect(hpAfterBite).toBe(hpAfterPunch);
|
673 |
+
|
674 |
+
// Test sound weakness
|
675 |
+
engine.executeActions(
|
676 |
+
{ type: 'move', piclet: 'player', moveIndex: 0 },
|
677 |
+
{ type: 'move', piclet: 'opponent', moveIndex: 2 } // Sound (should be weak)
|
678 |
+
);
|
679 |
+
|
680 |
+
const hpAfterSound = engine.getState().playerPiclet.currentHp;
|
681 |
+
expect(hpAfterSound).toBeLessThan(hpAfterBite);
|
682 |
+
|
683 |
+
const log = engine.getLog();
|
684 |
+
expect(log.some(msg => msg.includes('had no effect'))).toBe(true);
|
685 |
+
expect(log.some(msg => msg.includes('super effective') || msg.includes('weakness'))).toBe(true);
|
686 |
+
});
|
687 |
+
});
|
688 |
+
});
|