clibuddy 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/dist/adventure/adventureUI.d.ts +24 -0
- package/dist/adventure/adventureUI.js +290 -0
- package/dist/adventure/adventures.d.ts +4 -0
- package/dist/adventure/adventures.js +206 -0
- package/dist/adventure/biomes.d.ts +30 -0
- package/dist/adventure/biomes.js +80 -0
- package/dist/adventure/combat/combat.d.ts +14 -0
- package/dist/adventure/combat/combat.js +638 -0
- package/dist/adventure/combat/combatUI.d.ts +5 -0
- package/dist/adventure/combat/combatUI.js +116 -0
- package/dist/adventure/combat/conditions.d.ts +20 -0
- package/dist/adventure/combat/conditions.js +111 -0
- package/dist/adventure/combat/enemies.d.ts +4 -0
- package/dist/adventure/combat/enemies.js +430 -0
- package/dist/adventure/combat/gear.d.ts +3 -0
- package/dist/adventure/combat/gear.js +199 -0
- package/dist/adventure/combat/skills.d.ts +6 -0
- package/dist/adventure/combat/skills.js +197 -0
- package/dist/adventure/combat.d.ts +31 -0
- package/dist/adventure/combat.js +732 -0
- package/dist/adventure/combatUI.d.ts +5 -0
- package/dist/adventure/combatUI.js +116 -0
- package/dist/adventure/endless.d.ts +18 -0
- package/dist/adventure/endless.js +154 -0
- package/dist/adventure/enemies.d.ts +4 -0
- package/dist/adventure/enemies.js +320 -0
- package/dist/adventure/engine.d.ts +20 -0
- package/dist/adventure/engine.js +137 -0
- package/dist/adventure/gear.d.ts +3 -0
- package/dist/adventure/gear.js +149 -0
- package/dist/adventure/generation/biomes.d.ts +30 -0
- package/dist/adventure/generation/biomes.js +102 -0
- package/dist/adventure/generation/endless.d.ts +18 -0
- package/dist/adventure/generation/endless.js +154 -0
- package/dist/adventure/generation/generator.d.ts +9 -0
- package/dist/adventure/generation/generator.js +245 -0
- package/dist/adventure/generation/templates.d.ts +25 -0
- package/dist/adventure/generation/templates.js +228 -0
- package/dist/adventure/generator.d.ts +9 -0
- package/dist/adventure/generator.js +245 -0
- package/dist/adventure/skills.d.ts +6 -0
- package/dist/adventure/skills.js +197 -0
- package/dist/adventure/templates.d.ts +25 -0
- package/dist/adventure/templates.js +228 -0
- package/dist/adventure/types.d.ts +236 -0
- package/dist/adventure/types.js +97 -0
- package/dist/app/state.d.ts +49 -0
- package/dist/app/state.js +51 -0
- package/dist/buddy/activities.d.ts +16 -0
- package/dist/buddy/activities.js +90 -0
- package/dist/buddy/decay.d.ts +3 -0
- package/dist/buddy/decay.js +45 -0
- package/dist/buddy/leveling.d.ts +11 -0
- package/dist/buddy/leveling.js +25 -0
- package/dist/buddy/roll.d.ts +4 -0
- package/dist/buddy/roll.js +61 -0
- package/dist/buddy/species.d.ts +4 -0
- package/dist/buddy/species.js +592 -0
- package/dist/buddy/titles.d.ts +17 -0
- package/dist/buddy/titles.js +89 -0
- package/dist/buddy/types.d.ts +92 -0
- package/dist/buddy/types.js +21 -0
- package/dist/commands/actions.d.ts +2 -0
- package/dist/commands/actions.js +141 -0
- package/dist/commands/admin.d.ts +2 -0
- package/dist/commands/admin.js +202 -0
- package/dist/commands/registry.d.ts +25 -0
- package/dist/commands/registry.js +31 -0
- package/dist/commands/social.d.ts +2 -0
- package/dist/commands/social.js +92 -0
- package/dist/dialogue/engine.d.ts +7 -0
- package/dist/dialogue/engine.js +68 -0
- package/dist/dialogue/lines.d.ts +26 -0
- package/dist/dialogue/lines.js +294 -0
- package/dist/events/engine.d.ts +20 -0
- package/dist/events/engine.js +51 -0
- package/dist/events/events.d.ts +13 -0
- package/dist/events/events.js +149 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +1665 -0
- package/dist/inventory/finding.d.ts +11 -0
- package/dist/inventory/finding.js +99 -0
- package/dist/inventory/items.d.ts +31 -0
- package/dist/inventory/items.js +63 -0
- package/dist/minigames/copycat.d.ts +2 -0
- package/dist/minigames/copycat.js +153 -0
- package/dist/minigames/fetch.d.ts +2 -0
- package/dist/minigames/fetch.js +179 -0
- package/dist/minigames/moodmatch.d.ts +2 -0
- package/dist/minigames/moodmatch.js +144 -0
- package/dist/minigames/quickpaws.d.ts +2 -0
- package/dist/minigames/quickpaws.js +142 -0
- package/dist/minigames/registry.d.ts +5 -0
- package/dist/minigames/registry.js +16 -0
- package/dist/minigames/rpsplus.d.ts +2 -0
- package/dist/minigames/rpsplus.js +168 -0
- package/dist/minigames/treasurehunt.d.ts +2 -0
- package/dist/minigames/treasurehunt.js +146 -0
- package/dist/minigames/types.d.ts +30 -0
- package/dist/minigames/types.js +69 -0
- package/dist/rendering/commandPalette.d.ts +16 -0
- package/dist/rendering/commandPalette.js +77 -0
- package/dist/rendering/display.d.ts +9 -0
- package/dist/rendering/display.js +231 -0
- package/dist/rendering/inventoryUI.d.ts +14 -0
- package/dist/rendering/inventoryUI.js +99 -0
- package/dist/rendering/items.d.ts +7 -0
- package/dist/rendering/items.js +34 -0
- package/dist/rendering/listUtils.d.ts +3 -0
- package/dist/rendering/listUtils.js +24 -0
- package/dist/rendering/minigameUI.d.ts +11 -0
- package/dist/rendering/minigameUI.js +37 -0
- package/dist/rendering/overlayUI.d.ts +24 -0
- package/dist/rendering/overlayUI.js +184 -0
- package/dist/rendering/scene.d.ts +8 -0
- package/dist/rendering/scene.js +87 -0
- package/dist/rendering/screen.d.ts +43 -0
- package/dist/rendering/screen.js +97 -0
- package/dist/sound/sound.d.ts +11 -0
- package/dist/sound/sound.js +55 -0
- package/dist/state/save.d.ts +5 -0
- package/dist/state/save.js +100 -0
- package/dist/state/settings.d.ts +7 -0
- package/dist/state/settings.js +81 -0
- package/dist/story/mainStory.d.ts +4 -0
- package/dist/story/mainStory.js +3111 -0
- package/dist/story/npcs.d.ts +17 -0
- package/dist/story/npcs.js +137 -0
- package/dist/story/progress.d.ts +26 -0
- package/dist/story/progress.js +168 -0
- package/dist/story/seasonal.d.ts +6 -0
- package/dist/story/seasonal.js +96 -0
- package/dist/story/shop.d.ts +7 -0
- package/dist/story/shop.js +26 -0
- package/dist/story/sideQuests.d.ts +4 -0
- package/dist/story/sideQuests.js +151 -0
- package/dist/story/types.d.ts +61 -0
- package/dist/story/types.js +36 -0
- package/dist/updates.d.ts +23 -0
- package/dist/updates.js +142 -0
- package/package.json +53 -0
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.deriveCombatStats = deriveCombatStats;
|
|
4
|
+
exports.deriveEnemyStats = deriveEnemyStats;
|
|
5
|
+
exports.startCombat = startCombat;
|
|
6
|
+
exports.playerAttack = playerAttack;
|
|
7
|
+
exports.playerDefend = playerDefend;
|
|
8
|
+
exports.playerFlee = playerFlee;
|
|
9
|
+
exports.playerUseSkill = playerUseSkill;
|
|
10
|
+
exports.enemyTurn = enemyTurn;
|
|
11
|
+
exports.processConditions = processConditions;
|
|
12
|
+
exports.applyShield = applyShield;
|
|
13
|
+
exports.hasCondition = hasCondition;
|
|
14
|
+
exports.addCondition = addCondition;
|
|
15
|
+
exports.getConditionStatMod = getConditionStatMod;
|
|
16
|
+
exports.tickCombat = tickCombat;
|
|
17
|
+
const species_1 = require("../buddy/species");
|
|
18
|
+
const items_1 = require("../inventory/items");
|
|
19
|
+
const types_1 = require("./types");
|
|
20
|
+
const lines_1 = require("../dialogue/lines");
|
|
21
|
+
// ─── Species Dodge Chances ───────────────────────────────────
|
|
22
|
+
const SPECIES_DODGE = {
|
|
23
|
+
cat: 0.20,
|
|
24
|
+
fox: 0.15,
|
|
25
|
+
rabbit: 0.10,
|
|
26
|
+
owl: 0.08,
|
|
27
|
+
frog: 0.05,
|
|
28
|
+
phoenix: 0.05,
|
|
29
|
+
dragon: 0.03,
|
|
30
|
+
};
|
|
31
|
+
function pickCombatLine(pool, speciesId) {
|
|
32
|
+
const lines = pool[speciesId];
|
|
33
|
+
if (!lines || lines.length === 0)
|
|
34
|
+
return "";
|
|
35
|
+
return lines[Math.floor(Math.random() * lines.length)];
|
|
36
|
+
}
|
|
37
|
+
const COMBOS = [
|
|
38
|
+
{ skill1: "focus", skill2: "pounce", name: "Focused Pounce", damageMultiplier: 2.5 },
|
|
39
|
+
{ skill1: "focus", skill2: "fire_breath", name: "Focused Breath", damageMultiplier: 2.8, extraEffect: "burn" },
|
|
40
|
+
{ skill1: "shadow_step", skill2: "pounce", name: "Shadow Ambush", damageMultiplier: 3.0 },
|
|
41
|
+
{ skill1: "scale_shield", skill2: "fire_breath", name: "Dragon Fury", damageMultiplier: 2.5, extraEffect: "burn" },
|
|
42
|
+
{ skill1: "keen_eye", skill2: "piercing_gaze", name: "Owl Strike", damageMultiplier: 3.0 },
|
|
43
|
+
{ skill1: "quick_hop", skill2: "earthquake", name: "Nature's Wrath", damageMultiplier: 2.0, extraEffect: "stun" },
|
|
44
|
+
{ skill1: "focus", skill2: "flame_burst", name: "Focused Flame", damageMultiplier: 2.4, extraEffect: "burn" },
|
|
45
|
+
];
|
|
46
|
+
function checkCombo(lastSkill, currentSkill) {
|
|
47
|
+
if (!lastSkill)
|
|
48
|
+
return null;
|
|
49
|
+
return COMBOS.find((c) => c.skill1 === lastSkill && c.skill2 === currentSkill) ?? null;
|
|
50
|
+
}
|
|
51
|
+
// ─── Combat Stat Derivation ─────────────────────────────────
|
|
52
|
+
/** Collect all equipped gear items */
|
|
53
|
+
function getEquippedGear(state) {
|
|
54
|
+
return [
|
|
55
|
+
state.adventureStats.equippedWeapon ? (0, items_1.getItem)(state.adventureStats.equippedWeapon) ?? null : null,
|
|
56
|
+
state.adventureStats.equippedArmor ? (0, items_1.getItem)(state.adventureStats.equippedArmor) ?? null : null,
|
|
57
|
+
state.adventureStats.equippedAccessory ? (0, items_1.getItem)(state.adventureStats.equippedAccessory) ?? null : null,
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
/** Sum a numeric gear property across all equipped items */
|
|
61
|
+
function sumGearProp(gear, prop) {
|
|
62
|
+
let total = 0;
|
|
63
|
+
for (const item of gear) {
|
|
64
|
+
if (item && typeof item[prop] === "number")
|
|
65
|
+
total += item[prop];
|
|
66
|
+
}
|
|
67
|
+
return total;
|
|
68
|
+
}
|
|
69
|
+
function deriveCombatStats(state) {
|
|
70
|
+
const species = (0, species_1.getSpecies)(state.speciesId);
|
|
71
|
+
const mod = species?.statModifier ?? 1.0;
|
|
72
|
+
const gear = getEquippedGear(state);
|
|
73
|
+
const bonusATK = sumGearProp(gear, "combatATK");
|
|
74
|
+
const bonusDEF = sumGearProp(gear, "combatDEF");
|
|
75
|
+
const bonusHP = sumGearProp(gear, "combatHP");
|
|
76
|
+
const bonusSPD = sumGearProp(gear, "combatSPD");
|
|
77
|
+
const bonusDodge = sumGearProp(gear, "dodgeBonus");
|
|
78
|
+
const maxHp = Math.floor((20 + state.level * 5) * mod) + bonusHP;
|
|
79
|
+
return {
|
|
80
|
+
maxHp,
|
|
81
|
+
hp: maxHp,
|
|
82
|
+
atk: Math.floor((3 + state.level * 2) * mod) + bonusATK,
|
|
83
|
+
def: Math.floor((2 + state.level * 1) * mod) + bonusDEF,
|
|
84
|
+
spd: Math.floor((5 + state.level * 1) * mod) + bonusSPD,
|
|
85
|
+
dodgeChance: (SPECIES_DODGE[state.speciesId] ?? 0.05) + bonusDodge,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function deriveEnemyStats(enemy) {
|
|
89
|
+
return {
|
|
90
|
+
maxHp: enemy.baseHp,
|
|
91
|
+
hp: enemy.baseHp,
|
|
92
|
+
atk: enemy.baseAtk,
|
|
93
|
+
def: enemy.baseDef,
|
|
94
|
+
spd: enemy.baseSpd,
|
|
95
|
+
dodgeChance: enemy.baseDodge ?? 0.05,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// ─── Combat Initialization ──────────────────────────────────
|
|
99
|
+
function startCombat(state, enemy, morale = 50) {
|
|
100
|
+
const gear = getEquippedGear(state);
|
|
101
|
+
const energyBonus = sumGearProp(gear, "energyBonus");
|
|
102
|
+
const combatEnergy = 3 + Math.floor(state.level / 5) + energyBonus;
|
|
103
|
+
// Gear passive conditions at combat start
|
|
104
|
+
const startConditions = [];
|
|
105
|
+
for (const item of gear) {
|
|
106
|
+
if (!item)
|
|
107
|
+
continue;
|
|
108
|
+
if (item.id === "phoenix_robe")
|
|
109
|
+
startConditions.push({ type: "regen", healPerTurn: 3, turnsLeft: 99 });
|
|
110
|
+
if (item.id === "guardian_shield")
|
|
111
|
+
startConditions.push({ type: "shield", amount: 15 });
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
phase: "player_turn",
|
|
115
|
+
playerStats: deriveCombatStats(state),
|
|
116
|
+
enemyStats: deriveEnemyStats(enemy),
|
|
117
|
+
enemy,
|
|
118
|
+
combatEnergy,
|
|
119
|
+
maxCombatEnergy: combatEnergy,
|
|
120
|
+
playerEffects: [],
|
|
121
|
+
enemyEffects: [],
|
|
122
|
+
playerConditions: startConditions,
|
|
123
|
+
enemyConditions: [],
|
|
124
|
+
isDefending: false,
|
|
125
|
+
turnCount: 0,
|
|
126
|
+
log: [enemy.encounterLine, pickCombatLine(lines_1.COMBAT_START_LINES, state.speciesId)],
|
|
127
|
+
ticksInPhase: 0,
|
|
128
|
+
damageTakenThisBattle: 0,
|
|
129
|
+
usedRevive: false,
|
|
130
|
+
morale,
|
|
131
|
+
playerElement: types_1.SPECIES_ELEMENTS[state.speciesId] ?? "neutral",
|
|
132
|
+
enemyElement: enemy.element,
|
|
133
|
+
equippedGearIds: gear.filter((g) => g !== null).map((g) => g.id),
|
|
134
|
+
comboTriggeredThisBattle: 0,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
// ─── Damage Calculation ─────────────────────────────────────
|
|
138
|
+
function calculateDamage(atkStat, defStat) {
|
|
139
|
+
const variance = 0.8 + Math.random() * 0.4;
|
|
140
|
+
return Math.max(1, Math.floor(atkStat * variance - defStat / 2));
|
|
141
|
+
}
|
|
142
|
+
function isCritical(effects, morale = 50) {
|
|
143
|
+
const critBonus = effects
|
|
144
|
+
.filter((e) => e.stat === "crit")
|
|
145
|
+
.reduce((sum, e) => sum + e.amount, 0);
|
|
146
|
+
const moraleBonus = morale > 70 ? 0.10 : 0; // high morale = +10% crit
|
|
147
|
+
return Math.random() < (0.10 + critBonus + moraleBonus);
|
|
148
|
+
}
|
|
149
|
+
function getEffectiveAtk(base, effects) {
|
|
150
|
+
let mod = 1;
|
|
151
|
+
for (const e of effects) {
|
|
152
|
+
if (e.stat === "atk")
|
|
153
|
+
mod += e.amount;
|
|
154
|
+
}
|
|
155
|
+
return Math.floor(base * mod);
|
|
156
|
+
}
|
|
157
|
+
function getEffectiveDef(base, effects) {
|
|
158
|
+
let mod = 1;
|
|
159
|
+
for (const e of effects) {
|
|
160
|
+
if (e.stat === "def")
|
|
161
|
+
mod += e.amount;
|
|
162
|
+
}
|
|
163
|
+
return Math.floor(base * mod);
|
|
164
|
+
}
|
|
165
|
+
function getEffectiveDodge(base, effects) {
|
|
166
|
+
let bonus = 0;
|
|
167
|
+
for (const e of effects) {
|
|
168
|
+
if (e.stat === "dodge")
|
|
169
|
+
bonus += e.amount;
|
|
170
|
+
}
|
|
171
|
+
return Math.min(0.8, base + bonus);
|
|
172
|
+
}
|
|
173
|
+
function tickEffects(effects) {
|
|
174
|
+
return effects
|
|
175
|
+
.map((e) => ({ ...e, turnsLeft: e.turnsLeft - 1 }))
|
|
176
|
+
.filter((e) => e.turnsLeft > 0);
|
|
177
|
+
}
|
|
178
|
+
// ─── Player Actions ─────────────────────────────────────────
|
|
179
|
+
function playerAttack(combat) {
|
|
180
|
+
let atk = getEffectiveAtk(combat.playerStats.atk, combat.playerEffects);
|
|
181
|
+
if (combat.morale < 20)
|
|
182
|
+
atk = Math.floor(atk * 0.9);
|
|
183
|
+
const def = getEffectiveDef(combat.enemyStats.def, combat.enemyEffects);
|
|
184
|
+
const crit = isCritical(combat.playerEffects, combat.morale);
|
|
185
|
+
let damage = calculateDamage(atk, def);
|
|
186
|
+
// Element type matchup
|
|
187
|
+
const { multiplier: elemMult, label: elemLabel } = (0, types_1.getElementMultiplier)(combat.playerElement, combat.enemyElement);
|
|
188
|
+
damage = Math.max(1, Math.floor(damage * elemMult));
|
|
189
|
+
if (crit)
|
|
190
|
+
damage = Math.floor(damage * 1.5);
|
|
191
|
+
const enemyDodge = getEffectiveDodge(combat.enemyStats.dodgeChance, combat.enemyEffects);
|
|
192
|
+
if (Math.random() < enemyDodge) {
|
|
193
|
+
return {
|
|
194
|
+
...combat,
|
|
195
|
+
phase: "player_animate",
|
|
196
|
+
log: [...combat.log, `${combat.enemy.name} dodges!`],
|
|
197
|
+
ticksInPhase: 0,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const newEnemyHp = Math.max(0, combat.enemyStats.hp - damage);
|
|
201
|
+
const logLines = [...combat.log];
|
|
202
|
+
if (elemLabel)
|
|
203
|
+
logLines.push(elemLabel);
|
|
204
|
+
logLines.push(crit ? `CRITICAL HIT! Dealt ${damage} damage!` : `Dealt ${damage} damage!`);
|
|
205
|
+
const log = logLines;
|
|
206
|
+
// Check weapon on-hit passive effects
|
|
207
|
+
let enemyConds = combat.enemyConditions;
|
|
208
|
+
for (const gearId of combat.equippedGearIds) {
|
|
209
|
+
const item = (0, items_1.getItem)(gearId);
|
|
210
|
+
if (!item?.onHitEffect || !item.onHitChance)
|
|
211
|
+
continue;
|
|
212
|
+
if (Math.random() < item.onHitChance) {
|
|
213
|
+
const condType = item.onHitEffect;
|
|
214
|
+
const power = item.onHitPower ?? 1;
|
|
215
|
+
if (condType === "burn") {
|
|
216
|
+
enemyConds = addCondition(enemyConds, { type: "burn", damagePerTurn: power, turnsLeft: 2 }, combat.enemyElement);
|
|
217
|
+
log.push(`${item.name} inflicts burn!`);
|
|
218
|
+
}
|
|
219
|
+
else if (condType === "freeze") {
|
|
220
|
+
enemyConds = addCondition(enemyConds, { type: "freeze", turnsLeft: power }, combat.enemyElement);
|
|
221
|
+
log.push(`${item.name} freezes the enemy!`);
|
|
222
|
+
}
|
|
223
|
+
else if (condType === "charm") {
|
|
224
|
+
enemyConds = addCondition(enemyConds, { type: "charm", turnsLeft: power }, combat.enemyElement);
|
|
225
|
+
log.push(`${item.name} charms the enemy!`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
...combat,
|
|
231
|
+
phase: "player_animate",
|
|
232
|
+
enemyStats: { ...combat.enemyStats, hp: newEnemyHp },
|
|
233
|
+
enemyConditions: enemyConds,
|
|
234
|
+
log,
|
|
235
|
+
ticksInPhase: 0,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function playerDefend(combat) {
|
|
239
|
+
return {
|
|
240
|
+
...combat,
|
|
241
|
+
phase: "player_animate",
|
|
242
|
+
isDefending: true,
|
|
243
|
+
log: [...combat.log, "Bracing for impact!"],
|
|
244
|
+
ticksInPhase: 0,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function playerFlee(combat) {
|
|
248
|
+
const fleeChance = 0.4 + (combat.playerStats.spd - combat.enemyStats.spd) * 0.05;
|
|
249
|
+
if (Math.random() < Math.max(0.2, Math.min(0.9, fleeChance))) {
|
|
250
|
+
return { ...combat, phase: "fled", log: [...combat.log, "Got away safely!"], ticksInPhase: 0 };
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
...combat,
|
|
254
|
+
phase: "player_animate",
|
|
255
|
+
log: [...combat.log, "Couldn't escape!"],
|
|
256
|
+
ticksInPhase: 0,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
function playerUseSkill(combat, skill) {
|
|
260
|
+
if (combat.combatEnergy < skill.energyCost) {
|
|
261
|
+
return { ...combat, log: [...combat.log, "Not enough energy!"] };
|
|
262
|
+
}
|
|
263
|
+
// Check for combo
|
|
264
|
+
const combo = checkCombo(combat.lastSkillUsed, skill.id);
|
|
265
|
+
let result = { ...combat, combatEnergy: combat.combatEnergy - skill.energyCost, lastSkillUsed: skill.id };
|
|
266
|
+
const effect = skill.effect;
|
|
267
|
+
switch (effect.type) {
|
|
268
|
+
case "damage": {
|
|
269
|
+
const atk = getEffectiveAtk(result.playerStats.atk, result.playerEffects);
|
|
270
|
+
const multiplier = combo ? combo.damageMultiplier : effect.multiplier;
|
|
271
|
+
let damage = Math.floor(atk * multiplier);
|
|
272
|
+
if (!effect.ignoreDefense) {
|
|
273
|
+
const def = getEffectiveDef(result.enemyStats.def, result.enemyEffects);
|
|
274
|
+
damage = Math.max(1, damage - Math.floor(def / 2));
|
|
275
|
+
}
|
|
276
|
+
// Element matchup: use skill element if present, otherwise species element
|
|
277
|
+
const skillElem = skill.element ?? combat.playerElement;
|
|
278
|
+
const { multiplier: eMult, label: eLabel } = (0, types_1.getElementMultiplier)(skillElem, combat.enemyElement);
|
|
279
|
+
damage = Math.max(1, Math.floor(damage * eMult));
|
|
280
|
+
const newHp = Math.max(0, result.enemyStats.hp - damage);
|
|
281
|
+
let enemyConds = result.enemyConditions;
|
|
282
|
+
let skillLog;
|
|
283
|
+
if (combo) {
|
|
284
|
+
skillLog = [`COMBO! ${combo.name}!`, `Dealt ${damage} damage!`];
|
|
285
|
+
result = { ...result, comboTriggeredThisBattle: result.comboTriggeredThisBattle + 1 };
|
|
286
|
+
if (combo.extraEffect === "burn")
|
|
287
|
+
enemyConds = addCondition(enemyConds, { type: "burn", damagePerTurn: 4, turnsLeft: 3 }, result.enemyElement);
|
|
288
|
+
if (combo.extraEffect === "stun")
|
|
289
|
+
enemyConds = addCondition(enemyConds, { type: "stun", turnsLeft: 2 }, result.enemyElement);
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
skillLog = eLabel ? [`${skill.name}! ${eLabel}`, `Dealt ${damage} damage!`] : [`${skill.name}! Dealt ${damage} damage!`];
|
|
293
|
+
}
|
|
294
|
+
// Apply bonus conditions based on specific skills
|
|
295
|
+
if (skill.id === "earthquake")
|
|
296
|
+
enemyConds = addCondition(enemyConds, { type: "stun", turnsLeft: 1 }, result.enemyElement);
|
|
297
|
+
if (skill.id === "shadow_strike")
|
|
298
|
+
enemyConds = addCondition(enemyConds, { type: "charm", turnsLeft: 1 }, result.enemyElement);
|
|
299
|
+
if (skill.id === "tidal_wave")
|
|
300
|
+
enemyConds = addCondition(enemyConds, { type: "freeze", turnsLeft: 1 }, result.enemyElement);
|
|
301
|
+
if (skill.id === "inferno")
|
|
302
|
+
enemyConds = addCondition(enemyConds, { type: "burn", damagePerTurn: 5, turnsLeft: 3 }, result.enemyElement);
|
|
303
|
+
if (skill.id === "supernova")
|
|
304
|
+
enemyConds = addCondition(enemyConds, { type: "burn", damagePerTurn: 5, turnsLeft: 3 }, result.enemyElement);
|
|
305
|
+
if (skill.id === "outfox")
|
|
306
|
+
enemyConds = addCondition(enemyConds, { type: "charm", turnsLeft: 1 }, result.enemyElement);
|
|
307
|
+
if (skill.id === "piercing_gaze")
|
|
308
|
+
enemyConds = addCondition(enemyConds, { type: "debuff", stat: "def", amount: 0.5, turnsLeft: 2 }, result.enemyElement);
|
|
309
|
+
result = {
|
|
310
|
+
...result,
|
|
311
|
+
phase: "player_animate",
|
|
312
|
+
enemyStats: { ...result.enemyStats, hp: newHp },
|
|
313
|
+
enemyConditions: enemyConds,
|
|
314
|
+
log: [...result.log, ...skillLog],
|
|
315
|
+
ticksInPhase: 0,
|
|
316
|
+
};
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
case "buff": {
|
|
320
|
+
// Special skills that apply conditions instead of simple buffs
|
|
321
|
+
let playerConds = result.playerConditions;
|
|
322
|
+
if (skill.id === "aqua_shield") {
|
|
323
|
+
playerConds = [...playerConds, { type: "shield", amount: 20 }];
|
|
324
|
+
result = { ...result, phase: "player_animate", playerConditions: playerConds, log: [...result.log, `${skill.name}! Shield active (20 HP)!`], ticksInPhase: 0 };
|
|
325
|
+
}
|
|
326
|
+
else if (skill.id === "natures_blessing") {
|
|
327
|
+
playerConds = [...playerConds, { type: "regen", healPerTurn: 5, turnsLeft: 3 }];
|
|
328
|
+
result = { ...result, phase: "player_animate", playerConditions: playerConds, log: [...result.log, `${skill.name}! Regenerating!`], ticksInPhase: 0 };
|
|
329
|
+
}
|
|
330
|
+
else if (skill.id === "shadow_step") {
|
|
331
|
+
// Dodge buff + guaranteed crit on next attack
|
|
332
|
+
playerConds = [...playerConds, { type: "buff", stat: "crit", amount: 1.0, turnsLeft: 1 }];
|
|
333
|
+
result = {
|
|
334
|
+
...result, phase: "player_animate",
|
|
335
|
+
playerEffects: [...result.playerEffects, { stat: effect.stat, amount: effect.amount, turnsLeft: effect.turns }],
|
|
336
|
+
playerConditions: playerConds,
|
|
337
|
+
log: [...result.log, `${skill.name}! Next attack will crit!`], ticksInPhase: 0,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
result = {
|
|
342
|
+
...result, phase: "player_animate",
|
|
343
|
+
playerEffects: [...result.playerEffects, { stat: effect.stat, amount: effect.amount, turnsLeft: effect.turns }],
|
|
344
|
+
log: [...result.log, `${skill.name}!`], ticksInPhase: 0,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
case "debuff":
|
|
350
|
+
result = {
|
|
351
|
+
...result,
|
|
352
|
+
phase: "player_animate",
|
|
353
|
+
enemyEffects: [...result.enemyEffects, { stat: effect.stat, amount: -effect.amount, turnsLeft: effect.turns }],
|
|
354
|
+
log: [...result.log, `${skill.name}! Enemy weakened!`],
|
|
355
|
+
ticksInPhase: 0,
|
|
356
|
+
};
|
|
357
|
+
break;
|
|
358
|
+
case "poison":
|
|
359
|
+
result = {
|
|
360
|
+
...result,
|
|
361
|
+
phase: "player_animate",
|
|
362
|
+
enemyEffects: [...result.enemyEffects, { stat: "poison", amount: effect.damagePerTurn, turnsLeft: effect.turns }],
|
|
363
|
+
log: [...result.log, `${skill.name}! Enemy poisoned!`],
|
|
364
|
+
ticksInPhase: 0,
|
|
365
|
+
};
|
|
366
|
+
break;
|
|
367
|
+
case "stun":
|
|
368
|
+
case "skip_enemy_turn": {
|
|
369
|
+
let newEnemyConds = result.enemyConditions;
|
|
370
|
+
// Special: illuminate applies blind instead of stun
|
|
371
|
+
if (skill.id === "illuminate") {
|
|
372
|
+
newEnemyConds = addCondition(newEnemyConds, { type: "blind", turnsLeft: 2 }, result.enemyElement);
|
|
373
|
+
result = { ...result, phase: "player_animate", enemyConditions: newEnemyConds, log: [...result.log, `${skill.name}! Enemy blinded!`], ticksInPhase: 0 };
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
newEnemyConds = addCondition(newEnemyConds, { type: "stun", turnsLeft: effect.type === "stun" ? effect.turns : 1 }, result.enemyElement);
|
|
377
|
+
result = { ...result, phase: "player_animate", enemyConditions: newEnemyConds, log: [...result.log, `${skill.name}! Enemy stunned!`], ticksInPhase: 0 };
|
|
378
|
+
}
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
case "revive":
|
|
382
|
+
// Mark revive as used — actual effect triggers on KO
|
|
383
|
+
result = {
|
|
384
|
+
...result,
|
|
385
|
+
phase: "player_animate",
|
|
386
|
+
usedRevive: true,
|
|
387
|
+
log: [...result.log, `${skill.name} activated!`],
|
|
388
|
+
ticksInPhase: 0,
|
|
389
|
+
};
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
return result;
|
|
393
|
+
}
|
|
394
|
+
// ─── Enemy Turn ─────────────────────────────────────────────
|
|
395
|
+
function enemyTurn(combat) {
|
|
396
|
+
const enemy = combat.enemy;
|
|
397
|
+
const hpPct = combat.enemyStats.hp / combat.enemyStats.maxHp;
|
|
398
|
+
let enemyHp = combat.enemyStats.hp;
|
|
399
|
+
const extraLog = [];
|
|
400
|
+
// ─── Boss Phase Logic ──────────────────────────────
|
|
401
|
+
// Phase 3: Enraged (HP < 25%) — boosted ATK, special every other turn
|
|
402
|
+
const isEnraged = enemy.isBoss && enemy.enrageAtkBonus && hpPct < 0.25;
|
|
403
|
+
if (isEnraged && combat.turnCount % 2 === 0 && enemy.specialMove) {
|
|
404
|
+
const special = enemy.specialMove;
|
|
405
|
+
if (special.damageMultiplier < 0) {
|
|
406
|
+
const heal = Math.floor(combat.enemyStats.maxHp * Math.abs(special.damageMultiplier));
|
|
407
|
+
const newHp = Math.min(combat.enemyStats.maxHp, enemyHp + heal);
|
|
408
|
+
return { ...combat, phase: "enemy_animate", enemyStats: { ...combat.enemyStats, hp: newHp }, enemyEffects: tickEffects(combat.enemyEffects), log: [...combat.log, "ENRAGED!", special.line, `Healed ${heal} HP!`], ticksInPhase: 0 };
|
|
409
|
+
}
|
|
410
|
+
const enragedAtk = Math.floor(enemy.baseAtk * (1 + enemy.enrageAtkBonus));
|
|
411
|
+
const dmg = Math.floor(enragedAtk * special.damageMultiplier);
|
|
412
|
+
return applyDamageToPlayer(combat, dmg, ["ENRAGED!", special.line], enemyHp);
|
|
413
|
+
}
|
|
414
|
+
// Phase 2: (HP < 50%) — use phase2Move if available
|
|
415
|
+
if (enemy.isBoss && enemy.phase2Move && hpPct < 0.5 && hpPct >= 0.25) {
|
|
416
|
+
if (Math.random() < enemy.phase2Move.chance) {
|
|
417
|
+
const p2 = enemy.phase2Move;
|
|
418
|
+
if (p2.damageMultiplier < 0) {
|
|
419
|
+
const heal = Math.floor(combat.enemyStats.maxHp * Math.abs(p2.damageMultiplier));
|
|
420
|
+
const newHp = Math.min(combat.enemyStats.maxHp, enemyHp + heal);
|
|
421
|
+
return { ...combat, phase: "enemy_animate", enemyStats: { ...combat.enemyStats, hp: newHp }, enemyEffects: tickEffects(combat.enemyEffects), log: [...combat.log, p2.line, `Healed ${heal} HP!`], ticksInPhase: 0 };
|
|
422
|
+
}
|
|
423
|
+
const dmg = Math.floor(enemy.baseAtk * p2.damageMultiplier);
|
|
424
|
+
return applyDamageToPlayer(combat, dmg, [p2.line], enemyHp);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
// Phase 1 / non-boss: specialMove at < 30%
|
|
428
|
+
if (enemy.specialMove && hpPct < 0.3) {
|
|
429
|
+
if (Math.random() < enemy.specialMove.chance) {
|
|
430
|
+
const special = enemy.specialMove;
|
|
431
|
+
if (special.damageMultiplier < 0) {
|
|
432
|
+
const heal = Math.floor(combat.enemyStats.maxHp * Math.abs(special.damageMultiplier));
|
|
433
|
+
const newHp = Math.min(combat.enemyStats.maxHp, enemyHp + heal);
|
|
434
|
+
return { ...combat, phase: "enemy_animate", enemyStats: { ...combat.enemyStats, hp: newHp }, enemyEffects: tickEffects(combat.enemyEffects), log: [...combat.log, special.line, `Healed ${heal} HP!`], ticksInPhase: 0 };
|
|
435
|
+
}
|
|
436
|
+
const dmg = Math.floor(enemy.baseAtk * special.damageMultiplier);
|
|
437
|
+
return applyDamageToPlayer(combat, dmg, [special.line], enemyHp);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
// ─── AI Behavior ───────────────────────────────────
|
|
441
|
+
// Blind check — 40% miss
|
|
442
|
+
if (hasCondition(combat.enemyConditions, "blind") && Math.random() < 0.4) {
|
|
443
|
+
return { ...combat, phase: "enemy_animate", enemyEffects: tickEffects(combat.enemyEffects), isDefending: false, log: [...combat.log, `${enemy.name} attacks but misses! (blinded)`], ticksInPhase: 0 };
|
|
444
|
+
}
|
|
445
|
+
// Defensive: alternate attack/defend, defend when HP < 50%
|
|
446
|
+
if (enemy.aiBehavior === "defensive" && (hpPct < 0.5 || combat.turnCount % 2 === 1)) {
|
|
447
|
+
return { ...combat, phase: "enemy_animate", enemyEffects: tickEffects(combat.enemyEffects), isDefending: false, log: [...combat.log, enemy.defendLine ?? `${enemy.name} braces for impact!`], ticksInPhase: 0 };
|
|
448
|
+
// Defending enemies take less damage next turn — we'd need enemy isDefending state
|
|
449
|
+
// For now, defensive AI just skips attack occasionally (effectively a weaker turn)
|
|
450
|
+
}
|
|
451
|
+
// Evasive: high dodge, flee at < 20% HP
|
|
452
|
+
if (enemy.aiBehavior === "evasive" && hpPct < 0.2) {
|
|
453
|
+
// Enemy flees — player doesn't get full XP
|
|
454
|
+
return { ...combat, phase: "fled", log: [...combat.log, `${enemy.name} flees into the shadows!`], ticksInPhase: 0 };
|
|
455
|
+
}
|
|
456
|
+
// Tactical: debuff player first turn, then attack
|
|
457
|
+
if (enemy.aiBehavior === "tactical" && combat.turnCount <= 1) {
|
|
458
|
+
const debuffConds = addCondition(combat.playerConditions, { type: "debuff", stat: "def", amount: 0.2, turnsLeft: 3 }, combat.playerElement);
|
|
459
|
+
return { ...combat, phase: "enemy_animate", playerConditions: debuffConds, enemyEffects: tickEffects(combat.enemyEffects), isDefending: false, log: [...combat.log, `${enemy.name} studies your weaknesses! (-20% DEF)`], ticksInPhase: 0 };
|
|
460
|
+
}
|
|
461
|
+
// Berserker: ATK increases as HP drops
|
|
462
|
+
let atkMod = 1.0;
|
|
463
|
+
if (enemy.aiBehavior === "berserker" && hpPct < 0.25) {
|
|
464
|
+
atkMod = 1.5;
|
|
465
|
+
extraLog.push(`${enemy.name} is berserk!`);
|
|
466
|
+
}
|
|
467
|
+
else if (enemy.aiBehavior === "berserker" && hpPct < 0.5) {
|
|
468
|
+
atkMod = 1.25;
|
|
469
|
+
}
|
|
470
|
+
// Enraged boss ATK bonus (use max of berserker and enrage, don't stack)
|
|
471
|
+
if (isEnraged && enemy.enrageAtkBonus) {
|
|
472
|
+
atkMod = Math.max(atkMod, 1 + enemy.enrageAtkBonus);
|
|
473
|
+
}
|
|
474
|
+
// ─── Normal Attack ─────────────────────────────────
|
|
475
|
+
const atk = Math.floor(getEffectiveAtk(enemy.baseAtk, combat.enemyEffects) * atkMod);
|
|
476
|
+
const def = getEffectiveDef(combat.playerStats.def, combat.playerEffects);
|
|
477
|
+
let damage = calculateDamage(atk, def);
|
|
478
|
+
const { multiplier: enemyElemMult } = (0, types_1.getElementMultiplier)(combat.enemyElement, combat.playerElement);
|
|
479
|
+
damage = Math.max(1, Math.floor(damage * enemyElemMult));
|
|
480
|
+
if (combat.isDefending)
|
|
481
|
+
damage = Math.floor(damage * 0.5);
|
|
482
|
+
// Player dodge check
|
|
483
|
+
let dodge = getEffectiveDodge(combat.playerStats.dodgeChance, combat.playerEffects);
|
|
484
|
+
if (combat.morale < 40)
|
|
485
|
+
dodge = Math.max(0, dodge - 0.15);
|
|
486
|
+
// Evasive enemies are hard to counter-dodge against
|
|
487
|
+
if (Math.random() < dodge) {
|
|
488
|
+
return {
|
|
489
|
+
...combat, phase: "enemy_animate",
|
|
490
|
+
enemyStats: { ...combat.enemyStats, hp: enemyHp },
|
|
491
|
+
enemyEffects: tickEffects(combat.enemyEffects),
|
|
492
|
+
isDefending: false,
|
|
493
|
+
log: [...combat.log, ...extraLog, enemy.attackLine, "Dodged!"],
|
|
494
|
+
ticksInPhase: 0,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
return applyDamageToPlayer(combat, damage, [...extraLog, combat.enemy.attackLine], enemyHp);
|
|
498
|
+
}
|
|
499
|
+
function applyDamageToPlayer(combat, damage, extraLog, enemyHp) {
|
|
500
|
+
// Apply shield first
|
|
501
|
+
const shieldResult = applyShield(damage, combat.playerConditions);
|
|
502
|
+
damage = shieldResult.damage;
|
|
503
|
+
let playerConditions = shieldResult.conditions;
|
|
504
|
+
if (shieldResult.damage < damage)
|
|
505
|
+
extraLog.push("Shield absorbs some damage!");
|
|
506
|
+
// Freeze shatters on taking damage
|
|
507
|
+
playerConditions = playerConditions.filter((c) => c.type !== "freeze");
|
|
508
|
+
let playerHp = Math.max(0, combat.playerStats.hp - damage);
|
|
509
|
+
const damageTaken = combat.damageTakenThisBattle + damage;
|
|
510
|
+
// Low HP panic line
|
|
511
|
+
if (playerHp > 0 && playerHp < combat.playerStats.maxHp * 0.25 && combat.playerStats.hp >= combat.playerStats.maxHp * 0.25) {
|
|
512
|
+
const panicLine = lines_1.COMBAT_LOW_HP_LINES[Math.floor(Math.random() * lines_1.COMBAT_LOW_HP_LINES.length)];
|
|
513
|
+
extraLog.push(panicLine);
|
|
514
|
+
}
|
|
515
|
+
// Check revive
|
|
516
|
+
if (playerHp <= 0 && combat.usedRevive) {
|
|
517
|
+
playerHp = Math.floor(combat.playerStats.maxHp * 0.3);
|
|
518
|
+
return {
|
|
519
|
+
...combat,
|
|
520
|
+
phase: "enemy_animate",
|
|
521
|
+
playerStats: { ...combat.playerStats, hp: playerHp },
|
|
522
|
+
enemyStats: { ...combat.enemyStats, hp: enemyHp },
|
|
523
|
+
enemyEffects: tickEffects(combat.enemyEffects),
|
|
524
|
+
playerEffects: tickEffects(combat.playerEffects),
|
|
525
|
+
isDefending: false,
|
|
526
|
+
usedRevive: false,
|
|
527
|
+
damageTakenThisBattle: damageTaken,
|
|
528
|
+
log: [...combat.log, ...extraLog, `Took ${damage} damage!`, "Revived!"],
|
|
529
|
+
ticksInPhase: 0,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
if (playerHp <= 0) {
|
|
533
|
+
return {
|
|
534
|
+
...combat,
|
|
535
|
+
phase: "defeat",
|
|
536
|
+
playerStats: { ...combat.playerStats, hp: 0 },
|
|
537
|
+
damageTakenThisBattle: damageTaken,
|
|
538
|
+
log: [...combat.log, ...extraLog, `Took ${damage} damage!`, "Knocked out!"],
|
|
539
|
+
ticksInPhase: 0,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
return {
|
|
543
|
+
...combat,
|
|
544
|
+
phase: "enemy_animate",
|
|
545
|
+
playerStats: { ...combat.playerStats, hp: playerHp },
|
|
546
|
+
enemyStats: { ...combat.enemyStats, hp: enemyHp },
|
|
547
|
+
enemyEffects: tickEffects(combat.enemyEffects),
|
|
548
|
+
playerEffects: tickEffects(combat.playerEffects),
|
|
549
|
+
isDefending: false,
|
|
550
|
+
damageTakenThisBattle: damageTaken,
|
|
551
|
+
log: [...combat.log, ...extraLog, `Took ${damage} damage!`],
|
|
552
|
+
ticksInPhase: 0,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
// ─── Condition Processing ────────────────────────────────────
|
|
556
|
+
/** Apply start-of-turn conditions to a combatant. Returns updated HP, conditions, and log messages. */
|
|
557
|
+
function processConditions(hp, maxHp, conditions, log, targetName) {
|
|
558
|
+
let skipTurn = false;
|
|
559
|
+
const newLog = [...log];
|
|
560
|
+
let newConditions = [];
|
|
561
|
+
for (const cond of conditions) {
|
|
562
|
+
switch (cond.type) {
|
|
563
|
+
case "burn":
|
|
564
|
+
hp = Math.max(0, hp - cond.damagePerTurn);
|
|
565
|
+
newLog.push(`${targetName} takes ${cond.damagePerTurn} burn damage!`);
|
|
566
|
+
if (cond.turnsLeft > 1)
|
|
567
|
+
newConditions.push({ ...cond, turnsLeft: cond.turnsLeft - 1 });
|
|
568
|
+
// turnsLeft <= 1: burn expires, not pushed back
|
|
569
|
+
break;
|
|
570
|
+
case "poison":
|
|
571
|
+
hp = Math.max(0, hp - cond.damagePerTurn);
|
|
572
|
+
newLog.push(`${targetName} takes ${cond.damagePerTurn} poison damage!`);
|
|
573
|
+
if (cond.turnsLeft > 1)
|
|
574
|
+
newConditions.push({ ...cond, turnsLeft: cond.turnsLeft - 1 });
|
|
575
|
+
break;
|
|
576
|
+
case "freeze":
|
|
577
|
+
skipTurn = true;
|
|
578
|
+
newLog.push(`${targetName} is frozen solid!`);
|
|
579
|
+
// Freeze breaks after 1 turn or when damaged
|
|
580
|
+
if (cond.turnsLeft > 1)
|
|
581
|
+
newConditions.push({ ...cond, turnsLeft: cond.turnsLeft - 1 });
|
|
582
|
+
break;
|
|
583
|
+
case "stun":
|
|
584
|
+
skipTurn = true;
|
|
585
|
+
newLog.push(`${targetName} is stunned!`);
|
|
586
|
+
if (cond.turnsLeft > 1)
|
|
587
|
+
newConditions.push({ ...cond, turnsLeft: cond.turnsLeft - 1 });
|
|
588
|
+
break;
|
|
589
|
+
case "charm":
|
|
590
|
+
skipTurn = true; // handled specially — enemy attacks itself
|
|
591
|
+
newLog.push(`${targetName} is charmed and confused!`);
|
|
592
|
+
if (cond.turnsLeft > 1)
|
|
593
|
+
newConditions.push({ ...cond, turnsLeft: cond.turnsLeft - 1 });
|
|
594
|
+
break;
|
|
595
|
+
case "blind":
|
|
596
|
+
// Blind doesn't skip turn, just reduces accuracy — handled in attack logic
|
|
597
|
+
if (cond.turnsLeft > 1)
|
|
598
|
+
newConditions.push({ ...cond, turnsLeft: cond.turnsLeft - 1 });
|
|
599
|
+
break;
|
|
600
|
+
case "regen":
|
|
601
|
+
const heal = Math.min(cond.healPerTurn, maxHp - hp);
|
|
602
|
+
if (heal > 0) {
|
|
603
|
+
hp = hp + heal;
|
|
604
|
+
newLog.push(`${targetName} regenerates ${heal} HP!`);
|
|
605
|
+
}
|
|
606
|
+
if (cond.turnsLeft > 1)
|
|
607
|
+
newConditions.push({ ...cond, turnsLeft: cond.turnsLeft - 1 });
|
|
608
|
+
break;
|
|
609
|
+
case "shield":
|
|
610
|
+
// Shield persists until broken by damage — not tick-based
|
|
611
|
+
newConditions.push(cond);
|
|
612
|
+
break;
|
|
613
|
+
case "buff":
|
|
614
|
+
case "debuff":
|
|
615
|
+
if (cond.turnsLeft > 1)
|
|
616
|
+
newConditions.push({ ...cond, turnsLeft: cond.turnsLeft - 1 });
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return { hp, conditions: newConditions, log: newLog, skipTurn };
|
|
621
|
+
}
|
|
622
|
+
/** Apply shield absorption — returns remaining damage and updated conditions */
|
|
623
|
+
function applyShield(damage, conditions) {
|
|
624
|
+
const newConds = [];
|
|
625
|
+
let remaining = damage;
|
|
626
|
+
for (const cond of conditions) {
|
|
627
|
+
if (cond.type === "shield" && remaining > 0) {
|
|
628
|
+
if (cond.amount > remaining) {
|
|
629
|
+
newConds.push({ ...cond, amount: cond.amount - remaining });
|
|
630
|
+
remaining = 0;
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
633
|
+
remaining -= cond.amount;
|
|
634
|
+
// shield broken, don't keep it
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
else {
|
|
638
|
+
newConds.push(cond);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return { damage: remaining, conditions: newConds };
|
|
642
|
+
}
|
|
643
|
+
/** Check if target has a specific condition */
|
|
644
|
+
function hasCondition(conditions, type) {
|
|
645
|
+
return conditions.some((c) => c.type === type);
|
|
646
|
+
}
|
|
647
|
+
/** Add a condition, respecting immunities */
|
|
648
|
+
function addCondition(conditions, cond, targetElement) {
|
|
649
|
+
if ((0, types_1.isImmune)(cond.type, targetElement))
|
|
650
|
+
return conditions;
|
|
651
|
+
return [...conditions, cond];
|
|
652
|
+
}
|
|
653
|
+
/** Get effective stat modifier from conditions (buff/debuff) */
|
|
654
|
+
function getConditionStatMod(conditions, stat) {
|
|
655
|
+
let mod = 0;
|
|
656
|
+
for (const c of conditions) {
|
|
657
|
+
if ((c.type === "buff" || c.type === "debuff") && c.stat === stat) {
|
|
658
|
+
mod += c.type === "buff" ? c.amount : -c.amount;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return mod;
|
|
662
|
+
}
|
|
663
|
+
// ─── Turn Advancement ───────────────────────────────────────
|
|
664
|
+
/** Called each tick to advance animation phases */
|
|
665
|
+
function tickCombat(combat) {
|
|
666
|
+
const ticks = combat.ticksInPhase + 1;
|
|
667
|
+
if (combat.phase === "player_animate" && ticks >= 2) {
|
|
668
|
+
if (combat.enemyStats.hp <= 0) {
|
|
669
|
+
return { ...combat, phase: "victory", ticksInPhase: 0, log: [...combat.log, `${combat.enemy.name} is defeated!`] };
|
|
670
|
+
}
|
|
671
|
+
// Process enemy conditions before enemy turn
|
|
672
|
+
const enemyProc = processConditions(combat.enemyStats.hp, combat.enemyStats.maxHp, combat.enemyConditions, combat.log, combat.enemy.name);
|
|
673
|
+
const updatedCombat = {
|
|
674
|
+
...combat,
|
|
675
|
+
enemyStats: { ...combat.enemyStats, hp: enemyProc.hp },
|
|
676
|
+
enemyConditions: enemyProc.conditions,
|
|
677
|
+
log: enemyProc.log,
|
|
678
|
+
};
|
|
679
|
+
if (enemyProc.hp <= 0) {
|
|
680
|
+
return { ...updatedCombat, phase: "victory", ticksInPhase: 0, log: [...enemyProc.log, `${combat.enemy.name} is defeated!`] };
|
|
681
|
+
}
|
|
682
|
+
if (enemyProc.skipTurn) {
|
|
683
|
+
// Charm: enemy attacks itself
|
|
684
|
+
if (hasCondition(combat.enemyConditions, "charm")) {
|
|
685
|
+
const selfDmg = Math.max(1, Math.floor(combat.enemyStats.atk * 0.5));
|
|
686
|
+
return {
|
|
687
|
+
...updatedCombat,
|
|
688
|
+
enemyStats: { ...updatedCombat.enemyStats, hp: Math.max(0, updatedCombat.enemyStats.hp - selfDmg) },
|
|
689
|
+
phase: "enemy_animate",
|
|
690
|
+
log: [...updatedCombat.log, `${combat.enemy.name} attacks itself for ${selfDmg} damage!`],
|
|
691
|
+
ticksInPhase: 0,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
return { ...updatedCombat, phase: "enemy_animate", ticksInPhase: 0 };
|
|
695
|
+
}
|
|
696
|
+
return { ...updatedCombat, phase: "enemy_turn", ticksInPhase: 0 };
|
|
697
|
+
}
|
|
698
|
+
if (combat.phase === "enemy_turn") {
|
|
699
|
+
return enemyTurn(combat);
|
|
700
|
+
}
|
|
701
|
+
if (combat.phase === "enemy_animate" && ticks >= 2) {
|
|
702
|
+
// Process player conditions before player turn
|
|
703
|
+
const playerProc = processConditions(combat.playerStats.hp, combat.playerStats.maxHp, combat.playerConditions, combat.log, "You");
|
|
704
|
+
const turnCount = combat.turnCount + 1;
|
|
705
|
+
// Energy regen: +1 every 3 turns
|
|
706
|
+
const energyRegen = turnCount % 3 === 0 ? 1 : 0;
|
|
707
|
+
const newEnergy = Math.min(combat.maxCombatEnergy, combat.combatEnergy + energyRegen);
|
|
708
|
+
let regenLog = playerProc.log;
|
|
709
|
+
if (energyRegen > 0)
|
|
710
|
+
regenLog = [...regenLog, "+1 energy recovered!"];
|
|
711
|
+
if (playerProc.hp <= 0) {
|
|
712
|
+
return {
|
|
713
|
+
...combat, phase: "defeat", ticksInPhase: 0,
|
|
714
|
+
playerStats: { ...combat.playerStats, hp: 0 },
|
|
715
|
+
playerConditions: playerProc.conditions,
|
|
716
|
+
log: [...regenLog, "Knocked out!"],
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
return {
|
|
720
|
+
...combat,
|
|
721
|
+
phase: "player_turn",
|
|
722
|
+
ticksInPhase: 0,
|
|
723
|
+
turnCount,
|
|
724
|
+
combatEnergy: newEnergy,
|
|
725
|
+
playerStats: { ...combat.playerStats, hp: playerProc.hp },
|
|
726
|
+
playerConditions: playerProc.conditions,
|
|
727
|
+
log: regenLog,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
return { ...combat, ticksInPhase: ticks };
|
|
731
|
+
}
|
|
732
|
+
//# sourceMappingURL=combat.js.map
|