pokemon-io-core 0.0.83 → 0.0.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/core/battleState.d.ts +1 -1
  2. package/dist/core/index.d.ts +0 -1
  3. package/dist/core/index.js +0 -1
  4. package/dist/engine/actions/forcedSwitch.d.ts +5 -0
  5. package/dist/engine/actions/forcedSwitch.js +44 -0
  6. package/dist/engine/actions/index.d.ts +5 -0
  7. package/dist/engine/actions/index.js +5 -0
  8. package/dist/engine/actions/item.d.ts +6 -0
  9. package/dist/engine/actions/item.js +61 -0
  10. package/dist/engine/actions/move.d.ts +6 -0
  11. package/dist/engine/actions/move.js +120 -0
  12. package/dist/engine/actions/priority.d.ts +6 -0
  13. package/dist/engine/actions/priority.js +30 -0
  14. package/dist/engine/actions/switch.d.ts +6 -0
  15. package/dist/engine/actions/switch.js +55 -0
  16. package/dist/engine/combat/crit.d.ts +2 -0
  17. package/dist/engine/combat/crit.js +4 -0
  18. package/dist/engine/combat/damage.d.ts +20 -0
  19. package/dist/engine/combat/damage.js +66 -0
  20. package/dist/engine/combat/heal.d.ts +6 -0
  21. package/dist/engine/combat/heal.js +25 -0
  22. package/dist/engine/combat/index.d.ts +5 -0
  23. package/dist/engine/combat/index.js +5 -0
  24. package/dist/engine/combat/typeEffectiveness.d.ts +3 -0
  25. package/dist/engine/combat/typeEffectiveness.js +7 -0
  26. package/dist/engine/combat/winner.d.ts +2 -0
  27. package/dist/engine/combat/winner.js +11 -0
  28. package/dist/engine/debug.d.ts +1 -0
  29. package/dist/engine/debug.js +6 -0
  30. package/dist/engine/effects/applyEffects.d.ts +7 -0
  31. package/dist/engine/effects/applyEffects.js +103 -0
  32. package/dist/engine/effects/index.d.ts +2 -0
  33. package/dist/engine/effects/index.js +2 -0
  34. package/dist/engine/effects/target.d.ts +6 -0
  35. package/dist/engine/effects/target.js +26 -0
  36. package/dist/engine/engine.d.ts +53 -0
  37. package/dist/engine/engine.js +1046 -0
  38. package/dist/engine/events.d.ts +4 -0
  39. package/dist/engine/events.js +12 -0
  40. package/dist/engine/fighters/fighter.d.ts +4 -0
  41. package/dist/engine/fighters/fighter.js +53 -0
  42. package/dist/engine/fighters/index.d.ts +3 -0
  43. package/dist/engine/fighters/index.js +3 -0
  44. package/dist/engine/fighters/selectors.d.ts +13 -0
  45. package/dist/engine/fighters/selectors.js +19 -0
  46. package/dist/engine/fighters/update.d.ts +3 -0
  47. package/dist/engine/fighters/update.js +30 -0
  48. package/dist/engine/index.d.ts +11 -1
  49. package/dist/engine/index.js +11 -1
  50. package/dist/engine/rng.d.ts +2 -0
  51. package/dist/engine/rng.js +8 -0
  52. package/dist/engine/rules.d.ts +44 -0
  53. package/dist/engine/rules.js +10 -0
  54. package/dist/engine/runtime.d.ts +7 -0
  55. package/dist/engine/runtime.js +49 -0
  56. package/dist/engine/status/apply.d.ts +6 -0
  57. package/dist/engine/status/apply.js +49 -0
  58. package/dist/engine/status/clear.d.ts +6 -0
  59. package/dist/engine/status/clear.js +47 -0
  60. package/dist/engine/status/endOfTurn.d.ts +6 -0
  61. package/dist/engine/status/endOfTurn.js +80 -0
  62. package/dist/engine/status/hardCc.d.ts +3 -0
  63. package/dist/engine/status/hardCc.js +4 -0
  64. package/dist/engine/status/index.d.ts +4 -0
  65. package/dist/engine/status/index.js +4 -0
  66. package/dist/engine/turn/index.d.ts +1 -0
  67. package/dist/engine/turn/index.js +1 -0
  68. package/dist/engine/turn/resolveTurn.d.ts +5 -0
  69. package/dist/engine/turn/resolveTurn.js +139 -0
  70. package/dist/index.d.ts +1 -0
  71. package/dist/index.js +1 -0
  72. package/dist/skins/CombatSkin.d.ts +1 -1
  73. package/dist/skins/cliches/clicheSkin.d.ts +2 -1
  74. package/dist/skins/pokemon/pokemonSkin.d.ts +2 -1
  75. 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
+ };