pokemon-io-core 0.0.118 → 0.0.120
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/dist/core/battleState.d.ts +1 -1
- package/dist/core/engine.d.ts +53 -0
- package/dist/core/engine.js +1046 -0
- package/dist/core/fighters.d.ts +1 -1
- package/dist/core/types.d.ts +1 -1
- package/dist/engine/combat/damage.js +9 -9
- package/dist/engine/engine.d.ts +53 -0
- package/dist/engine/engine.js +1046 -0
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/pokemonBattleService.d.ts +6 -0
- package/dist/engine/pokemonBattleService.js +32 -0
- package/dist/engine/turn/index.d.ts +1 -0
- package/dist/engine/turn/index.js +1 -0
- package/dist/skins/pokemon/data/pokemon-moves.json +609 -0
- package/dist/skins/pokemon/moves.js +0 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1046 @@
|
|
|
1
|
+
// src/domain/combat/engine/battleEngine.ts
|
|
2
|
+
// ------------------------------------------------------
|
|
3
|
+
// Debug helper
|
|
4
|
+
// ------------------------------------------------------
|
|
5
|
+
const DEBUG_BATTLE = true;
|
|
6
|
+
const dbg = (...args) => {
|
|
7
|
+
if (!DEBUG_BATTLE)
|
|
8
|
+
return;
|
|
9
|
+
// eslint-disable-next-line no-console
|
|
10
|
+
console.log("[BATTLE]", ...args);
|
|
11
|
+
};
|
|
12
|
+
const DEFAULT_RULES = {
|
|
13
|
+
baseCritChance: 0.05,
|
|
14
|
+
critPerStat: 0.002,
|
|
15
|
+
critMultiplier: 1.1,
|
|
16
|
+
stabMultiplier: 1.1,
|
|
17
|
+
randomMinDamageFactor: 0.8,
|
|
18
|
+
randomMaxDamageFactor: 1.0,
|
|
19
|
+
};
|
|
20
|
+
const buildMap = (list) => list.reduce((acc, item) => {
|
|
21
|
+
acc[item.id] = item;
|
|
22
|
+
return acc;
|
|
23
|
+
}, {});
|
|
24
|
+
const cloneStats = (stats) => ({
|
|
25
|
+
offense: stats.offense,
|
|
26
|
+
defense: stats.defense,
|
|
27
|
+
speed: stats.speed,
|
|
28
|
+
crit: stats.crit,
|
|
29
|
+
});
|
|
30
|
+
// ------------------------------------------------------
|
|
31
|
+
// Creación de estado inicial
|
|
32
|
+
// ------------------------------------------------------
|
|
33
|
+
const createBattleFighter = (cfg) => {
|
|
34
|
+
if (cfg.moves.length !== 4) {
|
|
35
|
+
throw new Error("Each fighter must have exactly 4 moves in MVP");
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
fighterId: cfg.fighter.id,
|
|
39
|
+
classId: cfg.fighter.classId,
|
|
40
|
+
maxHp: cfg.maxHp,
|
|
41
|
+
currentHp: cfg.maxHp,
|
|
42
|
+
baseStats: cloneStats(cfg.fighter.baseStats),
|
|
43
|
+
effectiveStats: cloneStats(cfg.fighter.baseStats),
|
|
44
|
+
moves: cfg.moves.map((move) => ({
|
|
45
|
+
moveId: move.id,
|
|
46
|
+
currentPP: move.maxPP,
|
|
47
|
+
})),
|
|
48
|
+
amuletId: cfg.amulet ? cfg.amulet.id : null,
|
|
49
|
+
statuses: [],
|
|
50
|
+
isAlive: true,
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
const recomputeEffectiveStatsForFighter = (state, fighter) => {
|
|
54
|
+
// Partimos de base
|
|
55
|
+
let eff = { ...fighter.baseStats };
|
|
56
|
+
// 1) Aplicar estados
|
|
57
|
+
for (const st of fighter.statuses) {
|
|
58
|
+
const def = state.runtime.statusesById[st.statusId];
|
|
59
|
+
if (!def)
|
|
60
|
+
continue;
|
|
61
|
+
const stacks = st.stacks === 2 ? 2 : 1;
|
|
62
|
+
for (const effDef of def.effectsPerStack) {
|
|
63
|
+
const totalStacks = stacks; // podrías multiplicar efectos por stacks
|
|
64
|
+
switch (effDef.kind) {
|
|
65
|
+
case "modify_stats":
|
|
66
|
+
eff.offense += (effDef.offenseDelta ?? 0) * totalStacks;
|
|
67
|
+
eff.defense += (effDef.defenseDelta ?? 0) * totalStacks;
|
|
68
|
+
eff.speed += (effDef.speedDelta ?? 0) * totalStacks;
|
|
69
|
+
eff.crit += (effDef.critDelta ?? 0) * totalStacks;
|
|
70
|
+
break;
|
|
71
|
+
case "modify_effective_speed":
|
|
72
|
+
eff.speed = Math.floor(eff.speed * Math.pow(effDef.multiplier, totalStacks));
|
|
73
|
+
break;
|
|
74
|
+
default:
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// 2) Aquí podrías aplicar amuletos, buffs temporales o lo que quieras
|
|
80
|
+
return {
|
|
81
|
+
...fighter,
|
|
82
|
+
effectiveStats: eff,
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
const createPlayerBattleState = (cfg) => {
|
|
86
|
+
const team = cfg.fighters.map((fCfg) => createBattleFighter(fCfg));
|
|
87
|
+
const inventory = cfg.items.map((item) => ({
|
|
88
|
+
itemId: item.id,
|
|
89
|
+
usesRemaining: item.maxUses,
|
|
90
|
+
}));
|
|
91
|
+
return {
|
|
92
|
+
fighterTeam: team,
|
|
93
|
+
activeIndex: 0,
|
|
94
|
+
inventory,
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
export const createInitialBattleState = (config) => {
|
|
98
|
+
const rules = {
|
|
99
|
+
...DEFAULT_RULES,
|
|
100
|
+
...config.rules,
|
|
101
|
+
};
|
|
102
|
+
const runtime = {
|
|
103
|
+
rules,
|
|
104
|
+
typesById: buildMap(config.types),
|
|
105
|
+
movesById: buildMap(config.moves),
|
|
106
|
+
itemsById: buildMap(config.items),
|
|
107
|
+
amuletsById: buildMap(config.amulets),
|
|
108
|
+
statusesById: buildMap(config.statuses),
|
|
109
|
+
typeEffectiveness: config.typeEffectiveness,
|
|
110
|
+
};
|
|
111
|
+
const player1 = createPlayerBattleState(config.player1);
|
|
112
|
+
const player2 = createPlayerBattleState(config.player2);
|
|
113
|
+
const state = {
|
|
114
|
+
turnNumber: 1,
|
|
115
|
+
player1,
|
|
116
|
+
player2,
|
|
117
|
+
runtime,
|
|
118
|
+
forcedSwitch: null,
|
|
119
|
+
};
|
|
120
|
+
return state;
|
|
121
|
+
};
|
|
122
|
+
// ------------------------------------------------------
|
|
123
|
+
// Helpers
|
|
124
|
+
// ------------------------------------------------------
|
|
125
|
+
const getActiveFighter = (player) => player.fighterTeam[player.activeIndex];
|
|
126
|
+
const getOpponentAndSelf = (state, playerKey) => {
|
|
127
|
+
const selfPlayer = playerKey === "player1" ? state.player1 : state.player2;
|
|
128
|
+
const oppPlayer = playerKey === "player1" ? state.player2 : state.player1;
|
|
129
|
+
return {
|
|
130
|
+
self: getActiveFighter(selfPlayer),
|
|
131
|
+
opponent: getActiveFighter(oppPlayer),
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
const getPlayersAndActives = (state, playerKey) => {
|
|
135
|
+
const selfPlayer = playerKey === "player1" ? state.player1 : state.player2;
|
|
136
|
+
const oppPlayer = playerKey === "player1" ? state.player2 : state.player1;
|
|
137
|
+
return {
|
|
138
|
+
selfPlayer,
|
|
139
|
+
oppPlayer,
|
|
140
|
+
self: getActiveFighter(selfPlayer),
|
|
141
|
+
opponent: getActiveFighter(oppPlayer),
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
const getTypeEffectiveness = (state, attackerTypeId, defenderTypeId) => {
|
|
145
|
+
const matrix = state.runtime.typeEffectiveness;
|
|
146
|
+
const row = matrix[attackerTypeId];
|
|
147
|
+
if (!row)
|
|
148
|
+
return 1;
|
|
149
|
+
return row[defenderTypeId] ?? 1;
|
|
150
|
+
};
|
|
151
|
+
const randomInRange = (min, max) => Math.random() * (max - min) + min;
|
|
152
|
+
const chance = (probability) => {
|
|
153
|
+
if (probability <= 0)
|
|
154
|
+
return false;
|
|
155
|
+
if (probability >= 1)
|
|
156
|
+
return true;
|
|
157
|
+
return Math.random() < probability;
|
|
158
|
+
};
|
|
159
|
+
// ------------------------------------------------------
|
|
160
|
+
// Cálculo de críticos y daño
|
|
161
|
+
// ------------------------------------------------------
|
|
162
|
+
const computeCritChance = (rules, critStat) => {
|
|
163
|
+
const raw = rules.baseCritChance + critStat * rules.critPerStat;
|
|
164
|
+
return Math.max(0, Math.min(1, raw));
|
|
165
|
+
};
|
|
166
|
+
const computeDamage = (input) => {
|
|
167
|
+
dbg("🧨 computeDamage", { input });
|
|
168
|
+
const { state, attacker, defender, moveTypeId, basePower, isCritical } = input;
|
|
169
|
+
const rules = state.runtime.rules;
|
|
170
|
+
const attackerOff = attacker.effectiveStats.offense;
|
|
171
|
+
const defenderDef = defender.effectiveStats.defense;
|
|
172
|
+
const typeEffectiveness = getTypeEffectiveness(state, moveTypeId, defender.classId);
|
|
173
|
+
if (typeEffectiveness === 0) {
|
|
174
|
+
return {
|
|
175
|
+
damage: 0,
|
|
176
|
+
effectiveness: 0,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const stabMultiplier = attacker.classId === moveTypeId ? rules.stabMultiplier : 1;
|
|
180
|
+
const critMultiplier = isCritical ? rules.critMultiplier : 1;
|
|
181
|
+
const randomFactor = randomInRange(rules.randomMinDamageFactor, rules.randomMaxDamageFactor);
|
|
182
|
+
const rawDamage = basePower *
|
|
183
|
+
(attackerOff / Math.max(1, defenderDef)) *
|
|
184
|
+
typeEffectiveness *
|
|
185
|
+
stabMultiplier *
|
|
186
|
+
critMultiplier *
|
|
187
|
+
randomFactor;
|
|
188
|
+
dbg("🧨 computeDamage", `
|
|
189
|
+
basePower(${basePower}) *
|
|
190
|
+
(attackerOff(${attackerOff}) / Math.max(1, defenderDef(${defenderDef}))) = ${attackerOff / Math.max(1, defenderDef)}*
|
|
191
|
+
typeEffectiveness(${typeEffectiveness}) *
|
|
192
|
+
stabMultiplier(${stabMultiplier}) *
|
|
193
|
+
critMultiplier(${critMultiplier}) *
|
|
194
|
+
randomFactor(${randomFactor}) = ${rawDamage}
|
|
195
|
+
`);
|
|
196
|
+
const finalDamage = Math.max(1, Math.floor(rawDamage));
|
|
197
|
+
dbg("🧨 computeDamage", `
|
|
198
|
+
finalDamage(${finalDamage}) = Math.max(1, Math.floor(rawDamage))
|
|
199
|
+
`);
|
|
200
|
+
return {
|
|
201
|
+
damage: finalDamage,
|
|
202
|
+
effectiveness: typeEffectiveness,
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
export const applyForcedSwitchChoice = (state, targetPlayer, newIndex) => {
|
|
206
|
+
const forced = state.forcedSwitch;
|
|
207
|
+
if (!forced)
|
|
208
|
+
return { state, events: [] };
|
|
209
|
+
if (forced.targetPlayer !== targetPlayer)
|
|
210
|
+
return { state, events: [] };
|
|
211
|
+
const self = targetPlayer === "player1" ? state.player1 : state.player2;
|
|
212
|
+
if (newIndex < 0 || newIndex >= self.fighterTeam.length)
|
|
213
|
+
return { state, events: [] };
|
|
214
|
+
const fromIndex = self.activeIndex;
|
|
215
|
+
if (newIndex === fromIndex)
|
|
216
|
+
return { state, events: [] };
|
|
217
|
+
const toFighter = self.fighterTeam[newIndex];
|
|
218
|
+
if (!toFighter || !toFighter.isAlive || toFighter.currentHp <= 0)
|
|
219
|
+
return { state, events: [] };
|
|
220
|
+
const fromFighter = self.fighterTeam[fromIndex];
|
|
221
|
+
const events = [
|
|
222
|
+
{
|
|
223
|
+
...createBaseEvent(state.turnNumber, "fighter_switched", `El entrenador retira a ${fromFighter.fighterId} y manda a ${toFighter.fighterId}`),
|
|
224
|
+
fromFighterId: fromFighter.fighterId,
|
|
225
|
+
toFighterId: toFighter.fighterId,
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
...createBaseEvent(state.turnNumber, "turn_end", `Termina el turno ${state.turnNumber}`),
|
|
229
|
+
},
|
|
230
|
+
];
|
|
231
|
+
const switchedState = targetPlayer === "player1"
|
|
232
|
+
? {
|
|
233
|
+
...state,
|
|
234
|
+
forcedSwitch: null,
|
|
235
|
+
player1: { ...state.player1, activeIndex: newIndex },
|
|
236
|
+
}
|
|
237
|
+
: {
|
|
238
|
+
...state,
|
|
239
|
+
forcedSwitch: null,
|
|
240
|
+
player2: { ...state.player2, activeIndex: newIndex },
|
|
241
|
+
};
|
|
242
|
+
const nextState = {
|
|
243
|
+
...switchedState,
|
|
244
|
+
turnNumber: switchedState.turnNumber + 1,
|
|
245
|
+
};
|
|
246
|
+
return { state: nextState, events };
|
|
247
|
+
};
|
|
248
|
+
let eventCounter = 0;
|
|
249
|
+
const nextEventId = () => {
|
|
250
|
+
eventCounter += 1;
|
|
251
|
+
return `evt_${eventCounter}`;
|
|
252
|
+
};
|
|
253
|
+
const createBaseEvent = (turnNumber, kind, message) => ({
|
|
254
|
+
id: nextEventId(),
|
|
255
|
+
kind,
|
|
256
|
+
turnNumber,
|
|
257
|
+
message,
|
|
258
|
+
timestamp: Date.now(),
|
|
259
|
+
});
|
|
260
|
+
const applyDamageToFighter = (state, defender, amount, actorId, isCritical) => {
|
|
261
|
+
const events = [];
|
|
262
|
+
const newHp = Math.max(0, defender.currentHp - amount);
|
|
263
|
+
const updatedDefender = {
|
|
264
|
+
...defender,
|
|
265
|
+
currentHp: newHp,
|
|
266
|
+
isAlive: newHp > 0,
|
|
267
|
+
};
|
|
268
|
+
events.push({
|
|
269
|
+
...createBaseEvent(state.turnNumber, "damage", `${actorId} inflige ${amount} de daño a ${defender.fighterId}`),
|
|
270
|
+
actorId,
|
|
271
|
+
targetId: defender.fighterId,
|
|
272
|
+
amount,
|
|
273
|
+
isCritical,
|
|
274
|
+
});
|
|
275
|
+
if (!updatedDefender.isAlive) {
|
|
276
|
+
events.push({
|
|
277
|
+
...createBaseEvent(state.turnNumber, "fighter_fainted", `${defender.fighterId} ha caído`),
|
|
278
|
+
fighterId: defender.fighterId,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return { updatedDefender, events };
|
|
282
|
+
};
|
|
283
|
+
// ------------------------------------------------------
|
|
284
|
+
// apply_status
|
|
285
|
+
// ------------------------------------------------------
|
|
286
|
+
const applyStatusToFighter = (state, target, statusId) => {
|
|
287
|
+
const def = state.runtime.statusesById[statusId];
|
|
288
|
+
dbg("APPLY_STATUS_FN", {
|
|
289
|
+
targetId: target.fighterId,
|
|
290
|
+
statusId,
|
|
291
|
+
hasDef: !!def,
|
|
292
|
+
currentStatuses: target.statuses,
|
|
293
|
+
});
|
|
294
|
+
if (!def)
|
|
295
|
+
return { updated: target, events: [] };
|
|
296
|
+
const events = [];
|
|
297
|
+
const existing = target.statuses.find((s) => s.statusId === statusId);
|
|
298
|
+
const duration = Math.floor(randomInRange(def.minDurationTurns, def.maxDurationTurns + 1)) ||
|
|
299
|
+
def.minDurationTurns;
|
|
300
|
+
let newStatuses = [...target.statuses];
|
|
301
|
+
if (!existing) {
|
|
302
|
+
newStatuses.push({
|
|
303
|
+
statusId,
|
|
304
|
+
stacks: 1,
|
|
305
|
+
remainingTurns: duration,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
else if (existing.stacks === 1) {
|
|
309
|
+
newStatuses = newStatuses.map((s) => s.statusId === statusId
|
|
310
|
+
? { ...s, stacks: 2, remainingTurns: duration }
|
|
311
|
+
: s);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
newStatuses = newStatuses.map((s) => s.statusId === statusId ? { ...s, remainingTurns: duration } : s);
|
|
315
|
+
}
|
|
316
|
+
dbg("STATUS_APPLY", {
|
|
317
|
+
fighterId: target.fighterId,
|
|
318
|
+
statusId,
|
|
319
|
+
duration,
|
|
320
|
+
prevStacks: existing?.stacks ?? 0,
|
|
321
|
+
});
|
|
322
|
+
events.push({
|
|
323
|
+
...createBaseEvent(state.turnNumber, "status_applied", `Se aplica ${statusId} a ${target.fighterId}`),
|
|
324
|
+
targetId: target.fighterId,
|
|
325
|
+
statusId,
|
|
326
|
+
});
|
|
327
|
+
return {
|
|
328
|
+
updated: { ...target, statuses: newStatuses },
|
|
329
|
+
events,
|
|
330
|
+
};
|
|
331
|
+
};
|
|
332
|
+
// ------------------------------------------------------
|
|
333
|
+
// clear_status
|
|
334
|
+
// ------------------------------------------------------
|
|
335
|
+
const clearStatusFromFighter = (state, target, effect) => {
|
|
336
|
+
const { statusIds, kinds, clearAll } = effect;
|
|
337
|
+
const events = [];
|
|
338
|
+
const before = target.statuses;
|
|
339
|
+
let after = before;
|
|
340
|
+
if (clearAll) {
|
|
341
|
+
after = [];
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
after = before.filter((st) => {
|
|
345
|
+
const def = state.runtime.statusesById[st.statusId];
|
|
346
|
+
if (statusIds && statusIds.includes(st.statusId)) {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
if (kinds && def && kinds.includes(def.kind)) {
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
return true;
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
if (after.length === before.length) {
|
|
356
|
+
// nada que limpiar
|
|
357
|
+
return { updated: target, events };
|
|
358
|
+
}
|
|
359
|
+
const removed = before
|
|
360
|
+
.filter((old) => !after.some((st) => st.statusId === old.statusId))
|
|
361
|
+
.map((st) => st.statusId);
|
|
362
|
+
dbg("STATUS_CLEAR", {
|
|
363
|
+
fighterId: target.fighterId,
|
|
364
|
+
cleared: removed,
|
|
365
|
+
clearAll: !!clearAll,
|
|
366
|
+
});
|
|
367
|
+
events.push({
|
|
368
|
+
...createBaseEvent(state.turnNumber, "status_cleared", `Se limpian estados (${removed.join(", ")}) de ${target.fighterId}`),
|
|
369
|
+
targetId: target.fighterId,
|
|
370
|
+
statusIds: removed,
|
|
371
|
+
});
|
|
372
|
+
return {
|
|
373
|
+
updated: {
|
|
374
|
+
...target,
|
|
375
|
+
statuses: after,
|
|
376
|
+
},
|
|
377
|
+
events,
|
|
378
|
+
};
|
|
379
|
+
};
|
|
380
|
+
// ------------------------------------------------------
|
|
381
|
+
// heal
|
|
382
|
+
// ------------------------------------------------------
|
|
383
|
+
const applyHealToFighter = (state, target, amount, sourceId) => {
|
|
384
|
+
const newHp = Math.min(target.maxHp, target.currentHp + amount);
|
|
385
|
+
const healed = newHp - target.currentHp;
|
|
386
|
+
const updated = {
|
|
387
|
+
...target,
|
|
388
|
+
currentHp: newHp,
|
|
389
|
+
isAlive: newHp > 0,
|
|
390
|
+
};
|
|
391
|
+
const events = [];
|
|
392
|
+
dbg("HEAL", {
|
|
393
|
+
sourceId,
|
|
394
|
+
targetId: target.fighterId,
|
|
395
|
+
healed,
|
|
396
|
+
newHp,
|
|
397
|
+
});
|
|
398
|
+
events.push({
|
|
399
|
+
...createBaseEvent(state.turnNumber, "heal", `${sourceId} cura ${healed} a ${target.fighterId}`),
|
|
400
|
+
actorId: sourceId,
|
|
401
|
+
targetId: target.fighterId,
|
|
402
|
+
amount: healed,
|
|
403
|
+
});
|
|
404
|
+
return { updated, events };
|
|
405
|
+
};
|
|
406
|
+
// ------------------------------------------------------
|
|
407
|
+
// Target por defecto y resolución de target
|
|
408
|
+
// ------------------------------------------------------
|
|
409
|
+
const defaultTargetForKind = (kind) => {
|
|
410
|
+
switch (kind) {
|
|
411
|
+
case "damage":
|
|
412
|
+
case "apply_status":
|
|
413
|
+
return "enemy";
|
|
414
|
+
case "heal":
|
|
415
|
+
case "clear_status":
|
|
416
|
+
case "modify_stats":
|
|
417
|
+
case "modify_hit_chance":
|
|
418
|
+
case "modify_crit_chance":
|
|
419
|
+
case "modify_priority":
|
|
420
|
+
case "modify_effective_speed":
|
|
421
|
+
case "shield":
|
|
422
|
+
return "self";
|
|
423
|
+
default:
|
|
424
|
+
return "self";
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
const resolveEffectTarget = (eff, actor, target) => {
|
|
428
|
+
const t = eff.target ?? defaultTargetForKind(eff.kind);
|
|
429
|
+
const isSelf = t === "self";
|
|
430
|
+
return {
|
|
431
|
+
primary: isSelf ? actor : target,
|
|
432
|
+
isSelf,
|
|
433
|
+
};
|
|
434
|
+
};
|
|
435
|
+
// ------------------------------------------------------
|
|
436
|
+
// Motor de efectos genérico (move + item)
|
|
437
|
+
// ------------------------------------------------------
|
|
438
|
+
const applyEffectsOnTarget = (state, actor, target, moveTypeId, isCritical, effects) => {
|
|
439
|
+
dbg("applyEffectsOnTarget", {
|
|
440
|
+
actorId: actor.fighterId,
|
|
441
|
+
targetId: target.fighterId,
|
|
442
|
+
moveTypeId,
|
|
443
|
+
isCritical,
|
|
444
|
+
effects,
|
|
445
|
+
});
|
|
446
|
+
let currentActor = actor;
|
|
447
|
+
let currentTarget = target;
|
|
448
|
+
const events = [];
|
|
449
|
+
dbg("APPLY_EFFECTS_START", {
|
|
450
|
+
actorId: actor.fighterId,
|
|
451
|
+
targetId: target.fighterId,
|
|
452
|
+
moveTypeId,
|
|
453
|
+
isCritical,
|
|
454
|
+
effectsCount: effects?.length ?? 0,
|
|
455
|
+
});
|
|
456
|
+
for (const eff of effects) {
|
|
457
|
+
dbg("EFFECT_LOOP", eff);
|
|
458
|
+
switch (eff.kind) {
|
|
459
|
+
case "heal": {
|
|
460
|
+
dbg("EFFECT_HEAL_START", {
|
|
461
|
+
amount: eff.amount,
|
|
462
|
+
targetDefault: eff.target,
|
|
463
|
+
});
|
|
464
|
+
const { primary, isSelf } = resolveEffectTarget(eff, currentActor, currentTarget);
|
|
465
|
+
const res = applyHealToFighter(state, primary, eff.amount, actor.fighterId);
|
|
466
|
+
if (isSelf)
|
|
467
|
+
currentActor = res.updated;
|
|
468
|
+
else
|
|
469
|
+
currentTarget = res.updated;
|
|
470
|
+
events.push(...res.events);
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
case "apply_status": {
|
|
474
|
+
dbg("EFFECT_APPLY_STATUS_START", {
|
|
475
|
+
statusId: eff.statusId,
|
|
476
|
+
targetDefault: eff.target,
|
|
477
|
+
});
|
|
478
|
+
const { primary, isSelf } = resolveEffectTarget(eff, currentActor, currentTarget);
|
|
479
|
+
const res = applyStatusToFighter(state, primary, eff.statusId);
|
|
480
|
+
if (isSelf)
|
|
481
|
+
currentActor = res.updated;
|
|
482
|
+
else
|
|
483
|
+
currentTarget = res.updated;
|
|
484
|
+
events.push(...res.events);
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
case "clear_status": {
|
|
488
|
+
dbg("debemos eliminar status");
|
|
489
|
+
const { primary, isSelf } = resolveEffectTarget(eff, currentActor, currentTarget);
|
|
490
|
+
const res = clearStatusFromFighter(state, primary, eff);
|
|
491
|
+
if (isSelf)
|
|
492
|
+
currentActor = res.updated;
|
|
493
|
+
else
|
|
494
|
+
currentTarget = res.updated;
|
|
495
|
+
events.push(...res.events);
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
case "damage": {
|
|
499
|
+
dbg("debemos hacer daño");
|
|
500
|
+
const { primary, isSelf } = resolveEffectTarget(eff, currentActor, currentTarget);
|
|
501
|
+
let totalDamage = 0;
|
|
502
|
+
// Daño de fórmula (stats + tipo + crit…)
|
|
503
|
+
if (eff.basePower && moveTypeId) {
|
|
504
|
+
const { damage } = computeDamage({
|
|
505
|
+
state,
|
|
506
|
+
attacker: currentActor,
|
|
507
|
+
defender: primary,
|
|
508
|
+
moveTypeId,
|
|
509
|
+
basePower: eff.basePower,
|
|
510
|
+
isCritical,
|
|
511
|
+
});
|
|
512
|
+
totalDamage += damage;
|
|
513
|
+
}
|
|
514
|
+
// Daño plano adicional
|
|
515
|
+
if (eff.flatAmount && eff.flatAmount > 0) {
|
|
516
|
+
totalDamage += eff.flatAmount;
|
|
517
|
+
}
|
|
518
|
+
if (totalDamage > 0) {
|
|
519
|
+
const res = applyDamageToFighter(state, primary, totalDamage, currentActor.fighterId, isCritical);
|
|
520
|
+
if (isSelf)
|
|
521
|
+
currentActor = res.updatedDefender;
|
|
522
|
+
else
|
|
523
|
+
currentTarget = res.updatedDefender;
|
|
524
|
+
events.push(...res.events);
|
|
525
|
+
}
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
// TODO: conectar shield, modify_stats, etc.
|
|
529
|
+
default:
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return { actor: currentActor, target: currentTarget, events };
|
|
534
|
+
};
|
|
535
|
+
const getMovePriorityAndSpeed = (state, playerKey, action) => {
|
|
536
|
+
const { self } = getOpponentAndSelf(state, playerKey);
|
|
537
|
+
if (action.kind === "no_action") {
|
|
538
|
+
return { priority: 0, speed: 0 };
|
|
539
|
+
}
|
|
540
|
+
if (action.kind === "use_move") {
|
|
541
|
+
const moveEntry = self.moves[action.moveIndex];
|
|
542
|
+
if (!moveEntry) {
|
|
543
|
+
return { priority: 0, speed: self.effectiveStats.speed };
|
|
544
|
+
}
|
|
545
|
+
const moveDef = state.runtime.movesById[moveEntry.moveId];
|
|
546
|
+
const priority = moveDef?.priority ?? 0;
|
|
547
|
+
return {
|
|
548
|
+
priority,
|
|
549
|
+
speed: self.effectiveStats.speed,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
if (action.kind === "switch_fighter") {
|
|
553
|
+
return {
|
|
554
|
+
priority: 10, // prioridad especial (ajústalo si quieres)
|
|
555
|
+
speed: self.effectiveStats.speed,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
// Items y switch: prioridad base 0 por ahora
|
|
559
|
+
return {
|
|
560
|
+
priority: 0,
|
|
561
|
+
speed: self.effectiveStats.speed,
|
|
562
|
+
};
|
|
563
|
+
};
|
|
564
|
+
const resolveSwitchFighter = (state, playerKey, action) => {
|
|
565
|
+
if (action.kind !== "switch_fighter")
|
|
566
|
+
return { state, events: [] };
|
|
567
|
+
const events = [];
|
|
568
|
+
const selfPlayer = playerKey === "player1" ? state.player1 : state.player2;
|
|
569
|
+
const fromIndex = selfPlayer.activeIndex;
|
|
570
|
+
const toIndex = action.newIndex;
|
|
571
|
+
// validaciones
|
|
572
|
+
if (toIndex < 0 || toIndex >= selfPlayer.fighterTeam.length) {
|
|
573
|
+
return { state, events };
|
|
574
|
+
}
|
|
575
|
+
if (toIndex === fromIndex) {
|
|
576
|
+
return { state, events };
|
|
577
|
+
}
|
|
578
|
+
const target = selfPlayer.fighterTeam[toIndex];
|
|
579
|
+
if (!target || !target.isAlive || target.currentHp <= 0) {
|
|
580
|
+
return { state, events };
|
|
581
|
+
}
|
|
582
|
+
const fromFighter = selfPlayer.fighterTeam[fromIndex];
|
|
583
|
+
events.push({
|
|
584
|
+
...createBaseEvent(state.turnNumber, "fighter_switched", `El entrenador retira a ${fromFighter.fighterId} y manda a ${target.fighterId}`),
|
|
585
|
+
fromFighterId: fromFighter.fighterId,
|
|
586
|
+
toFighterId: target.fighterId,
|
|
587
|
+
toHp: target.currentHp,
|
|
588
|
+
toStatuses: (target.statuses ?? []).map((st) => ({
|
|
589
|
+
statusId: st.statusId,
|
|
590
|
+
stacks: st.stacks,
|
|
591
|
+
remainingTurns: st.remainingTurns,
|
|
592
|
+
})),
|
|
593
|
+
});
|
|
594
|
+
// actualizar activeIndex
|
|
595
|
+
if (playerKey === "player1") {
|
|
596
|
+
return {
|
|
597
|
+
state: {
|
|
598
|
+
...state,
|
|
599
|
+
player1: {
|
|
600
|
+
...state.player1,
|
|
601
|
+
activeIndex: toIndex,
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
events,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
return {
|
|
608
|
+
state: {
|
|
609
|
+
...state,
|
|
610
|
+
player2: {
|
|
611
|
+
...state.player2,
|
|
612
|
+
activeIndex: toIndex,
|
|
613
|
+
},
|
|
614
|
+
},
|
|
615
|
+
events,
|
|
616
|
+
};
|
|
617
|
+
};
|
|
618
|
+
// ------------------------------------------------------
|
|
619
|
+
// use_move
|
|
620
|
+
// ------------------------------------------------------
|
|
621
|
+
const resolveDamageMove = (state, playerKey, action) => {
|
|
622
|
+
if (action.kind !== "use_move") {
|
|
623
|
+
return { state, events: [] };
|
|
624
|
+
}
|
|
625
|
+
const { self, opponent } = getOpponentAndSelf(state, playerKey);
|
|
626
|
+
const events = [];
|
|
627
|
+
const moveSlot = self.moves[action.moveIndex];
|
|
628
|
+
dbg("use_move: slot", moveSlot);
|
|
629
|
+
if (!moveSlot) {
|
|
630
|
+
dbg("use_move: empty slot", { playerKey, moveIndex: action.moveIndex });
|
|
631
|
+
return { state, events };
|
|
632
|
+
}
|
|
633
|
+
const move = state.runtime.movesById[moveSlot.moveId];
|
|
634
|
+
if (!move) {
|
|
635
|
+
dbg("use_move: move definition not found", {
|
|
636
|
+
playerKey,
|
|
637
|
+
moveId: moveSlot.moveId,
|
|
638
|
+
});
|
|
639
|
+
return { state, events };
|
|
640
|
+
}
|
|
641
|
+
if (moveSlot.currentPP <= 0) {
|
|
642
|
+
dbg("use_move: no PP", {
|
|
643
|
+
playerKey,
|
|
644
|
+
fighterId: self.fighterId,
|
|
645
|
+
moveId: move.id,
|
|
646
|
+
});
|
|
647
|
+
return { state, events };
|
|
648
|
+
}
|
|
649
|
+
events.push({
|
|
650
|
+
...createBaseEvent(state.turnNumber, "action_declared", `${self.fighterId} usó ${move.name}`),
|
|
651
|
+
actorId: self.fighterId,
|
|
652
|
+
});
|
|
653
|
+
const accuracy = move.accuracy ?? 100;
|
|
654
|
+
const hitRoll = randomInRange(0, 100);
|
|
655
|
+
const hit = hitRoll < accuracy;
|
|
656
|
+
const updatedMoves = self.moves.map((m, idx) => idx === action.moveIndex
|
|
657
|
+
? { ...m, currentPP: Math.max(0, m.currentPP - 1) }
|
|
658
|
+
: m);
|
|
659
|
+
let updatedSelf = { ...self, moves: updatedMoves };
|
|
660
|
+
let updatedOpponent = { ...opponent };
|
|
661
|
+
if (!hit) {
|
|
662
|
+
dbg("MISS", {
|
|
663
|
+
fighterId: self.fighterId,
|
|
664
|
+
moveId: move.id,
|
|
665
|
+
moveName: move.name,
|
|
666
|
+
hitRoll,
|
|
667
|
+
accuracy,
|
|
668
|
+
});
|
|
669
|
+
events.push({
|
|
670
|
+
...createBaseEvent(state.turnNumber, "move_miss", `${self.fighterId} falla ${move.name}`),
|
|
671
|
+
actorId: self.fighterId,
|
|
672
|
+
moveId: move.id,
|
|
673
|
+
targetId: opponent.fighterId,
|
|
674
|
+
});
|
|
675
|
+
const newState = updateFightersInState(state, playerKey, updatedSelf, updatedOpponent);
|
|
676
|
+
return { state: newState, events };
|
|
677
|
+
}
|
|
678
|
+
const critChance = computeCritChance(state.runtime.rules, updatedSelf.effectiveStats.crit);
|
|
679
|
+
const isCritical = chance(critChance);
|
|
680
|
+
const effectiveness = getTypeEffectiveness(state, move.typeId, updatedOpponent.classId);
|
|
681
|
+
dbg("HIT", {
|
|
682
|
+
fighterId: self.fighterId,
|
|
683
|
+
moveId: move.id,
|
|
684
|
+
moveName: move.name,
|
|
685
|
+
isCritical,
|
|
686
|
+
effectiveness,
|
|
687
|
+
defenderBeforeHp: opponent.currentHp,
|
|
688
|
+
});
|
|
689
|
+
events.push({
|
|
690
|
+
...createBaseEvent(state.turnNumber, "move_hit", `${self.fighterId} acierta ${move.name}`),
|
|
691
|
+
actorId: self.fighterId,
|
|
692
|
+
moveId: move.id,
|
|
693
|
+
targetId: opponent.fighterId,
|
|
694
|
+
isCritical,
|
|
695
|
+
effectiveness,
|
|
696
|
+
});
|
|
697
|
+
dbg("MOVE_EFFECTS", {
|
|
698
|
+
moveId: move.id,
|
|
699
|
+
moveName: move.name,
|
|
700
|
+
effects: move.effects,
|
|
701
|
+
});
|
|
702
|
+
// Todos los efectos (daño, aplicar estado, curas, etc.)
|
|
703
|
+
const { actor: finalSelf, target: finalOpp, events: extraEvents, } = applyEffectsOnTarget(state, updatedSelf, updatedOpponent, move.typeId, isCritical, move.effects);
|
|
704
|
+
events.push(...extraEvents);
|
|
705
|
+
let newState = updateFightersInState(state, playerKey, finalSelf, finalOpp);
|
|
706
|
+
// --- FORCED SWITCH (Roar) ---
|
|
707
|
+
const hasForceSwitch = move.effects.some((e) => e.kind === "force_switch");
|
|
708
|
+
if (hasForceSwitch) {
|
|
709
|
+
const targetPlayer = playerKey === "player1" ? "player2" : "player1";
|
|
710
|
+
const targetSide = targetPlayer === "player1" ? newState.player1 : newState.player2;
|
|
711
|
+
const activeIdx = targetSide.activeIndex;
|
|
712
|
+
const hasBenchAlive = targetSide.fighterTeam.some((f, idx) => {
|
|
713
|
+
if (idx === activeIdx)
|
|
714
|
+
return false;
|
|
715
|
+
return f.isAlive && f.currentHp > 0;
|
|
716
|
+
});
|
|
717
|
+
if (hasBenchAlive) {
|
|
718
|
+
newState = {
|
|
719
|
+
...newState,
|
|
720
|
+
forcedSwitch: {
|
|
721
|
+
targetPlayer,
|
|
722
|
+
reason: "roar",
|
|
723
|
+
},
|
|
724
|
+
};
|
|
725
|
+
events.push({
|
|
726
|
+
...createBaseEvent(state.turnNumber, "action_declared", `¡El entrenador rival debe cambiar de luchador!`),
|
|
727
|
+
actorId: self.fighterId,
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return { state: newState, events };
|
|
732
|
+
};
|
|
733
|
+
// ------------------------------------------------------
|
|
734
|
+
// use_item (ahora el target real está en cada efecto)
|
|
735
|
+
// ------------------------------------------------------
|
|
736
|
+
const resolveItemUse = (state, playerKey, action) => {
|
|
737
|
+
if (action.kind !== "use_item") {
|
|
738
|
+
return { state, events: [] };
|
|
739
|
+
}
|
|
740
|
+
const { selfPlayer, oppPlayer, self, opponent } = getPlayersAndActives(state, playerKey);
|
|
741
|
+
const events = [];
|
|
742
|
+
const itemSlot = selfPlayer.inventory[action.itemIndex];
|
|
743
|
+
if (!itemSlot || itemSlot.usesRemaining <= 0) {
|
|
744
|
+
dbg("use_item: no charges", {
|
|
745
|
+
playerKey,
|
|
746
|
+
itemIndex: action.itemIndex,
|
|
747
|
+
});
|
|
748
|
+
return { state, events };
|
|
749
|
+
}
|
|
750
|
+
const itemDef = state.runtime.itemsById[itemSlot.itemId];
|
|
751
|
+
if (!itemDef) {
|
|
752
|
+
dbg("use_item: item definition not found", {
|
|
753
|
+
playerKey,
|
|
754
|
+
itemId: itemSlot.itemId,
|
|
755
|
+
});
|
|
756
|
+
return { state, events };
|
|
757
|
+
}
|
|
758
|
+
const updatedInventory = selfPlayer.inventory.map((it, idx) => idx === action.itemIndex
|
|
759
|
+
? { ...it, usesRemaining: Math.max(0, it.usesRemaining - 1) }
|
|
760
|
+
: it);
|
|
761
|
+
let updatedSelf = { ...self };
|
|
762
|
+
let updatedOpponent = { ...opponent };
|
|
763
|
+
events.push({
|
|
764
|
+
...createBaseEvent(state.turnNumber, "action_declared", `${self.fighterId} usó ${itemDef.name}`),
|
|
765
|
+
actorId: self.fighterId,
|
|
766
|
+
});
|
|
767
|
+
const { actor: finalSelf, target: finalOpp, events: extraEvents, } = applyEffectsOnTarget(state, updatedSelf, updatedOpponent, null, // los items no usan fórmula de tipo por ahora
|
|
768
|
+
false, itemDef.effects);
|
|
769
|
+
events.push(...extraEvents);
|
|
770
|
+
let newState = updateFightersInState(state, playerKey, finalSelf, finalOpp);
|
|
771
|
+
// persistimos el inventario gastado en el jugador que actuó
|
|
772
|
+
if (playerKey === "player1") {
|
|
773
|
+
newState = {
|
|
774
|
+
...newState,
|
|
775
|
+
player1: {
|
|
776
|
+
...newState.player1,
|
|
777
|
+
inventory: updatedInventory,
|
|
778
|
+
},
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
else {
|
|
782
|
+
newState = {
|
|
783
|
+
...newState,
|
|
784
|
+
player2: {
|
|
785
|
+
...newState.player2,
|
|
786
|
+
inventory: updatedInventory,
|
|
787
|
+
},
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
return { state: newState, events };
|
|
791
|
+
};
|
|
792
|
+
// ------------------------------------------------------
|
|
793
|
+
// Actualizar fighters en el estado
|
|
794
|
+
// ------------------------------------------------------
|
|
795
|
+
const updateFightersInState = (state, actingPlayerKey, updatedSelf, updatedOpponent) => {
|
|
796
|
+
const player1 = { ...state.player1 };
|
|
797
|
+
const player2 = { ...state.player2 };
|
|
798
|
+
const updateInPlayer = (player, updated) => {
|
|
799
|
+
const newTeam = player.fighterTeam.map((f, idx) => idx === player.activeIndex ? updated : f);
|
|
800
|
+
return {
|
|
801
|
+
...player,
|
|
802
|
+
fighterTeam: newTeam,
|
|
803
|
+
};
|
|
804
|
+
};
|
|
805
|
+
if (actingPlayerKey === "player1") {
|
|
806
|
+
const selfPlayer = updateInPlayer(player1, updatedSelf);
|
|
807
|
+
const oppPlayer = updateInPlayer(player2, updatedOpponent);
|
|
808
|
+
return {
|
|
809
|
+
...state,
|
|
810
|
+
player1: selfPlayer,
|
|
811
|
+
player2: oppPlayer,
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
const selfPlayer = updateInPlayer(player2, updatedSelf);
|
|
815
|
+
const oppPlayer = updateInPlayer(player1, updatedOpponent);
|
|
816
|
+
return {
|
|
817
|
+
...state,
|
|
818
|
+
player1: oppPlayer,
|
|
819
|
+
player2: selfPlayer,
|
|
820
|
+
};
|
|
821
|
+
};
|
|
822
|
+
// ------------------------------------------------------
|
|
823
|
+
// CC duro + check winner
|
|
824
|
+
// ------------------------------------------------------
|
|
825
|
+
const hasHardCc = (state, fighter) => fighter.statuses.some((st) => {
|
|
826
|
+
const def = state.runtime.statusesById[st.statusId];
|
|
827
|
+
return def?.kind === "hard_cc";
|
|
828
|
+
});
|
|
829
|
+
const checkWinner = (state) => {
|
|
830
|
+
const p1Alive = state.player1.fighterTeam.some((f) => f.isAlive && f.currentHp > 0);
|
|
831
|
+
const p2Alive = state.player2.fighterTeam.some((f) => f.isAlive && f.currentHp > 0);
|
|
832
|
+
if (p1Alive && !p2Alive)
|
|
833
|
+
return "player1";
|
|
834
|
+
if (!p1Alive && p2Alive)
|
|
835
|
+
return "player2";
|
|
836
|
+
if (!p1Alive && !p2Alive)
|
|
837
|
+
return "draw";
|
|
838
|
+
return "none";
|
|
839
|
+
};
|
|
840
|
+
// ------------------------------------------------------
|
|
841
|
+
// Fin de turno: aplicar DoT de estados
|
|
842
|
+
// ------------------------------------------------------
|
|
843
|
+
const applyEndOfTurnStatuses = (state) => {
|
|
844
|
+
const events = [];
|
|
845
|
+
const applyForPlayer = (player) => {
|
|
846
|
+
const active = getActiveFighter(player);
|
|
847
|
+
let updated = { ...active };
|
|
848
|
+
const updatedStatuses = [];
|
|
849
|
+
for (const st of active.statuses) {
|
|
850
|
+
const def = state.runtime.statusesById[st.statusId];
|
|
851
|
+
if (!def) {
|
|
852
|
+
dbg("STATUS_MISSING_DEF", {
|
|
853
|
+
fighterId: active.fighterId,
|
|
854
|
+
statusId: st.statusId,
|
|
855
|
+
});
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
let damageFromStatus = 0;
|
|
859
|
+
def.effectsPerStack.forEach((eff) => {
|
|
860
|
+
if (eff.kind === "damage") {
|
|
861
|
+
const stacksMultiplier = st.stacks === 2 ? 2 : 1;
|
|
862
|
+
const base = typeof eff.flatAmount === "number"
|
|
863
|
+
? eff.flatAmount
|
|
864
|
+
: typeof eff.basePower === "number"
|
|
865
|
+
? eff.basePower
|
|
866
|
+
: 10; // fallback
|
|
867
|
+
damageFromStatus += base * stacksMultiplier;
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
if (damageFromStatus > 0 && updated.isAlive) {
|
|
871
|
+
dbg("STATUS_DOT", {
|
|
872
|
+
fighterId: updated.fighterId,
|
|
873
|
+
statusId: st.statusId,
|
|
874
|
+
damageFromStatus,
|
|
875
|
+
stacks: st.stacks,
|
|
876
|
+
});
|
|
877
|
+
const damageRes = applyDamageToFighter(state, updated, damageFromStatus, updated.fighterId, false);
|
|
878
|
+
updated = damageRes.updatedDefender;
|
|
879
|
+
events.push(...damageRes.events);
|
|
880
|
+
}
|
|
881
|
+
const remaining = st.remainingTurns - 1;
|
|
882
|
+
if (remaining > 0) {
|
|
883
|
+
updatedStatuses.push({
|
|
884
|
+
...st,
|
|
885
|
+
remainingTurns: remaining,
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
else {
|
|
889
|
+
dbg("STATUS_EXPIRE", {
|
|
890
|
+
fighterId: updated.fighterId,
|
|
891
|
+
statusId: st.statusId,
|
|
892
|
+
});
|
|
893
|
+
events.push({
|
|
894
|
+
...createBaseEvent(state.turnNumber, "status_expired", `El estado ${st.statusId} expira en ${updated.fighterId}`),
|
|
895
|
+
targetId: updated.fighterId,
|
|
896
|
+
statusId: st.statusId,
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
updated = {
|
|
901
|
+
...updated,
|
|
902
|
+
statuses: updatedStatuses,
|
|
903
|
+
};
|
|
904
|
+
const newTeam = player.fighterTeam.map((f, idx) => idx === player.activeIndex ? updated : f);
|
|
905
|
+
return {
|
|
906
|
+
...player,
|
|
907
|
+
fighterTeam: newTeam,
|
|
908
|
+
};
|
|
909
|
+
};
|
|
910
|
+
const p1 = applyForPlayer(state.player1);
|
|
911
|
+
const p2 = applyForPlayer(state.player2);
|
|
912
|
+
const newState = {
|
|
913
|
+
...state,
|
|
914
|
+
player1: p1,
|
|
915
|
+
player2: p2,
|
|
916
|
+
};
|
|
917
|
+
return { state: newState, events };
|
|
918
|
+
};
|
|
919
|
+
// ------------------------------------------------------
|
|
920
|
+
// Bucle principal de turno
|
|
921
|
+
// ------------------------------------------------------
|
|
922
|
+
export const resolveTurn = (state, actions) => {
|
|
923
|
+
const runtimeState = state;
|
|
924
|
+
let currentState = runtimeState;
|
|
925
|
+
const recalcForPlayer = (player) => {
|
|
926
|
+
const team = player.fighterTeam.map((f) => recomputeEffectiveStatsForFighter(runtimeState, f));
|
|
927
|
+
return { ...player, fighterTeam: team };
|
|
928
|
+
};
|
|
929
|
+
currentState = {
|
|
930
|
+
...currentState,
|
|
931
|
+
player1: recalcForPlayer(currentState.player1),
|
|
932
|
+
player2: recalcForPlayer(currentState.player2),
|
|
933
|
+
};
|
|
934
|
+
const events = [];
|
|
935
|
+
dbg(`TURN ${runtimeState.turnNumber} start`, {
|
|
936
|
+
p1Action: actions.player1.kind,
|
|
937
|
+
p2Action: actions.player2.kind,
|
|
938
|
+
});
|
|
939
|
+
events.push({
|
|
940
|
+
...createBaseEvent(runtimeState.turnNumber, "turn_start", `Comienza el turno ${runtimeState.turnNumber}`),
|
|
941
|
+
});
|
|
942
|
+
const entries = [
|
|
943
|
+
{
|
|
944
|
+
playerKey: "player1",
|
|
945
|
+
action: actions.player1,
|
|
946
|
+
...getMovePriorityAndSpeed(runtimeState, "player1", actions.player1),
|
|
947
|
+
},
|
|
948
|
+
{
|
|
949
|
+
playerKey: "player2",
|
|
950
|
+
action: actions.player2,
|
|
951
|
+
...getMovePriorityAndSpeed(runtimeState, "player2", actions.player2),
|
|
952
|
+
},
|
|
953
|
+
];
|
|
954
|
+
entries.sort((a, b) => {
|
|
955
|
+
if (b.priority !== a.priority) {
|
|
956
|
+
return b.priority - a.priority;
|
|
957
|
+
}
|
|
958
|
+
if (b.speed !== a.speed) {
|
|
959
|
+
return b.speed - a.speed;
|
|
960
|
+
}
|
|
961
|
+
// empate total → coin flip
|
|
962
|
+
return Math.random() < 0.5 ? -1 : 1;
|
|
963
|
+
});
|
|
964
|
+
dbg(`TURN ${runtimeState.turnNumber} order`, entries.map((e) => ({
|
|
965
|
+
player: e.playerKey,
|
|
966
|
+
action: e.action.kind,
|
|
967
|
+
priority: e.priority,
|
|
968
|
+
speed: e.speed,
|
|
969
|
+
})));
|
|
970
|
+
for (const entry of entries) {
|
|
971
|
+
const { playerKey, action } = entry;
|
|
972
|
+
if (action.kind === "no_action")
|
|
973
|
+
continue;
|
|
974
|
+
const { self } = getOpponentAndSelf(currentState, playerKey);
|
|
975
|
+
if (!self.isAlive || self.currentHp <= 0)
|
|
976
|
+
continue;
|
|
977
|
+
if (hasHardCc(currentState, self)) {
|
|
978
|
+
events.push({
|
|
979
|
+
...createBaseEvent(currentState.turnNumber, "action_skipped_hard_cc", `${self.fighterId} no puede actuar por control total`),
|
|
980
|
+
actorId: self.fighterId,
|
|
981
|
+
});
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
if (action.kind === "switch_fighter") {
|
|
985
|
+
const result = resolveSwitchFighter(currentState, playerKey, action);
|
|
986
|
+
currentState = result.state;
|
|
987
|
+
events.push(...result.events);
|
|
988
|
+
}
|
|
989
|
+
if (action.kind === "use_move") {
|
|
990
|
+
const result = resolveDamageMove(currentState, playerKey, action);
|
|
991
|
+
currentState = result.state;
|
|
992
|
+
events.push(...result.events);
|
|
993
|
+
}
|
|
994
|
+
if (action.kind === "use_item") {
|
|
995
|
+
const result = resolveItemUse(currentState, playerKey, action);
|
|
996
|
+
currentState = result.state;
|
|
997
|
+
events.push(...result.events);
|
|
998
|
+
}
|
|
999
|
+
else {
|
|
1000
|
+
// switch_fighter u otros no implementados aún
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
1003
|
+
if (currentState.forcedSwitch) {
|
|
1004
|
+
return {
|
|
1005
|
+
newState: currentState,
|
|
1006
|
+
events,
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
const winnerAfterAction = checkWinner(currentState);
|
|
1010
|
+
if (winnerAfterAction !== "none") {
|
|
1011
|
+
events.push({
|
|
1012
|
+
...createBaseEvent(currentState.turnNumber, "battle_end", `La batalla termina: ${winnerAfterAction}`),
|
|
1013
|
+
winner: winnerAfterAction,
|
|
1014
|
+
});
|
|
1015
|
+
const finalState = {
|
|
1016
|
+
...currentState,
|
|
1017
|
+
turnNumber: currentState.turnNumber + 1,
|
|
1018
|
+
};
|
|
1019
|
+
return {
|
|
1020
|
+
newState: finalState,
|
|
1021
|
+
events,
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
const statusResult = applyEndOfTurnStatuses(currentState);
|
|
1026
|
+
currentState = statusResult.state;
|
|
1027
|
+
events.push(...statusResult.events);
|
|
1028
|
+
const winnerAtEnd = checkWinner(currentState);
|
|
1029
|
+
if (winnerAtEnd !== "none") {
|
|
1030
|
+
events.push({
|
|
1031
|
+
...createBaseEvent(currentState.turnNumber, "battle_end", `La batalla termina: ${winnerAtEnd}`),
|
|
1032
|
+
winner: winnerAtEnd,
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
events.push({
|
|
1036
|
+
...createBaseEvent(currentState.turnNumber, "turn_end", `Termina el turno ${currentState.turnNumber}`),
|
|
1037
|
+
});
|
|
1038
|
+
const finalState = {
|
|
1039
|
+
...currentState,
|
|
1040
|
+
turnNumber: currentState.turnNumber + 1,
|
|
1041
|
+
};
|
|
1042
|
+
return {
|
|
1043
|
+
newState: finalState,
|
|
1044
|
+
events,
|
|
1045
|
+
};
|
|
1046
|
+
};
|