feat: implement sword/melee auto-attack system (spec §3.1, §4.3)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m1s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m1s
- Add calcMeleeDamage() with elemental matchup for enchanted swords - Add meleeSwordProgress per-instance accumulator to combat state - Add melee branch in processCombatTick (no mana cost, no Executioner/Berserker) - Add baseDamage/attackSpeed stats to all 5 sword types - Wire equippedSwords through gameStore to combat tick pipeline - 16 new regression tests, all 937 tests pass
This commit is contained in:
@@ -217,6 +217,7 @@ Mana-Loop/
|
|||||||
│ │ │ │ ├── formatting.test.ts
|
│ │ │ │ ├── formatting.test.ts
|
||||||
│ │ │ │ ├── guardian-names.test.ts
|
│ │ │ │ ├── guardian-names.test.ts
|
||||||
│ │ │ │ ├── mana-utils.test.ts
|
│ │ │ │ ├── mana-utils.test.ts
|
||||||
|
│ │ │ │ ├── melee-auto-attack.test.ts
|
||||||
│ │ │ │ ├── pact-utils.test.ts
|
│ │ │ │ ├── pact-utils.test.ts
|
||||||
│ │ │ │ ├── persistence.test.ts
|
│ │ │ │ ├── persistence.test.ts
|
||||||
│ │ │ │ ├── regression-fixes.test.ts
|
│ │ │ │ ├── regression-fixes.test.ts
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { processCombatTick, makeInitialSpells } from '../stores/combat-actions';
|
||||||
|
import { useCombatStore, makeInitialSpells as makeStoreInitialSpells } from '../stores/combatStore';
|
||||||
|
import { useManaStore, makeInitialElements } from '../stores/manaStore';
|
||||||
|
import { useGameStore } from '../stores/gameStore';
|
||||||
|
import { usePrestigeStore } from '../stores/prestigeStore';
|
||||||
|
import { useUIStore } from '../stores/uiStore';
|
||||||
|
import { useDisciplineStore } from '../stores/discipline-slice';
|
||||||
|
import { getFloorMaxHP } from '../utils';
|
||||||
|
import { calcMeleeDamage, getElementalBonus } from '../utils/combat-utils';
|
||||||
|
import type { CombatTickResult } from '../stores/combat-actions';
|
||||||
|
import type { EquipmentInstance } from '../types';
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function resetStores() {
|
||||||
|
useUIStore.setState({ paused: false, gameOver: false, victory: false, logs: [] });
|
||||||
|
useGameStore.setState({ day: 1, hour: 0, incursionStrength: 0, containmentWards: 0, initialized: true });
|
||||||
|
useManaStore.setState({ rawMana: 1000, meditateTicks: 0, totalManaGathered: 0, elements: makeInitialElements(500, {}) });
|
||||||
|
useCombatStore.setState({
|
||||||
|
currentFloor: 1,
|
||||||
|
floorHP: getFloorMaxHP(1),
|
||||||
|
floorMaxHP: getFloorMaxHP(1),
|
||||||
|
maxFloorReached: 1,
|
||||||
|
activeSpell: 'manaBolt',
|
||||||
|
currentAction: 'climb',
|
||||||
|
castProgress: 0,
|
||||||
|
spireMode: false,
|
||||||
|
currentRoom: { roomType: 'combat', enemies: [] },
|
||||||
|
clearedFloors: {},
|
||||||
|
climbDirection: 'up',
|
||||||
|
isDescending: false,
|
||||||
|
startFloor: 1,
|
||||||
|
exitFloor: 1,
|
||||||
|
currentRoomIndex: 0,
|
||||||
|
roomsPerFloor: 5,
|
||||||
|
descentPeak: null,
|
||||||
|
roomResetState: {},
|
||||||
|
clearedRooms: {},
|
||||||
|
isDescentComplete: false,
|
||||||
|
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
||||||
|
equipmentSpellStates: [],
|
||||||
|
comboHitCount: 0,
|
||||||
|
floorHitCount: 0,
|
||||||
|
meleeSwordProgress: {},
|
||||||
|
spells: makeStoreInitialSpells(),
|
||||||
|
activityLog: [],
|
||||||
|
achievements: { unlocked: [], progress: {} },
|
||||||
|
totalSpellsCast: 0,
|
||||||
|
totalDamageDealt: 0,
|
||||||
|
totalCraftsCompleted: 0,
|
||||||
|
});
|
||||||
|
usePrestigeStore.setState({
|
||||||
|
loopCount: 0, insight: 0, totalInsight: 0, loopInsight: 0,
|
||||||
|
prestigeUpgrades: {}, pactSlots: 1, defeatedGuardians: [],
|
||||||
|
signedPacts: [], signedPactDetails: {},
|
||||||
|
pactRitualFloor: null, pactRitualProgress: 0,
|
||||||
|
});
|
||||||
|
useDisciplineStore.setState({ disciplines: {}, activeIds: [], concurrentLimit: 1, totalXP: 0, processedPerks: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function golemApplyDamageToRoom(dmg: number): { floorHP: number; floorMaxHP: number; roomCleared: boolean } {
|
||||||
|
const cs = useCombatStore.getState();
|
||||||
|
const newFloorHP = Math.max(0, cs.floorHP - dmg);
|
||||||
|
return { floorHP: newFloorHP, floorMaxHP: cs.floorMaxHP, roomCleared: newFloorHP <= 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSwordInstance(typeId: string, enchantments: EquipmentInstance['enchantments'] = []): EquipmentInstance {
|
||||||
|
return {
|
||||||
|
instanceId: `test-${typeId}`,
|
||||||
|
typeId,
|
||||||
|
name: typeId,
|
||||||
|
enchantments,
|
||||||
|
usedCapacity: 0,
|
||||||
|
totalCapacity: 50,
|
||||||
|
rarity: 'common',
|
||||||
|
quality: 50,
|
||||||
|
tags: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCombatTickWithSwords(
|
||||||
|
rawMana: number,
|
||||||
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||||
|
equippedSwords: Record<string, EquipmentInstance>,
|
||||||
|
): CombatTickResult {
|
||||||
|
return processCombatTick(
|
||||||
|
() => useCombatStore.getState(),
|
||||||
|
(partial) => useCombatStore.setState(partial),
|
||||||
|
rawMana,
|
||||||
|
elements,
|
||||||
|
1000, // maxMana
|
||||||
|
1, // attackSpeedMult
|
||||||
|
vi.fn(), // onFloorCleared
|
||||||
|
(dmg) => ({ rawMana, elements, modifiedDamage: dmg }),
|
||||||
|
[], // signedPacts
|
||||||
|
{ activeGolems: [] },
|
||||||
|
golemApplyDamageToRoom,
|
||||||
|
(dmg: number) => dmg,
|
||||||
|
equippedSwords,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// MELEE AUTO-ATTACK SYSTEM (spec §3.1, §4.3)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
describe('calcMeleeDamage', () => {
|
||||||
|
it('should return base damage for a sword with no enchantments', () => {
|
||||||
|
const swordType = { stats: { baseDamage: 10, attackSpeed: 1.2 } };
|
||||||
|
const swordInstance = makeSwordInstance('ironBlade');
|
||||||
|
const damage = calcMeleeDamage(swordInstance, swordType, 'fire');
|
||||||
|
expect(damage).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply elemental bonus for fire-enchanted sword vs water enemy', () => {
|
||||||
|
const swordType = { stats: { baseDamage: 10, attackSpeed: 1.2 } };
|
||||||
|
const swordInstance = makeSwordInstance('ironBlade', [
|
||||||
|
{ effectId: 'sword_fire', stacks: 1, actualCost: 40 },
|
||||||
|
]);
|
||||||
|
const damage = calcMeleeDamage(swordInstance, swordType, 'water');
|
||||||
|
expect(damage).toBe(10 * 1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply elemental bonus for fire-enchanted sword vs fire enemy (resonance)', () => {
|
||||||
|
const swordType = { stats: { baseDamage: 10, attackSpeed: 1.2 } };
|
||||||
|
const swordInstance = makeSwordInstance('ironBlade', [
|
||||||
|
{ effectId: 'sword_fire', stacks: 1, actualCost: 40 },
|
||||||
|
]);
|
||||||
|
const damage = calcMeleeDamage(swordInstance, swordType, 'fire');
|
||||||
|
expect(damage).toBe(10 * 1.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply elemental bonus for fire-enchanted sword vs air enemy (neutral)', () => {
|
||||||
|
const swordType = { stats: { baseDamage: 10, attackSpeed: 1.2 } };
|
||||||
|
const swordInstance = makeSwordInstance('ironBlade', [
|
||||||
|
{ effectId: 'sword_fire', stacks: 1, actualCost: 40 },
|
||||||
|
]);
|
||||||
|
const damage = calcMeleeDamage(swordInstance, swordType, 'air');
|
||||||
|
expect(damage).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return fallback damage for sword with no stats', () => {
|
||||||
|
const swordType = {};
|
||||||
|
const swordInstance = makeSwordInstance('ironBlade');
|
||||||
|
const damage = calcMeleeDamage(swordInstance, swordType, 'fire');
|
||||||
|
expect(damage).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle frost-enchanted sword vs fire enemy (frost weak to fire = 0.75x)', () => {
|
||||||
|
const swordType = { stats: { baseDamage: 15, attackSpeed: 1.0 } };
|
||||||
|
const swordInstance = makeSwordInstance('crystalBlade', [
|
||||||
|
{ effectId: 'sword_frost', stacks: 1, actualCost: 40 },
|
||||||
|
]);
|
||||||
|
const damage = calcMeleeDamage(swordInstance, swordType, 'fire');
|
||||||
|
expect(damage).toBe(15 * 0.75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle lightning-enchanted sword vs earth enemy (lightning weak to earth = 0.75x)', () => {
|
||||||
|
const swordType = { stats: { baseDamage: 20, attackSpeed: 1.4 } };
|
||||||
|
const swordInstance = makeSwordInstance('arcanistBlade', [
|
||||||
|
{ effectId: 'sword_lightning', stacks: 1, actualCost: 50 },
|
||||||
|
]);
|
||||||
|
const damage = calcMeleeDamage(swordInstance, swordType, 'earth');
|
||||||
|
expect(damage).toBe(20 * 0.75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle void-enchanted sword correctly (no specific matchup = neutral)', () => {
|
||||||
|
const swordType = { stats: { baseDamage: 25, attackSpeed: 1.0 } };
|
||||||
|
const swordInstance = makeSwordInstance('voidBlade', [
|
||||||
|
{ effectId: 'sword_void', stacks: 1, actualCost: 60 },
|
||||||
|
]);
|
||||||
|
const damage = calcMeleeDamage(swordInstance, swordType, 'light');
|
||||||
|
expect(damage).toBe(25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('melee auto-attack in processCombatTick', () => {
|
||||||
|
beforeEach(resetStores);
|
||||||
|
|
||||||
|
it('should not error when no swords are equipped', () => {
|
||||||
|
const elements = makeInitialElements(500, {});
|
||||||
|
const result = runCombatTickWithSwords(1000, elements, {});
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.meleeSwordProgress).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accumulate melee progress for equipped swords', () => {
|
||||||
|
const elements = makeInitialElements(500, {});
|
||||||
|
const sword = makeSwordInstance('ironBlade');
|
||||||
|
const equippedSwords = { [sword.instanceId]: sword };
|
||||||
|
let result: CombatTickResult = { rawMana: 1000, elements, logMessages: [], totalManaGathered: 0, currentFloor: 1, floorHP: 100, floorMaxHP: 100, maxFloorReached: 1, castProgress: 0, equipmentSpellStates: [], activeGolems: [], meleeSwordProgress: {} };
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
result = runCombatTickWithSwords(1000, elements, equippedSwords);
|
||||||
|
}
|
||||||
|
expect(Object.keys(result.meleeSwordProgress).length).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track meleeSwordProgress per sword instance', () => {
|
||||||
|
const sword1 = makeSwordInstance('ironBlade');
|
||||||
|
const sword2 = makeSwordInstance('steelBlade');
|
||||||
|
sword1.instanceId = 'sword-1';
|
||||||
|
sword2.instanceId = 'sword-2';
|
||||||
|
const elements = makeInitialElements(500, {});
|
||||||
|
const equippedSwords = { [sword1.instanceId]: sword1, [sword2.instanceId]: sword2 };
|
||||||
|
const result = runCombatTickWithSwords(1000, elements, equippedSwords);
|
||||||
|
expect(result.meleeSwordProgress['sword-1']).toBeDefined();
|
||||||
|
expect(result.meleeSwordProgress['sword-2']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return meleeSwordProgress in the result', () => {
|
||||||
|
const elements = makeInitialElements(500, {});
|
||||||
|
const result = runCombatTickWithSwords(1000, elements, {});
|
||||||
|
expect(result.meleeSwordProgress).toBeDefined();
|
||||||
|
expect(typeof result.meleeSwordProgress).toBe('object');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deal melee damage alongside spell damage', () => {
|
||||||
|
// Run many ticks to accumulate enough melee progress for a hit
|
||||||
|
const elements = makeInitialElements(500, {});
|
||||||
|
const sword = makeSwordInstance('ironBlade');
|
||||||
|
sword.instanceId = 'test-sword';
|
||||||
|
const equippedSwords = { [sword.instanceId]: sword };
|
||||||
|
let result: CombatTickResult = { rawMana: 1000, elements, logMessages: [], totalManaGathered: 0, currentFloor: 1, floorHP: 100000, floorMaxHP: 100000, maxFloorReached: 1, castProgress: 0, equipmentSpellStates: [], activeGolems: [], meleeSwordProgress: {} };
|
||||||
|
// Run 25 ticks: each tick adds 0.048 progress, so 25 * 0.048 = 1.2, triggering a hit
|
||||||
|
for (let i = 0; i < 25; i++) {
|
||||||
|
result = runCombatTickWithSwords(1000, elements, equippedSwords);
|
||||||
|
}
|
||||||
|
// Floor HP should have decreased from both spell and melee damage
|
||||||
|
expect(result.floorHP).toBeLessThan(100000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply elemental bonus for enchanted swords', () => {
|
||||||
|
// Verify that calcMeleeDamage applies elemental bonus correctly
|
||||||
|
const swordType = { stats: { baseDamage: 8, attackSpeed: 1.2 } };
|
||||||
|
|
||||||
|
// Plain sword vs fire enemy (neutral)
|
||||||
|
const plainSword = makeSwordInstance('ironBlade');
|
||||||
|
const plainDamage = calcMeleeDamage(plainSword, swordType, 'fire');
|
||||||
|
expect(plainDamage).toBe(8);
|
||||||
|
|
||||||
|
// Fire-enchanted sword vs fire enemy (resonance = 1.25x)
|
||||||
|
const fireSword = makeSwordInstance('ironBlade', [
|
||||||
|
{ effectId: 'sword_fire', stacks: 1, actualCost: 40 },
|
||||||
|
]);
|
||||||
|
const fireDamage = calcMeleeDamage(fireSword, swordType, 'fire');
|
||||||
|
expect(fireDamage).toBe(8 * 1.25);
|
||||||
|
|
||||||
|
// Fire-enchanted sword vs water enemy (super effective = 1.5x)
|
||||||
|
const fireVsWater = calcMeleeDamage(fireSword, swordType, 'water');
|
||||||
|
expect(fireVsWater).toBe(8 * 1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not deduct mana for melee attacks', () => {
|
||||||
|
const sword = makeSwordInstance('ironBlade');
|
||||||
|
sword.instanceId = 'test-sword';
|
||||||
|
const equippedSwords = { [sword.instanceId]: sword };
|
||||||
|
const elements = makeInitialElements(500, {});
|
||||||
|
const startMana = 1000;
|
||||||
|
const result = runCombatTickWithSwords(startMana, elements, equippedSwords);
|
||||||
|
// Melee attacks cost no mana — rawMana should be <= startMana (spell may have deducted)
|
||||||
|
expect(result.rawMana).toBeLessThanOrEqual(startMana);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple swords with different attack speeds', () => {
|
||||||
|
const fastSword = makeSwordInstance('arcanistBlade');
|
||||||
|
fastSword.instanceId = 'fast-sword';
|
||||||
|
const slowSword = makeSwordInstance('voidBlade');
|
||||||
|
slowSword.instanceId = 'slow-sword';
|
||||||
|
const elements = makeInitialElements(500, {});
|
||||||
|
const equippedSwords = { [fastSword.instanceId]: fastSword, [slowSword.instanceId]: slowSword };
|
||||||
|
const result = runCombatTickWithSwords(1000, elements, equippedSwords);
|
||||||
|
const fastProgress = result.meleeSwordProgress[fastSword.instanceId] || 0;
|
||||||
|
const slowProgress = result.meleeSwordProgress[slowSword.instanceId] || 0;
|
||||||
|
expect(fastProgress).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(slowProgress).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,9 @@ export type { RoomType } from '../types/game';
|
|||||||
export { PUZZLE_ROOM_INTERVAL, SWARM_ROOM_CHANCE, SPEED_ROOM_CHANCE, PUZZLE_ROOM_CHANCE } from './rooms';
|
export { PUZZLE_ROOM_INTERVAL, SWARM_ROOM_CHANCE, SPEED_ROOM_CHANCE, PUZZLE_ROOM_CHANCE } from './rooms';
|
||||||
export { PUZZLE_ROOMS, SWARM_CONFIG, SPEED_ROOM_CONFIG, FLOOR_ARMOR_CONFIG } from './rooms';
|
export { PUZZLE_ROOMS, SWARM_CONFIG, SPEED_ROOM_CONFIG, FLOOR_ARMOR_CONFIG } from './rooms';
|
||||||
|
|
||||||
|
// Equipment types
|
||||||
|
export { EQUIPMENT_TYPES } from '../data/equipment/equipment-types-data';
|
||||||
|
|
||||||
// Room type display labels
|
// Room type display labels
|
||||||
export const ROOM_TYPE_LABELS: Record<string, { label: string; icon: string; color: string }> = {
|
export const ROOM_TYPE_LABELS: Record<string, { label: string; icon: string; color: string }> = {
|
||||||
combat: { label: 'Combat', icon: '⚔️', color: '#EF4444' },
|
combat: { label: 'Combat', icon: '⚔️', color: '#EF4444' },
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const SWORD_EQUIPMENT: Record<string, EquipmentType> = {
|
|||||||
slot: 'mainHand',
|
slot: 'mainHand',
|
||||||
baseCapacity: 30,
|
baseCapacity: 30,
|
||||||
description: 'A simple iron sword. Can be enchanted with elemental effects.',
|
description: 'A simple iron sword. Can be enchanted with elemental effects.',
|
||||||
|
stats: { baseDamage: 8, attackSpeed: 1.2 },
|
||||||
},
|
},
|
||||||
steelBlade: {
|
steelBlade: {
|
||||||
id: 'steelBlade',
|
id: 'steelBlade',
|
||||||
@@ -20,6 +21,7 @@ export const SWORD_EQUIPMENT: Record<string, EquipmentType> = {
|
|||||||
slot: 'mainHand',
|
slot: 'mainHand',
|
||||||
baseCapacity: 40,
|
baseCapacity: 40,
|
||||||
description: 'A well-crafted steel sword. Balanced for combat and enchanting.',
|
description: 'A well-crafted steel sword. Balanced for combat and enchanting.',
|
||||||
|
stats: { baseDamage: 12, attackSpeed: 1.3 },
|
||||||
},
|
},
|
||||||
crystalBlade: {
|
crystalBlade: {
|
||||||
id: 'crystalBlade',
|
id: 'crystalBlade',
|
||||||
@@ -28,6 +30,7 @@ export const SWORD_EQUIPMENT: Record<string, EquipmentType> = {
|
|||||||
slot: 'mainHand',
|
slot: 'mainHand',
|
||||||
baseCapacity: 55,
|
baseCapacity: 55,
|
||||||
description: 'A blade made of crystallized mana. Excellent for elemental enchantments.',
|
description: 'A blade made of crystallized mana. Excellent for elemental enchantments.',
|
||||||
|
stats: { baseDamage: 18, attackSpeed: 1.1 },
|
||||||
},
|
},
|
||||||
arcanistBlade: {
|
arcanistBlade: {
|
||||||
id: 'arcanistBlade',
|
id: 'arcanistBlade',
|
||||||
@@ -36,6 +39,7 @@ export const SWORD_EQUIPMENT: Record<string, EquipmentType> = {
|
|||||||
slot: 'mainHand',
|
slot: 'mainHand',
|
||||||
baseCapacity: 65,
|
baseCapacity: 65,
|
||||||
description: 'A sword forged for battle mages. High capacity for powerful enchantments.',
|
description: 'A sword forged for battle mages. High capacity for powerful enchantments.',
|
||||||
|
stats: { baseDamage: 22, attackSpeed: 1.4 },
|
||||||
},
|
},
|
||||||
voidBlade: {
|
voidBlade: {
|
||||||
id: 'voidBlade',
|
id: 'voidBlade',
|
||||||
@@ -44,5 +48,6 @@ export const SWORD_EQUIPMENT: Record<string, EquipmentType> = {
|
|||||||
slot: 'mainHand',
|
slot: 'mainHand',
|
||||||
baseCapacity: 50,
|
baseCapacity: 50,
|
||||||
description: 'A blade corrupted by void energy. Powerful but consumes more mana.',
|
description: 'A blade corrupted by void energy. Powerful but consumes more mana.',
|
||||||
|
stats: { baseDamage: 25, attackSpeed: 1.0 },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,4 +27,8 @@ export interface EquipmentType {
|
|||||||
baseCapacity: number;
|
baseCapacity: number;
|
||||||
description: string;
|
description: string;
|
||||||
twoHanded?: boolean; // If true, weapon occupies both main hand and offhand slots
|
twoHanded?: boolean; // If true, weapon occupies both main hand and offhand slots
|
||||||
|
stats?: {
|
||||||
|
baseDamage?: number; // Base damage per hit (swords)
|
||||||
|
attackSpeed?: number; // Attacks per in-game hour (swords)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,13 @@
|
|||||||
// Pure combat logic — no cross-store getState() calls.
|
// Pure combat logic — no cross-store getState() calls.
|
||||||
// All external data (signedPacts, etc.) is passed in as parameters.
|
// All external data (signedPacts, etc.) is passed in as parameters.
|
||||||
|
|
||||||
import { SPELLS_DEF, HOURS_PER_TICK } from '../constants';
|
import { SPELLS_DEF, HOURS_PER_TICK, EQUIPMENT_TYPES } from '../constants';
|
||||||
import { getGuardianForFloor } from '../data/guardian-encounters';
|
import { getGuardianForFloor } from '../data/guardian-encounters';
|
||||||
import type { CombatStore, CombatState } from './combat-state.types';
|
import type { CombatStore, CombatState } from './combat-state.types';
|
||||||
import type { SpellState, EnemyState } from '../types';
|
import type { SpellState, EnemyState, EquipmentInstance } from '../types';
|
||||||
import { applyOnHitEffect, processDoTPhase } from './dot-runtime';
|
import { applyOnHitEffect, processDoTPhase } from './dot-runtime';
|
||||||
import type { ActiveGolem } from '../types';
|
import type { ActiveGolem } from '../types';
|
||||||
import type { SpellOnHitEffect } from '../types/spells';
|
import { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, calcMeleeDamage, canAffordSpellCost, deductSpellCost } from '../utils';
|
||||||
import { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, canAffordSpellCost, deductSpellCost } from '../utils';
|
|
||||||
import { computeDisciplineEffects } from '../effects/discipline-effects';
|
import { computeDisciplineEffects } from '../effects/discipline-effects';
|
||||||
import { processGolemMaintenance, processGolemAttacks } from './golem-combat-actions';
|
import { processGolemMaintenance, processGolemAttacks } from './golem-combat-actions';
|
||||||
|
|
||||||
@@ -34,6 +33,7 @@ function makeDefaultCombatTickResult(
|
|||||||
castProgress: state.castProgress,
|
castProgress: state.castProgress,
|
||||||
equipmentSpellStates: state.equipmentSpellStates,
|
equipmentSpellStates: state.equipmentSpellStates,
|
||||||
activeGolems,
|
activeGolems,
|
||||||
|
meleeSwordProgress: state.meleeSwordProgress,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +49,7 @@ export interface CombatTickResult {
|
|||||||
castProgress: number;
|
castProgress: number;
|
||||||
equipmentSpellStates: CombatState['equipmentSpellStates'];
|
equipmentSpellStates: CombatState['equipmentSpellStates'];
|
||||||
activeGolems: ActiveGolem[];
|
activeGolems: ActiveGolem[];
|
||||||
|
meleeSwordProgress: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function processCombatTick(
|
export function processCombatTick(
|
||||||
@@ -75,6 +76,7 @@ export function processCombatTick(
|
|||||||
bypassArmor?: boolean,
|
bypassArmor?: boolean,
|
||||||
bypassBarrier?: boolean,
|
bypassBarrier?: boolean,
|
||||||
) => number,
|
) => number,
|
||||||
|
equippedSwords?: Record<string, EquipmentInstance>,
|
||||||
): CombatTickResult {
|
): CombatTickResult {
|
||||||
const state = get();
|
const state = get();
|
||||||
const logMessages: string[] = [];
|
const logMessages: string[] = [];
|
||||||
@@ -84,6 +86,7 @@ export function processCombatTick(
|
|||||||
return makeDefaultCombatTickResult(rawMana, elements, state, golemancyState.activeGolems);
|
return makeDefaultCombatTickResult(rawMana, elements, state, golemancyState.activeGolems);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
// ─── Golem maintenance (spec §9.5) ──────────────────────────────────────
|
// ─── Golem maintenance (spec §9.5) ──────────────────────────────────────
|
||||||
const maintenanceResult = processGolemMaintenance(
|
const maintenanceResult = processGolemMaintenance(
|
||||||
golemancyState.activeGolems,
|
golemancyState.activeGolems,
|
||||||
@@ -100,39 +103,15 @@ export function processCombatTick(
|
|||||||
|
|
||||||
const spellId = state.activeSpell;
|
const spellId = state.activeSpell;
|
||||||
const spellDef = SPELLS_DEF[spellId];
|
const spellDef = SPELLS_DEF[spellId];
|
||||||
if (!spellDef) {
|
|
||||||
// Even if no spell is configured, golems can still fight
|
|
||||||
// Process golem attacks even without a valid spell
|
|
||||||
if (activeGolems.length > 0) {
|
|
||||||
const golemAttackResult = processGolemAttacks(
|
|
||||||
activeGolems,
|
|
||||||
rawMana,
|
|
||||||
elements,
|
|
||||||
state.floorHP,
|
|
||||||
state.floorMaxHP,
|
|
||||||
state.currentFloor,
|
|
||||||
onDamageDealt,
|
|
||||||
golemApplyDamageToRoom,
|
|
||||||
);
|
|
||||||
rawMana = golemAttackResult.rawMana;
|
|
||||||
elements = golemAttackResult.elements;
|
|
||||||
activeGolems = golemAttackResult.activeGolems;
|
|
||||||
logMessages.push(...golemAttackResult.logMessages);
|
|
||||||
|
|
||||||
// Check if golems cleared the room
|
let floorHP = state.floorHP;
|
||||||
const newFloorHP = golemApplyDamageToRoom(0);
|
let currentFloor = state.currentFloor;
|
||||||
if (newFloorHP.roomCleared || golemAttackResult.totalDamageDealt > 0) {
|
let floorMaxHP = state.floorMaxHP;
|
||||||
// Re-check floor state after golem attacks
|
let castProgress = state.castProgress;
|
||||||
const newState = get();
|
const updatedEquipmentSpellStates = [...state.equipmentSpellStates];
|
||||||
if (newState.floorHP <= 0) {
|
|
||||||
return makeDefaultCombatTickResult(rawMana, elements, state, activeGolems);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return makeDefaultCombatTickResult(rawMana, elements, state, activeGolems);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
// ─── Spell casting (only when a valid spell is configured) ────────────────
|
||||||
|
if (spellDef) {
|
||||||
// Compute discipline bonuses once per tick
|
// Compute discipline bonuses once per tick
|
||||||
const disciplineEffects = computeDisciplineEffects();
|
const disciplineEffects = computeDisciplineEffects();
|
||||||
|
|
||||||
@@ -141,10 +120,7 @@ export function processCombatTick(
|
|||||||
const spellCastSpeed = spellDef.castSpeed || 1;
|
const spellCastSpeed = spellDef.castSpeed || 1;
|
||||||
const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed;
|
const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed;
|
||||||
|
|
||||||
let castProgress = (state.castProgress || 0) + progressPerTick;
|
castProgress = (castProgress || 0) + progressPerTick;
|
||||||
let floorHP = state.floorHP;
|
|
||||||
let currentFloor = state.currentFloor;
|
|
||||||
let floorMaxHP = state.floorMaxHP;
|
|
||||||
|
|
||||||
// Process complete casts for active spell (safety counter prevents infinite loop)
|
// Process complete casts for active spell (safety counter prevents infinite loop)
|
||||||
let safetyCounter = 0;
|
let safetyCounter = 0;
|
||||||
@@ -208,15 +184,14 @@ export function processCombatTick(
|
|||||||
castProgress = 0;
|
castProgress = 0;
|
||||||
|
|
||||||
if (guardian) {
|
if (guardian) {
|
||||||
logMessages.push(`\u2694\ufe0f ${guardian.name} defeated!`);
|
logMessages.push(`⚔️ ${guardian.name} defeated!`);
|
||||||
} else if (currentFloor % 5 === 0) {
|
} else if (currentFloor % 5 === 0) {
|
||||||
logMessages.push(`\ud83c\udff0 Floor ${currentFloor - 1} cleared!`);
|
logMessages.push(`🗺️ Floor ${currentFloor - 1} cleared!`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process equipment spell states (for progress bars in UI)
|
// Process equipment spell states (for progress bars in UI)
|
||||||
const updatedEquipmentSpellStates = [...state.equipmentSpellStates];
|
|
||||||
for (let i = 0; i < updatedEquipmentSpellStates.length; i++) {
|
for (let i = 0; i < updatedEquipmentSpellStates.length; i++) {
|
||||||
const eSpell = updatedEquipmentSpellStates[i];
|
const eSpell = updatedEquipmentSpellStates[i];
|
||||||
const eSpellDef = SPELLS_DEF[eSpell.spellId];
|
const eSpellDef = SPELLS_DEF[eSpell.spellId];
|
||||||
@@ -224,7 +199,7 @@ export function processCombatTick(
|
|||||||
|
|
||||||
// Calculate progress for this equipment spell
|
// Calculate progress for this equipment spell
|
||||||
const eSpellCastSpeed = eSpellDef.castSpeed || 1;
|
const eSpellCastSpeed = eSpellDef.castSpeed || 1;
|
||||||
const eProgressPerTick = HOURS_PER_TICK * eSpellCastSpeed * totalAttackSpeed;
|
const eProgressPerTick = HOURS_PER_TICK * eSpellCastSpeed * attackSpeedMult;
|
||||||
let eCastProgress = (eSpell.castProgress || 0) + eProgressPerTick;
|
let eCastProgress = (eSpell.castProgress || 0) + eProgressPerTick;
|
||||||
|
|
||||||
// Process complete casts for equipment spells (safety counter prevents infinite loop)
|
// Process complete casts for equipment spells (safety counter prevents infinite loop)
|
||||||
@@ -277,9 +252,9 @@ export function processCombatTick(
|
|||||||
floorHP = newState.floorHP;
|
floorHP = newState.floorHP;
|
||||||
eCastProgress = 0;
|
eCastProgress = 0;
|
||||||
if (eGuardian) {
|
if (eGuardian) {
|
||||||
logMessages.push(`\u2694\ufe0f ${eGuardian.name} defeated!`);
|
logMessages.push(`⚔️ ${eGuardian.name} defeated!`);
|
||||||
} else if (currentFloor % 5 === 0) {
|
} else if (currentFloor % 5 === 0) {
|
||||||
logMessages.push(`\ud83c\udff0 Floor ${currentFloor - 1} cleared!`);
|
logMessages.push(`🗺️ Floor ${currentFloor - 1} cleared!`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -288,8 +263,48 @@ export function processCombatTick(
|
|||||||
// Update equipment spell state
|
// Update equipment spell state
|
||||||
updatedEquipmentSpellStates[i] = { ...eSpell, castProgress: eCastProgress % 1 };
|
updatedEquipmentSpellStates[i] = { ...eSpell, castProgress: eCastProgress % 1 };
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Golem attacks (spec §9.4) ─────────────────────────────────────────
|
// ─── Melee sword attacks (spec §3.1, §4.3) ────────────────────────────
|
||||||
|
// Melee: no mana cost, no Executioner/Berserker, elemental matchup applies
|
||||||
|
const updatedMeleeSwordProgress = { ...state.meleeSwordProgress };
|
||||||
|
const floorElement = getFloorElement(currentFloor);
|
||||||
|
const guardian = getGuardianForFloor(currentFloor);
|
||||||
|
const floorElems = guardian && guardian.element.length > 0 ? guardian.element : [floorElement];
|
||||||
|
|
||||||
|
|
||||||
|
if (equippedSwords && Object.keys(equippedSwords).length > 0 && floorHP > 0) {
|
||||||
|
for (const [instanceId, swordInstance] of Object.entries(equippedSwords)) {
|
||||||
|
const swordType = EQUIPMENT_TYPES[swordInstance.typeId];
|
||||||
|
if (!swordType || !swordType.stats?.attackSpeed) continue;
|
||||||
|
const swordAttackSpeed = swordType.stats.attackSpeed;
|
||||||
|
const meleeProgressPerTick = HOURS_PER_TICK * swordAttackSpeed * attackSpeedMult;
|
||||||
|
let meleeProgress = (updatedMeleeSwordProgress[instanceId] || 0) + meleeProgressPerTick;
|
||||||
|
let meleeSafetyCounter = 0;
|
||||||
|
while (meleeProgress >= 1 && meleeSafetyCounter < 100) {
|
||||||
|
const meleeDamage = calcMeleeDamage(swordInstance as EquipmentInstance, swordType, floorElems[0]);
|
||||||
|
const finalMeleeDamage = applyEnemyDefenses(meleDamage, null, 'combat', (msg) => logMessages.push(msg));
|
||||||
|
if (!Number.isFinite(finalMeleeDamage)) break;
|
||||||
|
floorHP = Math.max(0, floorHP - finalMeleeDamage);
|
||||||
|
meleeProgress -= 1;
|
||||||
|
meleeSafetyCounter++;
|
||||||
|
if (floorHP <= 0) {
|
||||||
|
const g = getGuardianForFloor(currentFloor);
|
||||||
|
onFloorCleared(currentFloor, !!g);
|
||||||
|
get().advanceRoomOrFloor();
|
||||||
|
const ns = get();
|
||||||
|
currentFloor = ns.currentFloor;
|
||||||
|
floorMaxHP = ns.floorMaxHP;
|
||||||
|
floorHP = ns.floorHP;
|
||||||
|
meleeProgress = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updatedMeleeSwordProgress[instanceId] = meleeProgress % 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Golem attacks (spec §9.4) ───────────────────────────────────────────
|
||||||
// Golems attack after spells, using the same damage pipeline.
|
// Golems attack after spells, using the same damage pipeline.
|
||||||
// They ignore Executioner/Berserker (handled internally by processGolemAttacks).
|
// They ignore Executioner/Berserker (handled internally by processGolemAttacks).
|
||||||
if (activeGolems.length > 0 && floorHP > 0) {
|
if (activeGolems.length > 0 && floorHP > 0) {
|
||||||
@@ -315,7 +330,7 @@ export function processCombatTick(
|
|||||||
currentFloor = postGolemState.currentFloor;
|
currentFloor = postGolemState.currentFloor;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── DoT/Debuff tick processing (spec §6.3) ──────────────────────────
|
// ─── DoT/Debuff tick processing (spec §6.3) ─────────────────────────────
|
||||||
// Process after all weapon/golem attacks
|
// Process after all weapon/golem attacks
|
||||||
if (floorHP > 0) {
|
if (floorHP > 0) {
|
||||||
const doTDamage = processDoTPhase(get, set, applyEnemyDefenses, logMessages);
|
const doTDamage = processDoTPhase(get, set, applyEnemyDefenses, logMessages);
|
||||||
@@ -347,12 +362,13 @@ export function processCombatTick(
|
|||||||
castProgress,
|
castProgress,
|
||||||
equipmentSpellStates: updatedEquipmentSpellStates,
|
equipmentSpellStates: updatedEquipmentSpellStates,
|
||||||
activeGolems,
|
activeGolems,
|
||||||
|
meleeSwordProgress: updatedMeleeSwordProgress,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Return safe defaults on error — combat tick should never crash the game
|
// Return safe defaults on error — combat tick should never crash the game
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
logMessages.push(`⚠️ Combat error: ${errorMsg}`);
|
logMessages.push(`⚠️ Combat error: ${errorMsg}`);
|
||||||
return makeDefaultCombatTickResult(rawMana, elements, state, activeGolems);
|
return makeDefaultCombatTickResult(rawMana, elements, state, golemancyState.activeGolems);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// ─── Combat State Types ────────────────────────────────────────────────────────
|
// ─── Combat State Types ────────────────────────────────────────────────────────
|
||||||
// Shared types for combat store and combat actions to avoid circular dependency
|
// Shared types for combat store and combat actions to avoid circular dependency
|
||||||
|
|
||||||
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem, EnemyState } from '../types';
|
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem, EnemyState, EquipmentInstance } from '../types';
|
||||||
|
|
||||||
// ─── Combat State (data only) ─────────────────────────────────────────────────
|
// ─── Combat State (data only) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -58,6 +58,9 @@ export interface CombatState {
|
|||||||
comboHitCount: number;
|
comboHitCount: number;
|
||||||
floorHitCount: number;
|
floorHitCount: number;
|
||||||
|
|
||||||
|
// Melee sword progress accumulators (spec §3.1)
|
||||||
|
meleeSwordProgress: Record<string, number>;
|
||||||
|
|
||||||
// Guardian defensive state (shield, barrier, regen)
|
// Guardian defensive state (shield, barrier, regen)
|
||||||
guardianShield: number;
|
guardianShield: number;
|
||||||
guardianShieldMax: number;
|
guardianShieldMax: number;
|
||||||
@@ -153,6 +156,7 @@ export interface CombatActions {
|
|||||||
bypassArmor?: boolean,
|
bypassArmor?: boolean,
|
||||||
bypassBarrier?: boolean,
|
bypassBarrier?: boolean,
|
||||||
) => number,
|
) => number,
|
||||||
|
equippedSwords?: Record<string, EquipmentInstance>,
|
||||||
) => {
|
) => {
|
||||||
rawMana: number;
|
rawMana: number;
|
||||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
@@ -165,6 +169,7 @@ export interface CombatActions {
|
|||||||
castProgress: number;
|
castProgress: number;
|
||||||
equipmentSpellStates: EquipmentSpellState[];
|
equipmentSpellStates: EquipmentSpellState[];
|
||||||
activeGolems: ActiveGolem[];
|
activeGolems: ActiveGolem[];
|
||||||
|
meleeSwordProgress: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { createSafeStorage } from '../utils/safe-persist';
|
import { createSafeStorage } from '../utils/safe-persist';
|
||||||
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem, EnemyState } from '../types';
|
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem, EnemyState, EquipmentInstance } from '../types';
|
||||||
import { getFloorMaxHP } from '../utils';
|
import { getFloorMaxHP } from '../utils';
|
||||||
import { generateFloorState } from '../utils/room-utils';
|
import { generateFloorState } from '../utils/room-utils';
|
||||||
import { addActivityLogEntry } from '../utils/activity-log';
|
import { addActivityLogEntry } from '../utils/activity-log';
|
||||||
@@ -66,6 +66,9 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
comboHitCount: 0,
|
comboHitCount: 0,
|
||||||
floorHitCount: 0,
|
floorHitCount: 0,
|
||||||
|
|
||||||
|
// Melee sword progress accumulators (spec §3.1)
|
||||||
|
meleeSwordProgress: {},
|
||||||
|
|
||||||
// Guardian defensive state
|
// Guardian defensive state
|
||||||
guardianShield: 0,
|
guardianShield: 0,
|
||||||
guardianShieldMax: 0,
|
guardianShieldMax: 0,
|
||||||
@@ -310,6 +313,7 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
bypassArmor?: boolean,
|
bypassArmor?: boolean,
|
||||||
bypassBarrier?: boolean,
|
bypassBarrier?: boolean,
|
||||||
) => number,
|
) => number,
|
||||||
|
equippedSwords?: Record<string, EquipmentInstance>,
|
||||||
) => {
|
) => {
|
||||||
return processCombatTick(
|
return processCombatTick(
|
||||||
get,
|
get,
|
||||||
@@ -324,6 +328,7 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
golemancyState,
|
golemancyState,
|
||||||
golemApplyDamageToRoom,
|
golemApplyDamageToRoom,
|
||||||
applyEnemyDefenses,
|
applyEnemyDefenses,
|
||||||
|
equippedSwords,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -372,6 +377,7 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
guardianShieldMax: state.guardianShieldMax,
|
guardianShieldMax: state.guardianShieldMax,
|
||||||
guardianBarrier: state.guardianBarrier,
|
guardianBarrier: state.guardianBarrier,
|
||||||
guardianBarrierMax: state.guardianBarrierMax,
|
guardianBarrierMax: state.guardianBarrierMax,
|
||||||
|
meleeSwordProgress: state.meleeSwordProgress,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Game Store — coordinator, tick pipeline, time/incursion
|
// Game Store — coordinator, tick pipeline, time/incursion
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { HOURS_PER_TICK, MAX_DAY } from '../constants';
|
import { HOURS_PER_TICK, MAX_DAY, EQUIPMENT_TYPES } from '../constants';
|
||||||
import { computeEquipmentEffects } from '../effects';
|
import { computeEquipmentEffects } from '../effects';
|
||||||
import type { ComputedEffects } from '../effects/upgrade-effects.types';
|
import type { ComputedEffects } from '../effects/upgrade-effects.types';
|
||||||
|
|
||||||
@@ -303,6 +303,19 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
|||||||
const activeEnemy = combatCbs.applyMageBarrierRecharge(primaryEnemy) ?? primaryEnemy;
|
const activeEnemy = combatCbs.applyMageBarrierRecharge(primaryEnemy) ?? primaryEnemy;
|
||||||
const defCtx = { roomType: ctx.combat.currentRoom?.roomType ?? 'combat', enemy: activeEnemy };
|
const defCtx = { roomType: ctx.combat.currentRoom?.roomType ?? 'combat', enemy: activeEnemy };
|
||||||
const golemPipeline = buildGolemCombatPipeline(addLog);
|
const golemPipeline = buildGolemCombatPipeline(addLog);
|
||||||
|
|
||||||
|
// Build equipped swords map for melee auto-attack (spec §3.1)
|
||||||
|
const equippedSwords: Record<string, import('../types').EquipmentInstance> = {};
|
||||||
|
for (const [slot, instanceId] of Object.entries(ctx.crafting.equippedInstances || {})) {
|
||||||
|
if (!instanceId) continue;
|
||||||
|
const inst = ctx.crafting.equipmentInstances?.[instanceId];
|
||||||
|
if (!inst) continue;
|
||||||
|
const eqType = EQUIPMENT_TYPES[inst.typeId];
|
||||||
|
if (eqType?.category === 'sword') {
|
||||||
|
equippedSwords[instanceId] = inst;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const combatResult = useCombatStore.getState().processCombatTick(
|
const combatResult = useCombatStore.getState().processCombatTick(
|
||||||
rawMana, elements, maxMana, 1,
|
rawMana, elements, maxMana, 1,
|
||||||
combatCbs.onFloorCleared,
|
combatCbs.onFloorCleared,
|
||||||
@@ -313,12 +326,13 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
|||||||
(dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier) => applyEnemyDefensesFromPipeline(
|
(dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier) => applyEnemyDefensesFromPipeline(
|
||||||
dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier,
|
dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier,
|
||||||
),
|
),
|
||||||
|
equippedSwords,
|
||||||
);
|
);
|
||||||
rawMana = combatResult.rawMana;
|
rawMana = combatResult.rawMana;
|
||||||
elements = combatResult.elements;
|
elements = combatResult.elements;
|
||||||
totalManaGathered += combatResult.totalManaGathered || 0;
|
totalManaGathered += combatResult.totalManaGathered || 0;
|
||||||
if (combatResult.logMessages) combatResult.logMessages.forEach(msg => addLog(msg));
|
if (combatResult.logMessages) combatResult.logMessages.forEach(msg => addLog(msg));
|
||||||
writes.combat = { ...(writes.combat || {}), currentFloor: combatResult.currentFloor, floorHP: combatResult.floorHP, floorMaxHP: combatResult.floorMaxHP, maxFloorReached: combatResult.maxFloorReached, castProgress: combatResult.castProgress, equipmentSpellStates: combatResult.equipmentSpellStates, golemancy: { ...(writes.combat?.golemancy || ctx.combat.golemancy), activeGolems: combatResult.activeGolems } };
|
writes.combat = { ...(writes.combat || {}), currentFloor: combatResult.currentFloor, floorHP: combatResult.floorHP, floorMaxHP: combatResult.floorMaxHP, maxFloorReached: combatResult.maxFloorReached, castProgress: combatResult.castProgress, equipmentSpellStates: combatResult.equipmentSpellStates, golemancy: { ...(writes.combat?.golemancy || ctx.combat.golemancy), activeGolems: combatResult.activeGolems }, meleeSwordProgress: combatResult.meleeSwordProgress };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.combat.currentAction === 'craft') {
|
if (ctx.combat.currentAction === 'craft') {
|
||||||
|
|||||||
@@ -273,6 +273,55 @@ export function deductSpellCost(
|
|||||||
return { rawMana, elements: newElements };
|
return { rawMana, elements: newElements };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Melee Damage Calculation (spec §4.3) ────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map from sword enchantment specialId to element type for elemental matchup.
|
||||||
|
*/
|
||||||
|
const SWORD_ENCHANT_ELEMENT: Record<string, string> = {
|
||||||
|
fireBlade: 'fire',
|
||||||
|
frostBlade: 'frost',
|
||||||
|
lightningBlade: 'lightning',
|
||||||
|
voidBlade: 'void',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate melee damage for a sword attack (spec §4.3).
|
||||||
|
*
|
||||||
|
* Formula: baseDmg = sword.baseDamage + sword.elementalEnchantDamage
|
||||||
|
* damage = baseDmg × getElementalBonus(sword.enchantElement, enemy.element)
|
||||||
|
*
|
||||||
|
* No crit, no discipline damage bonus for melee in v1.
|
||||||
|
* attackSpeedMult from equipment does apply to meleeProgress accumulation.
|
||||||
|
*/
|
||||||
|
export function calcMeleeDamage(
|
||||||
|
swordInstance: EquipmentInstance,
|
||||||
|
swordType: { stats?: { baseDamage?: number } },
|
||||||
|
enemyElement: string,
|
||||||
|
): number {
|
||||||
|
const baseDmg = swordType.stats?.baseDamage || 5;
|
||||||
|
|
||||||
|
// Determine enchant element from sword's enchantments
|
||||||
|
let enchantElement: string | null = null;
|
||||||
|
for (const ench of swordInstance.enchantments) {
|
||||||
|
const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
|
||||||
|
if (effectDef?.effect.type === 'special' && effectDef.effect.specialId) {
|
||||||
|
const elem = SWORD_ENCHANT_ELEMENT[effectDef.effect.specialId];
|
||||||
|
if (elem) {
|
||||||
|
enchantElement = elem;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply elemental bonus if sword has an elemental enchant
|
||||||
|
if (enchantElement) {
|
||||||
|
return baseDmg * getElementalBonus(enchantElement, enemyElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseDmg;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Equipment Spell Helpers ──────────────────────────────────────────────────
|
// ─── Equipment Spell Helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
// Return type for active equipment spells with source equipment
|
// Return type for active equipment spells with source equipment
|
||||||
|
|||||||
Reference in New Issue
Block a user