+
{gameClass.name[0]}
-
{gameClass.name} Tree
-
Shape Your Healing Style
+
{gameClass.name} Effects
+
Modify Your Spells
- {profile.character.talentPoints}
- Available
- {classPointsSpent} spent in this tree
+ {selectedEffects.length}/{capacity}
+ Active
+ Slots unlock at levels 5, 10, 15, 20
-
- {tierPages.map((pageTiers, index) => (
- setTalentPage(index)}
- role="tab"
- type="button"
- >
- Tiers {pageTiers[0]}-{pageTiers[pageTiers.length - 1]}
-
- ))}
-
+ {!isEffectClass ? (
+
+
Spell effects coming soon for {gameClass.name}.
+
This replacement system starts with the first class.
+
+ ) : (
+
+
+ Active Slots
+ {EFFECT_SLOT_LEVELS.map((level, index) => {
+ const effect = selectedEffects[index]
+ const unlocked = profile.character.level >= level
+ return (
+ effect && setSelectedTalentId(effect.id)}
+ type="button"
+ >
+ Lv {level}
+ {effect?.name ?? (unlocked ? 'Empty Slot' : 'Locked')}
+ {effect?.description ?? (unlocked ? 'Choose an effect from the pool.' : `Reach level ${level}.`)}
+
+ )
+ })}
+
-
- {visibleTiers.map((tier) => {
- const requiredPoints = (tier - 1) * 5
- return (
-
-
-
Tier {tier}
-
- {tier === 1 ? 'Open' : `${requiredPoints} earlier-tier points`}
-
+
+
+
+
Effect Pool
+
Choose and Swap
-
- {gameClass.talents
- .filter((talent) => talent.tier === tier)
- .sort((a, b) => a.branch - b.branch)
- .map((talent) => {
- const reason = lockReason(talent)
- const isBusy = busyTalentId === talent.id
- return (
-
0 ? 'invested' : ''}`}
- key={talent.id}
- style={{ gridColumn: talent.branch }}
- >
-
-
{talent.glyph}
-
- {talent.name}
- Rank {talent.rank}/{talent.maxRank}
-
-
- {talent.description}
-
- {Array.from({ length: talent.maxRank }, (_, index) => (
-
- ))}
-
- purchaseRank(talent)}
- type="button"
- >
- {isBusy ? 'Saving...' : reason || 'Add Rank'}
-
-
- )
- })}
-
-
- )
- })}
-
+ {selectedEffects.length}/{capacity} active
+
+
+ {gameClass.talents.map((talent) => {
+ const reason = lockReason(talent)
+ const active = talent.rank > 0
+ const selected = selectedTalent?.id === talent.id
+ const isBusy = busyTalentId === talent.id
+ return (
+
{
+ setSelectedTalentId(talent.id)
+ void toggleEffect(talent)
+ }}
+ type="button"
+ >
+ {talent.glyph}
+
+ {talent.name}
+ {talent.description}
+
+ {isBusy ? 'Saving' : active ? 'Active' : reason || 'Available'}
+
+ )
+ })}
+
+
+
+
+
+ )}
- {message || 'Talent changes are saved immediately.'}
+ {message || 'Spell effect changes are saved immediately.'}
- {resetting ? 'Refunding...' : 'Reset Tree'}
+ {resetting ? 'Clearing...' : 'Clear Effects'}
>
diff --git a/src/dualScreen.tsx b/src/dualScreen.tsx
index 10d1ce6..928cbaf 100644
--- a/src/dualScreen.tsx
+++ b/src/dualScreen.tsx
@@ -54,6 +54,7 @@ export type DualScreenCombatState = {
directPartyTargeting: boolean
paused: boolean
targetGroup: 0 | 1 | 2
+ speedMultiplier: 1 | 2
}
export type DualScreenWorkshopState = {
@@ -121,7 +122,7 @@ function loadRecentSnapshot() {
function memberHotEffects(member: PartyMember) {
if (member.hotEffects?.length) return member.hotEffects
return member.hotTicks > 0
- ? [{ id: 'legacy-renew', label: 'Renew', ticks: member.hotTicks }]
+ ? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }]
: []
}
@@ -445,6 +446,7 @@ export function DualScreenBottomDisplay() {
{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}
+ {state.speedMultiplier === 2 &&
2x speed }
diff --git a/src/game.ts b/src/game.ts
index 88a1e47..fce0782 100644
--- a/src/game.ts
+++ b/src/game.ts
@@ -10,6 +10,7 @@ export type PartyMember = {
hotTicks: number
hotEffects?: Array<{
id: string
+ spellId: string
label: string
ticks: number
power: number
diff --git a/src/gameRepository.ts b/src/gameRepository.ts
index 431f212..cb7bc94 100644
--- a/src/gameRepository.ts
+++ b/src/gameRepository.ts
@@ -428,6 +428,10 @@ function scaledPvpBossExperience(
return { experience, level }
}
+function talentEffectCapacity(level: number) {
+ return Math.min(4, Math.max(0, Math.floor(level / 5)))
+}
+
type ComponentTemplate = { id: number; slug: string; name: string; itemLevel: number; glyph: string; description: string }
const COMPONENT_ITEMS: Record
= {
1: { id: 600, slug: 'minor-component', name: 'Minor Component', itemLevel: 1, glyph: '◆', description: 'A basic crafting component.' },
@@ -1102,6 +1106,24 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
)!
const talent = gameClass.talents.find((candidate) => candidate.id === talentId)
if (!talent) throw new Error('That talent does not belong to the active class.')
+ if (save.activeClassId === 1) {
+ if ((cd.talentRanks[String(talentId)] ?? 0) > 0) {
+ cd.talentRanks[String(talentId)] = 0
+ } else {
+ const capacity = talentEffectCapacity(cd.level)
+ if (capacity <= 0) throw new Error('Spell effects unlock at level 5.')
+ const activeCount = gameClass.talents.reduce(
+ (total, candidate) => total + ((cd.talentRanks[String(candidate.id)] ?? 0) > 0 ? 1 : 0),
+ 0,
+ )
+ if (activeCount >= capacity) {
+ throw new Error(`Level ${cd.level} allows ${capacity} active spell effect${capacity === 1 ? '' : 's'}.`)
+ }
+ cd.talentRanks[String(talentId)] = 1
+ }
+ store.writeSave(save)
+ return buildProfile(save)
+ }
if (cd.talentPoints <= 0) {
throw new Error('No talent points are available.')
}
@@ -1144,10 +1166,12 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
for (const talent of gameClass.talents) {
cd.talentRanks[String(talent.id)] = 0
}
- cd.talentPoints = Math.min(
- profile.maxTalentPoints,
- cd.talentPoints + refunded,
- )
+ if (save.activeClassId !== 1) {
+ cd.talentPoints = Math.min(
+ profile.maxTalentPoints,
+ cd.talentPoints + refunded,
+ )
+ }
store.writeSave(save)
return buildProfile(save)
},
diff --git a/src/input.tsx b/src/input.tsx
index e4da847..bee8e94 100644
--- a/src/input.tsx
+++ b/src/input.tsx
@@ -35,6 +35,7 @@ export const INPUT_ACTIONS = [
'targetParty5',
'targetParty6',
'toggleTargetGroup',
+ 'toggleSpeed',
'pause',
] as const
@@ -63,6 +64,7 @@ export const ACTION_LABELS: Record = {
targetParty5: 'Target Party Member 5',
targetParty6: 'Target Party Member 6',
toggleTargetGroup: 'Switch Raid Target Group',
+ toggleSpeed: 'Toggle 2x Speed',
pause: 'Pause Menu',
}
@@ -89,6 +91,7 @@ export const DEFAULT_BINDINGS: Record = {
targetParty5: 'F5',
targetParty6: 'F6',
toggleTargetGroup: 'Tab',
+ toggleSpeed: 'Backquote',
pause: 'Escape',
},
controller: {
@@ -111,8 +114,9 @@ export const DEFAULT_BINDINGS: Record = {
targetParty3: 'Button15',
targetParty4: 'Button13',
targetParty5: 'Button4',
- targetParty6: 'Button11',
+ targetParty6: 'Button10',
toggleTargetGroup: 'Button6',
+ toggleSpeed: 'Button11',
pause: 'Button9',
},
}
@@ -145,7 +149,8 @@ const InputContext = createContext(null)
function loadBindings(): Record {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial>>
- const controller = { ...DEFAULT_BINDINGS.controller, ...saved.controller }
+ const savedController = saved.controller
+ const controller = { ...DEFAULT_BINDINGS.controller, ...savedController }
const usesLegacyAbilityDefaults = [
'Button2',
'Button3',
@@ -166,6 +171,15 @@ function loadBindings(): Record {
ability6: DEFAULT_BINDINGS.controller.ability6,
})
}
+ if (savedController?.toggleSpeed === 'Button7') {
+ controller.toggleSpeed = DEFAULT_BINDINGS.controller.toggleSpeed
+ }
+ if (savedController?.ability6 === 'Button10') {
+ controller.ability6 = DEFAULT_BINDINGS.controller.ability6
+ }
+ if (savedController?.targetParty6 === 'Button11') {
+ controller.targetParty6 = DEFAULT_BINDINGS.controller.targetParty6
+ }
return {
pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
controller,
@@ -504,9 +518,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
'targetParty5',
'targetParty6',
'toggleTargetGroup',
+ 'toggleSpeed',
] satisfies InputAction[]
const combatPriority = [
'pause',
+ 'toggleSpeed',
'ability1',
'ability2',
'ability3',
diff --git a/src/offline-starter-profile.json b/src/offline-starter-profile.json
index 8b72c34..305480c 100644
--- a/src/offline-starter-profile.json
+++ b/src/offline-starter-profile.json
@@ -147,154 +147,137 @@
{
"id": 1,
"classId": 1,
- "slug": "bright-reserves",
- "name": "Bright Reserves",
- "maxRank": 5,
+ "slug": "shield-applies-renew",
+ "name": "Shield applies Renew",
+ "maxRank": 1,
"tier": 1,
"branch": 1,
"prerequisiteTalentId": null,
"prerequisiteRank": 0,
"prerequisiteName": null,
- "effectType": "max_resource",
- "effectValuePerRank": 2,
- "glyph": "M",
- "description": "Increases maximum Mana by 2 per rank.",
+ "effectType": "shield_applies_renew",
+ "effectValuePerRank": 0,
+ "glyph": "~",
+ "description": "Sun Ward also applies Renew to the target.",
"rank": 0
},
{
"id": 2,
"classId": 1,
- "slug": "gentle-dawn",
- "name": "Gentle Dawn",
- "maxRank": 5,
+ "slug": "mend-applies-renew",
+ "name": "Mend applies Renew",
+ "maxRank": 1,
"tier": 1,
"branch": 2,
"prerequisiteTalentId": null,
"prerequisiteRank": 0,
"prerequisiteName": null,
- "effectType": "hot_power_percent",
- "effectValuePerRank": 2,
+ "effectType": "mend_applies_renew",
+ "effectValuePerRank": 0,
"glyph": "~",
- "description": "Increases healing-over-time power by 2% per rank.",
+ "description": "Mend also applies Renew to the target.",
"rank": 0
},
{
"id": 10,
"classId": 1,
- "slug": "steady-hands",
- "name": "Steady Hands",
- "maxRank": 5,
+ "slug": "mend-adds-shield",
+ "name": "Mend adds Shield",
+ "maxRank": 1,
"tier": 1,
"branch": 3,
"prerequisiteTalentId": null,
"prerequisiteRank": 0,
"prerequisiteName": null,
- "effectType": "direct_heal_percent",
- "effectValuePerRank": 2,
- "glyph": "+",
- "description": "Increases direct healing by 2% per rank.",
+ "effectType": "mend_applies_shield",
+ "effectValuePerRank": 0,
+ "glyph": "O",
+ "description": "Mend also applies a shield at 50% strength to the target.",
"rank": 0
},
{
"id": 11,
"classId": 1,
- "slug": "overflowing-light",
- "name": "Overflowing Light",
- "maxRank": 5,
- "tier": 2,
- "branch": 1,
- "prerequisiteTalentId": 1,
- "prerequisiteRank": 3,
- "prerequisiteName": "Bright Reserves",
- "effectType": "resource_regen_percent",
- "effectValuePerRank": 2,
+ "slug": "radiance-adds-shield",
+ "name": "Radiance adds Shield",
+ "maxRank": 1,
+ "tier": 1,
+ "branch": 4,
+ "prerequisiteTalentId": null,
+ "prerequisiteRank": 0,
+ "prerequisiteName": null,
+ "effectType": "radiance_applies_shield",
+ "effectValuePerRank": 0,
"glyph": "O",
- "description": "Improves Mana regeneration by 2% per rank. Requires Bright Reserves rank 3.",
+ "description": "Radiance applies a shield at 30% strength to affected party members.",
"rank": 0
},
{
"id": 12,
"classId": 1,
- "slug": "lingering-rays",
- "name": "Lingering Rays",
- "maxRank": 5,
- "tier": 2,
- "branch": 2,
- "prerequisiteTalentId": 2,
- "prerequisiteRank": 3,
- "prerequisiteName": "Gentle Dawn",
- "effectType": "hot_duration_percent",
- "effectValuePerRank": 4,
- "glyph": "L",
- "description": "Extends healing-over-time duration by 4% per rank. Requires Gentle Dawn rank 3.",
+ "slug": "radiance-applies-renew",
+ "name": "Radiance applies Renew",
+ "maxRank": 1,
+ "tier": 1,
+ "branch": 5,
+ "prerequisiteTalentId": null,
+ "prerequisiteRank": 0,
+ "prerequisiteName": null,
+ "effectType": "radiance_applies_renew",
+ "effectValuePerRank": 0,
+ "glyph": "~",
+ "description": "Radiance applies Renew at 50% duration to affected party members.",
"rank": 0
},
{
"id": 13,
"classId": 1,
- "slug": "radiant-precision",
- "name": "Radiant Precision",
- "maxRank": 5,
- "tier": 2,
- "branch": 3,
- "prerequisiteTalentId": 10,
- "prerequisiteRank": 3,
- "prerequisiteName": "Steady Hands",
- "effectType": "critical_heal_percent",
- "effectValuePerRank": 1,
- "glyph": "!",
- "description": "Adds 1% healing critical chance per rank. Requires Steady Hands rank 3.",
+ "slug": "shielded-damage-reduction",
+ "name": "Shielded takes less",
+ "maxRank": 1,
+ "tier": 1,
+ "branch": 6,
+ "prerequisiteTalentId": null,
+ "prerequisiteRank": 0,
+ "prerequisiteName": null,
+ "effectType": "shielded_damage_reduction",
+ "effectValuePerRank": 0,
+ "glyph": "D",
+ "description": "While shielded, the target receives 20% less damage.",
"rank": 0
},
{
"id": 14,
"classId": 1,
- "slug": "sunlit-aegis",
- "name": "Sunlit Aegis",
- "maxRank": 3,
- "tier": 3,
- "branch": 1,
- "prerequisiteTalentId": 11,
- "prerequisiteRank": 5,
- "prerequisiteName": "Overflowing Light",
- "effectType": "absorb_power_percent",
- "effectValuePerRank": 5,
- "glyph": "A",
- "description": "Strengthens absorb effects by 5% per rank. Requires Overflowing Light rank 5.",
+ "slug": "shielded-healing-bonus",
+ "name": "Shielded healing boost",
+ "maxRank": 1,
+ "tier": 1,
+ "branch": 7,
+ "prerequisiteTalentId": null,
+ "prerequisiteRank": 0,
+ "prerequisiteName": null,
+ "effectType": "shielded_healing_bonus",
+ "effectValuePerRank": 0,
+ "glyph": "+",
+ "description": "While shielded, the target receives 20% more healing.",
"rank": 0
},
{
"id": 15,
"classId": 1,
- "slug": "shared-dawn",
- "name": "Shared Dawn",
- "maxRank": 3,
- "tier": 3,
- "branch": 2,
- "prerequisiteTalentId": 12,
- "prerequisiteRank": 5,
- "prerequisiteName": "Lingering Rays",
- "effectType": "party_heal_percent",
- "effectValuePerRank": 5,
- "glyph": "*",
- "description": "Increases party-wide healing by 5% per rank. Requires Lingering Rays rank 5.",
- "rank": 0
- },
- {
- "id": 16,
- "classId": 1,
- "slug": "miracle-worker",
- "name": "Miracle Worker",
+ "slug": "mend-reduces-radiance",
+ "name": "Mend lowers Radiance",
"maxRank": 1,
- "tier": 4,
- "branch": 2,
- "prerequisiteTalentId": 15,
- "prerequisiteRank": 3,
- "prerequisiteName": "Shared Dawn",
- "effectType": "cooldown_reduction_percent",
- "effectValuePerRank": 10,
- "glyph": "S",
- "description": "Reduces major healing cooldowns by 10%. Requires Shared Dawn rank 3.",
+ "tier": 1,
+ "branch": 8,
+ "prerequisiteTalentId": null,
+ "prerequisiteRank": 0,
+ "prerequisiteName": null,
+ "effectType": "mend_reduces_radiance_cooldown",
+ "effectValuePerRank": 0,
+ "glyph": "*",
+ "description": "Casting Mend reduces the cooldown of Radiance by 2 seconds.",
"rank": 0
}
]