feat: implement Transference Channel system for Enchanter attunement
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
- Add isChanneling, channelSpeedMultiplier, channelDrainRate to CombatState - Add startChanneling/stopChanneling actions to combat store - Add transference-channeling discipline with 3 perks (channel-efficiency, channel-power, channel-mastery) - Add channelIntensity and channelEfficiency to KNOWN_BONUS_STATS - Create combat-channel.ts with drain + speed multiplier computation - Apply channel speed multiplier to equipment spells and melee attacks - Add Channel Transference hold-button UI to SpireCombatPage - Add compact channel status indicator to SpireCombatControls - Channel state resets on spire exit, persists across room transitions - All 1235 existing tests pass
This commit is contained in:
@@ -9,6 +9,8 @@ import { DebugName } from '@/components/game/debug/debug-context';
|
||||
|
||||
interface SpireCombatControlsProps {
|
||||
castProgress: number;
|
||||
isChanneling: boolean;
|
||||
channelSpeedMultiplier: number;
|
||||
}
|
||||
|
||||
function formatSpellCost(cost: { type: 'raw' | 'element'; element?: string; amount: number }): string {
|
||||
@@ -17,7 +19,7 @@ function formatSpellCost(cost: { type: 'raw' | 'element'; element?: string; amou
|
||||
return `${cost.amount} ${elemDef?.sym || '?'}`;
|
||||
}
|
||||
|
||||
export function SpireCombatControls({ castProgress }: SpireCombatControlsProps) {
|
||||
export function SpireCombatControls({ castProgress, isChanneling, channelSpeedMultiplier }: SpireCombatControlsProps) {
|
||||
const spells = useCombatStore((s) => s.spells);
|
||||
const activeSpell = useCombatStore((s) => s.activeSpell);
|
||||
const setSpell = useCombatStore((s) => s.setSpell);
|
||||
@@ -66,6 +68,18 @@ export function SpireCombatControls({ castProgress }: SpireCombatControlsProps)
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Transference Channel Status Indicator */}
|
||||
{isChanneling && (
|
||||
<Card className="border-teal-700 bg-teal-950/40 ring-1 ring-teal-500/50 shadow-[0_0_12px_rgba(26,188,156,0.3)]">
|
||||
<CardContent className="p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-teal-300">⚡ Channeling</span>
|
||||
<span className="text-xs text-teal-400">{channelSpeedMultiplier.toFixed(2)}×</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Active Spell Panel */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCombatStore, useManaStore, usePrestigeStore, useGameStore, fmt, comp
|
||||
import { computeDisciplineEffects } from '@/lib/game/effects/discipline-effects';
|
||||
import { getUnifiedEffects } from '@/lib/game/effects';
|
||||
import { useCraftingStore } from '@/lib/game/stores/craftingStore';
|
||||
import { useAttunementStore } from '@/lib/game/stores/attunementStore';
|
||||
import { SpireHeader } from './SpireHeader';
|
||||
import { RoomDisplay } from './RoomDisplay';
|
||||
import { SpireCombatControls } from './SpireCombatControls';
|
||||
@@ -15,6 +16,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { getGuardianForFloor } from '@/lib/game/data/guardian-encounters';
|
||||
import { SPELLS_DEF } from '@/lib/game/constants';
|
||||
import { computeChannelStats } from '@/lib/game/stores/combat-channel';
|
||||
|
||||
// ─── Derived Stats Hook ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -124,6 +126,7 @@ export function SpireCombatPage() {
|
||||
const {
|
||||
currentFloor,
|
||||
castProgress,
|
||||
currentAction,
|
||||
isDescending,
|
||||
currentRoom,
|
||||
activityLog,
|
||||
@@ -141,9 +144,13 @@ export function SpireCombatPage() {
|
||||
stayLongerInRoom,
|
||||
invocationCharge,
|
||||
activeInvocation,
|
||||
isChanneling,
|
||||
startChanneling,
|
||||
stopChanneling,
|
||||
} = useCombatStore(useShallow((s) => ({
|
||||
currentFloor: s.currentFloor,
|
||||
castProgress: s.castProgress,
|
||||
currentAction: s.currentAction,
|
||||
isDescending: s.isDescending,
|
||||
currentRoom: s.currentRoom,
|
||||
activityLog: s.activityLog,
|
||||
@@ -161,6 +168,9 @@ export function SpireCombatPage() {
|
||||
stayLongerInRoom: s.stayLongerInRoom,
|
||||
invocationCharge: s.invocationCharge,
|
||||
activeInvocation: s.activeInvocation,
|
||||
isChanneling: s.isChanneling,
|
||||
startChanneling: s.startChanneling,
|
||||
stopChanneling: s.stopChanneling,
|
||||
})));
|
||||
|
||||
const { rawMana, elements } = useManaStore(useShallow((s) => ({
|
||||
@@ -180,6 +190,11 @@ export function SpireCombatPage() {
|
||||
|
||||
const day = useGameStore((s) => s.day);
|
||||
const hour = useGameStore((s) => s.hour);
|
||||
const enchanterActive = useAttunementStore((s) => s.attunements?.enchanter?.active ?? false);
|
||||
const transference = elements.transference;
|
||||
const channelStats = computeChannelStats();
|
||||
|
||||
const showChannelButton = enchanterActive && currentAction === 'climb' && transference && transference.current > 0;
|
||||
|
||||
const { maxMana, baseRegen } = useSpireStats(prestigeUpgrades, equippedInstances, equipmentInstances);
|
||||
|
||||
@@ -198,6 +213,18 @@ export function SpireCombatPage() {
|
||||
exitSpireMode();
|
||||
};
|
||||
|
||||
const handleChannelMouseDown = () => {
|
||||
startChanneling();
|
||||
};
|
||||
|
||||
const handleChannelMouseUp = () => {
|
||||
stopChanneling();
|
||||
};
|
||||
|
||||
const handleChannelMouseLeave = () => {
|
||||
if (isChanneling) stopChanneling();
|
||||
};
|
||||
|
||||
return (
|
||||
<DebugName name="SpireCombatPage">
|
||||
<div className="min-h-screen bg-gray-950 flex flex-col">
|
||||
@@ -228,12 +255,53 @@ export function SpireCombatPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invocation Panel (§11.1) */}
|
||||
{/* Invocation Panel */}
|
||||
<InvocationPanel
|
||||
invocationCharge={invocationCharge}
|
||||
activeInvocation={activeInvocation}
|
||||
/>
|
||||
|
||||
{/* Transference Channel Button */}
|
||||
{showChannelButton && (
|
||||
<Card className={`border-teal-700 transition-all ${isChanneling ? 'bg-teal-950/40 ring-1 ring-teal-500/50 shadow-[0_0_12px_rgba(26,188,156,0.3)]' : 'bg-gray-900/80 border-gray-700'}`}>
|
||||
<CardContent className="p-3 space-y-2">
|
||||
<button
|
||||
onMouseDown={handleChannelMouseDown}
|
||||
onMouseUp={handleChannelMouseUp}
|
||||
onMouseLeave={handleChannelMouseLeave}
|
||||
onTouchStart={handleChannelMouseDown}
|
||||
onTouchEnd={handleChannelMouseUp}
|
||||
className={`w-full py-2 px-4 rounded font-medium text-sm transition-all ${
|
||||
isChanneling
|
||||
? 'bg-teal-600 text-white shadow-[0_0_16px_rgba(26,188,156,0.5)]'
|
||||
: 'bg-teal-800/60 text-teal-200 hover:bg-teal-700/60 border border-teal-700/50'
|
||||
}`}
|
||||
>
|
||||
🔗 {isChanneling ? 'Channeling...' : 'Channel Transference'}
|
||||
</button>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-400">Transference</span>
|
||||
<span className="text-teal-300">
|
||||
{fmt(transference.current)} / {fmt(transference.max)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={(transference.current / transference.max) * 100}
|
||||
className="h-1.5 bg-gray-800"
|
||||
style={{ '--progress-bg': '#1ABC9C' } as React.CSSProperties}
|
||||
/>
|
||||
{isChanneling && (
|
||||
<div className="flex items-center justify-between text-[10px] text-gray-500">
|
||||
<span>⚡ {channelStats.speedMultiplier.toFixed(2)}× speed</span>
|
||||
<span>Draining {(channelStats.drainRate * 5).toFixed(2)}/s</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="lg:col-span-2">
|
||||
<RoomDisplay
|
||||
@@ -248,7 +316,11 @@ export function SpireCombatPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<SpireCombatControls castProgress={castProgress} />
|
||||
<SpireCombatControls
|
||||
castProgress={castProgress}
|
||||
isChanneling={isChanneling}
|
||||
channelSpeedMultiplier={channelStats.speedMultiplier}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -143,4 +143,43 @@ export const enchanterDisciplines: DisciplineDefinition[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'transference-channeling',
|
||||
name: 'Transference Channeling',
|
||||
attunement: DisciplinesAttunementType.ENCHANTER,
|
||||
manaType: 'transference',
|
||||
baseCost: 15,
|
||||
description: 'Channel transference mana through your equipment, making enchanted gear strike and cast faster. Higher mastery costs more mana but grants greater speed.',
|
||||
statBonus: { stat: 'channelIntensity', baseValue: 0.10, label: 'Channel Intensity' },
|
||||
difficultyFactor: 180,
|
||||
scalingFactor: 100,
|
||||
drainBase: 4,
|
||||
perks: [
|
||||
{
|
||||
id: 'channel-efficiency',
|
||||
type: 'once',
|
||||
threshold: 100,
|
||||
value: 0,
|
||||
description: '15% less drain for same speed',
|
||||
bonus: { stat: 'channelEfficiency', amount: 0.15 },
|
||||
},
|
||||
{
|
||||
id: 'channel-power',
|
||||
type: 'infinite',
|
||||
threshold: 200,
|
||||
value: 150,
|
||||
description: '+0.05 Channel Intensity per tier',
|
||||
bonus: { stat: 'channelIntensity', amount: 0.05 },
|
||||
},
|
||||
{
|
||||
id: 'channel-mastery',
|
||||
type: 'capped',
|
||||
threshold: 400,
|
||||
value: 200,
|
||||
maxTier: 3,
|
||||
description: '+0.10 Channel Efficiency per tier (max 3)',
|
||||
bonus: { stat: 'channelEfficiency', amount: 0.10 },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -62,6 +62,8 @@ const KNOWN_BONUS_STATS = new Set([
|
||||
'conversion_soul',
|
||||
'conversion_plasma',
|
||||
'conversion_time',
|
||||
'channelIntensity',
|
||||
'channelEfficiency',
|
||||
]);
|
||||
|
||||
export interface DisciplineEffectsResult {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { processGolemAttacksFromStore } from './golem-combat-helpers';
|
||||
import { applyDamageToRoom, deductWeaponEnchantCosts } from './combat-damage';
|
||||
import { processInvocationTick } from './combat-invocation';
|
||||
import { processMeleeTick } from './combat-melee';
|
||||
import { computeChannelStats, applyChannelDrain, getChannelMultiplier } from './combat-channel';
|
||||
|
||||
// ─── Result Type ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -103,6 +104,16 @@ export function processCombatTick(
|
||||
}
|
||||
|
||||
try {
|
||||
// ─── Transference Channel: update stats + drain ──────────────────────
|
||||
const channelStats = computeChannelStats();
|
||||
set({
|
||||
channelSpeedMultiplier: channelStats.speedMultiplier,
|
||||
channelDrainRate: channelStats.drainRate,
|
||||
});
|
||||
if (state.isChanneling) {
|
||||
applyChannelDrain(get, set, channelStats.drainRate);
|
||||
}
|
||||
|
||||
// ─── Golem maintenance (spec §13) ──────────────────────────────────────
|
||||
const golemDesigns = state.golemancy.golemDesigns || {};
|
||||
const maintenanceResult = processGolemMaintenance(
|
||||
@@ -206,7 +217,8 @@ export function processCombatTick(
|
||||
|
||||
const isESpellAoe = !!eSpellDef.isAoe;
|
||||
const eSpellCastSpeed = eSpellDef.castSpeed || 1;
|
||||
const eProgressPerTick = HOURS_PER_TICK * eSpellCastSpeed * totalAttackSpeed;
|
||||
const channelMult = getChannelMultiplier(get);
|
||||
const eProgressPerTick = HOURS_PER_TICK * eSpellCastSpeed * totalAttackSpeed * channelMult;
|
||||
let eCastProgress = (eSpell.castProgress || 0) + eProgressPerTick;
|
||||
|
||||
let eSafetyCounter = 0;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// ─── Combat Channel Actions ────────────────────────────────────────────────────
|
||||
// Extracted from combatStore.ts to keep the store under the 400-line limit.
|
||||
// Provides startChanneling/stopChanneling action factory for the combat store.
|
||||
|
||||
import type { CombatStore, CombatState } from './combat-state.types';
|
||||
|
||||
type GetFn = () => CombatStore;
|
||||
type SetFn = (state: Partial<CombatState>) => void;
|
||||
|
||||
export function createChannelActions(get: GetFn, set: SetFn) {
|
||||
return {
|
||||
startChanneling: () => {
|
||||
set({ isChanneling: true });
|
||||
},
|
||||
|
||||
stopChanneling: () => {
|
||||
set({ isChanneling: false });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// ─── Transference Channel Combat Logic ─────────────────────────────────────────
|
||||
// Extracted from combat-actions.ts to stay under the 400-line file limit.
|
||||
// Handles Transference mana drain and speed multiplier application.
|
||||
|
||||
import { computeDisciplineEffects } from '../effects/discipline-effects';
|
||||
import { useManaStore } from './manaStore';
|
||||
import type { CombatStore, CombatState } from './combat-state.types';
|
||||
|
||||
// ─── Base Values ───────────────────────────────────────────────────────────────
|
||||
|
||||
const BASE_DRAIN_RATE = 0.08;
|
||||
const BASE_SPEED_MULTIPLIER = 1.5;
|
||||
const CHANNEL_EFFICIENCY_CAP = 0.60;
|
||||
|
||||
// ─── Effective Stat Computation ────────────────────────────────────────────────
|
||||
|
||||
export interface ChannelStats {
|
||||
speedMultiplier: number;
|
||||
drainRate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute effective channel speed multiplier and drain rate from discipline stats.
|
||||
*
|
||||
* Formulas:
|
||||
* channelEfficiency = min(CHANNEL_EFFICIENCY_CAP, sum of efficiency perks)
|
||||
* effectiveSpeedMultiplier = 1.5 + (channelIntensity × 0.5)
|
||||
* effectiveDrainRate = 0.08 × (1 + channelIntensity × 1.0) × (1 - channelEfficiency)
|
||||
*/
|
||||
export function computeChannelStats(): ChannelStats {
|
||||
const effects = computeDisciplineEffects();
|
||||
const channelIntensity = effects.bonuses.channelIntensity || 0;
|
||||
const rawEfficiency = effects.bonuses.channelEfficiency || 0;
|
||||
const channelEfficiency = Math.min(CHANNEL_EFFICIENCY_CAP, rawEfficiency);
|
||||
|
||||
const speedMultiplier = BASE_SPEED_MULTIPLIER + channelIntensity * 0.5;
|
||||
const drainRate = BASE_DRAIN_RATE * (1 + channelIntensity * 1.0) * (1 - channelEfficiency);
|
||||
|
||||
return { speedMultiplier, drainRate };
|
||||
}
|
||||
|
||||
// ─── Channel Drain ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply one tick of Transference drain while channeling.
|
||||
* Returns the amount of Transference actually drained (0 if insufficient mana).
|
||||
*/
|
||||
export function applyChannelDrain(
|
||||
get: () => CombatStore,
|
||||
set: (s: Partial<CombatState>) => void,
|
||||
drainRate: number,
|
||||
): number {
|
||||
const transference = useManaStore.getState().elements.transference;
|
||||
if (!transference || transference.current < drainRate) {
|
||||
set({ isChanneling: false });
|
||||
return 0;
|
||||
}
|
||||
useManaStore.getState().deductElement('transference', drainRate);
|
||||
return drainRate;
|
||||
}
|
||||
|
||||
// ─── Speed Multiplier Application ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the current channel speed multiplier.
|
||||
* Returns 1.0 if not channeling.
|
||||
*/
|
||||
export function getChannelMultiplier(
|
||||
get: () => CombatStore,
|
||||
): number {
|
||||
const state = get();
|
||||
return state.isChanneling ? state.channelSpeedMultiplier : 1.0;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { EquipmentInstance } from '../types';
|
||||
import { getFloorElement, getMultiElementBonus, calcMeleeDamage } from '../utils';
|
||||
import { getGuardianForFloor } from '../data/guardian-encounters';
|
||||
import { applyDamageToRoom, deductWeaponEnchantCosts } from './combat-damage';
|
||||
import { getChannelMultiplier } from './combat-channel';
|
||||
|
||||
export interface MeleeTickParams {
|
||||
get: () => CombatStore;
|
||||
@@ -61,7 +62,8 @@ export function processMeleeTick(
|
||||
const swordType = EQUIPMENT_TYPES[swordInstance.typeId];
|
||||
if (!swordType || !swordType.stats?.attackSpeed) continue;
|
||||
const swordAttackSpeed = swordType.stats.attackSpeed;
|
||||
const meleeProgressPerTick = HOURS_PER_TICK * swordAttackSpeed * attackSpeedMult;
|
||||
const channelMult = getChannelMultiplier(get);
|
||||
const meleeProgressPerTick = HOURS_PER_TICK * swordAttackSpeed * attackSpeedMult * channelMult;
|
||||
let meleeProgress = (updatedMeleeSwordProgress[instanceId] || 0) + meleeProgressPerTick;
|
||||
let meleeSafetyCounter = 0;
|
||||
while (meleeProgress >= 1 && meleeSafetyCounter < 100) {
|
||||
|
||||
@@ -63,5 +63,10 @@ export function createDefaultCombatState(
|
||||
totalSpellsCast: 0,
|
||||
totalDamageDealt: 0,
|
||||
totalCraftsCompleted: 0,
|
||||
|
||||
// Transference Channel defaults
|
||||
isChanneling: false,
|
||||
channelSpeedMultiplier: 1.5,
|
||||
channelDrainRate: 0.08,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,6 +96,11 @@ export interface CombatState {
|
||||
// Invocation system
|
||||
invocationCharge: number;
|
||||
activeInvocation: ActiveInvocation | null;
|
||||
|
||||
// ─── Transference Channel ──────────────────────────────────────────────
|
||||
isChanneling: boolean;
|
||||
channelSpeedMultiplier: number;
|
||||
channelDrainRate: number;
|
||||
}
|
||||
|
||||
// ─── Combat Actions ───────────────────────────────────────────────────────────
|
||||
@@ -199,6 +204,10 @@ export interface CombatActions {
|
||||
// Invocation
|
||||
resetInvocationState: () => void;
|
||||
|
||||
// Transference Channel
|
||||
startChanneling: () => void;
|
||||
stopChanneling: () => void;
|
||||
|
||||
// Reset
|
||||
resetCombat: (startFloor: number, spellsToKeep?: string[]) => void;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useDisciplineStore } from './discipline-slice';
|
||||
import {
|
||||
addGolemDesign, removeGolemDesign, toggleGolemLoadoutEntry,
|
||||
} from './golemancy-actions';
|
||||
import { createChannelActions } from './combat-channel-actions';
|
||||
|
||||
export const useCombatStore = create<CombatStore>()(
|
||||
persist(
|
||||
@@ -108,6 +109,11 @@ export const useCombatStore = create<CombatStore>()(
|
||||
invocationCharge: 0,
|
||||
activeInvocation: null,
|
||||
|
||||
// Transference Channel state
|
||||
isChanneling: false,
|
||||
channelSpeedMultiplier: 1.5,
|
||||
channelDrainRate: 0.08,
|
||||
|
||||
setCurrentFloor: (floor: number) => {
|
||||
set({
|
||||
currentFloor: floor,
|
||||
@@ -217,6 +223,7 @@ export const useCombatStore = create<CombatStore>()(
|
||||
golemancy: { ...s.golemancy, activeGolems: [] as RuntimeActiveGolem[] },
|
||||
invocationCharge: 0,
|
||||
activeInvocation: null,
|
||||
isChanneling: false,
|
||||
});
|
||||
// Deactivate all disciplines on spire exit for safety
|
||||
useDisciplineStore.getState().deactivateAll();
|
||||
@@ -290,6 +297,8 @@ export const useCombatStore = create<CombatStore>()(
|
||||
set({ invocationCharge: 0, activeInvocation: null });
|
||||
},
|
||||
|
||||
...createChannelActions(get, set),
|
||||
|
||||
initGuardianDefensiveState: () => {
|
||||
const state = get();
|
||||
const guardian = getGuardianForFloor(state.currentFloor);
|
||||
@@ -378,6 +387,9 @@ export const useCombatStore = create<CombatStore>()(
|
||||
runId: state.runId,
|
||||
invocationCharge: state.invocationCharge,
|
||||
activeInvocation: state.activeInvocation,
|
||||
isChanneling: state.isChanneling,
|
||||
channelSpeedMultiplier: state.channelSpeedMultiplier,
|
||||
channelDrainRate: state.channelDrainRate,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user