feat: implement Transference Channel system for Enchanter attunement
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:
2026-06-14 21:56:20 +02:00
parent 505481cefc
commit 718aed38b1
16 changed files with 1286 additions and 9 deletions
@@ -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 {
+13 -1
View File
@@ -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 });
},
};
}
+73
View File
@@ -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;
}
+3 -1
View File
@@ -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) {
+5
View File
@@ -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;
}
+12
View File
@@ -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,
}),
}
)