Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ src/
│ ├── upload/ # File upload tab
│ ├── character/ # Equipment/inventory display tab
│ ├── filter/ # Attribute search/filtering tab
│ └── items/ # Items browsing tab
│ ├── items/ # Items browsing tab
│ └── skilltree/ # Skill tree display/editing tab
├── hooks/ # Custom React hooks
├── models/ # Clean data models (Item, SkillTree, transformers)
├── utils/ # Core logic (parsing, filtering, WASM)
Expand Down Expand Up @@ -58,16 +59,20 @@ uesave-wasm/pkg/ # Pre-built WASM module (do not modify)
| Skill tree data model | `src/models/SkillTree.js` |
| Skill tree extraction | `src/utils/skillTreeParser.js` |
| Skill/card/keystone registry | `src/utils/skillTreeRegistry.js` |
| Skill tree tab UI | `src/components/skilltree/SkillTreeTab.jsx` |
| Skill tree state/store | `src/hooks/useSkillTreeStore.js` |
| Styling/theming | `src/styles/index.css` |

## Tab Architecture

Tabs defined in `App.jsx` TABS array:
```jsx
{ id: 'upload', label: 'Upload', icon: '📁' }
{ id: 'upload', label: 'Upload', icon: '📂' }
{ id: 'character', label: 'Character', icon: '🧙' }
{ id: 'filter', label: 'Filter', icon: '🔍' }
{ id: 'skilltree', label: 'Skill Tree', icon: '🌳' }
{ id: 'items', label: 'Items', icon: '🎒' }
{ id: 'filter', label: 'Filter', icon: '🔍' }
{ id: 'stats', label: 'Stats', icon: '📊' }
```

**To add a new tab:**
Expand All @@ -87,14 +92,21 @@ App.jsx (state holder)
│ ├── inventory[] → All items from save
│ ├── equippedSlotMap → Items by slot key
│ └── metadata → Filename, load time
├── skillTreeStore → Skill tree store (useSkillTreeStore hook)
│ ├── skillTreeData → Parsed SkillTreeData from save
│ ├── keystoneSelections → Manual keystone checkbox state
│ ├── skillOverrides → Per-skill enable/level overrides
│ ├── skillValues → User-entered stat values
│ ├── effectiveSkillStats → Computed stats for useDerivedStats
│ └── skillConfigOverrides → Computed config overrides for eDPS
├── sharedFilterModel → Decoded filter from URL hash (consumed once)
├── status/statusType → UI feedback messages
├── logs → Debug log buffer
└── wasmReady → WASM initialization flag

Tab callbacks:
- onFileLoaded(data) → Sets saveData, loads itemStore, switches to Character tab
- onClearSave() → Clears saveData and itemStore, returns to Upload tab
- onFileLoaded(data) → Sets saveData, loads itemStore + skillTreeStore, switches to Character tab
- onClearSave() → Clears saveData, itemStore + skillTreeStore, returns to Upload tab
- onLog(msg) → Adds to log buffer
- onStatusChange(msg, type) → Updates status bar
```
Expand Down Expand Up @@ -305,13 +317,28 @@ Save data at `HostPlayerData_0.Struct.Struct.CharacterSkills_77_*` contains 4 sk
- `CRAFTING_SKILL_REGISTRY` - 40 crafting/elven entries mapped to branches
- `CARD_REGISTRY` - 16 card entries (skeleton, effects TBD)
- `TREE_KEYSTONES` - Manually curated main-tree keystones (proximity, mastery, affinity, utility)
- `src/hooks/useSkillTreeStore.js` - Central store for skill tree state and overrides
- `src/components/skilltree/SkillTreeTab.jsx` - Tab UI with sub-components

### Skill Tree Tab (hybrid auto-detect + manual input)
The Skill Tree tab uses a hybrid approach:
- **Auto-detected** from save: weapon stances (94 skills), crafting/elven tree (40 skills), cards (16)
- **Manual checklist**: 14 main-tree keystones (opaque IDs can't be auto-mapped)
- **User-editable values**: skills with `statId` show a value input (flat or percent based on `STAT_REGISTRY.isPercent`)
- **Overrides**: toggle skills on/off, edit paragon levels, enter stat values
- **Integration**: `effectiveSkillStats` and `skillConfigOverrides` feed into `useDerivedStats`

### Main tree keystones (user-input checklist)
Opaque node IDs can't be auto-detected. `TREE_KEYSTONES` provides a checklist of notable effects that overlap with monograms or grant unique bonuses:
- Close/Far Distance (proximity damage), Melee/Ranged Mastery (damage/armor)
- Fire/Arcane/Lightning Affinity (CDR ~35%, damage ~100% additive)
- Extra inventory slots, extra potions

### Skill Tree → eDPS integration
- Affinity damage keystones → `edpsAD.affinityDamage` config override
- Proximity keystones → `edpsEMulti` distance flags
- Weapon/crafting skills with `statId` + user-entered values → base stat aggregation via `useDerivedStats`

### TODO: Card registry
Card effects need population. Cards have L1/L2/L3 base stats; L6 doubles L3 and removes from further choice. Currently stored as skeleton entries with empty effects arrays.

Expand All @@ -330,6 +357,7 @@ npm run test:coverage # With coverage report
- `test/itemTransformer.test.js` - Save file parsing tests
- `test/shareUrl.test.js` - URL sharing encode/decode tests
- `test/skillTreeParser.test.js` - Skill tree parsing/registry tests
- `test/useSkillTreeStore.test.js` - Skill tree store logic tests

### Key Testable Modules
| Module | Pure Functions | Notes |
Expand All @@ -340,6 +368,7 @@ npm run test:coverage # With coverage report
| `shareUrl.js` | `encodeFilterShare()`, `decodeFilterShare()`, `parseShareFromHash()` | URL sharing |
| `skillTreeParser.js` | `extractSkillTree()`, `categorizeSkill()` | Skill tree extraction |
| `skillTreeRegistry.js` | `getWeaponSkillDef()`, `getCraftingSkillDef()`, `getCardDef()` | Skill/card/keystone lookups |
| `useSkillTreeStore.js` | `computeEffectiveStats()`, `computeConfigOverrides()` | Stat aggregation from skills |

## Test Fixtures

Expand Down
23 changes: 20 additions & 3 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ import { CharacterTab } from './components/character';
import { FilterTab } from './components/filter';
import { ItemsTab } from './components/items';
import { StatsTab } from './components/stats';
import { SkillTreeTab } from './components/skilltree';
import { initWasm } from './utils/wasm';
import { detectPlatform } from './utils/platform';
import { useLogger } from './hooks/useLogger';
import { useItemStore } from './hooks/useItemStore';
import { useSkillTreeStore } from './hooks/useSkillTreeStore';
import { parseShareFromHash, decodeFilterShare } from './utils/shareUrl';

const TABS = [
{ id: 'upload', label: 'Upload', icon: '📂' },
{ id: 'character', label: 'Character', icon: '🧙' },
{ id: 'skilltree', label: 'Skill Tree', icon: '🌳' },
{ id: 'items', label: 'Items', icon: '🎒' },
{ id: 'filter', label: 'Filter', icon: '🔍' },
{ id: 'stats', label: 'Stats', icon: '📊' },
Expand All @@ -34,6 +37,9 @@ export default function App() {
// Central item store - all UI reads from here, not from raw saveData
const itemStore = useItemStore();

// Skill tree store - manages skill tree data and user overrides
const skillTreeStore = useSkillTreeStore();

// Initialize WASM and detect platform
useEffect(() => {
async function init() {
Expand Down Expand Up @@ -92,23 +98,26 @@ export default function App() {
setSaveData(data);
// Load items into central store from parsed save data
itemStore.loadFromSave(data.parsed || data.raw, data.filename);
// Load skill tree data
skillTreeStore.loadFromSave(data.parsed || data.raw);
setActiveTab('character');
log(`🎮 Save loaded: ${data.filename}`);
}, [log, itemStore]);

const handleClearSave = useCallback(() => {
setSaveData(null);
itemStore.clear();
skillTreeStore.clear();
setActiveTab('upload');
log('🗑️ Save data cleared');
}, [log, itemStore]);
}, [log, itemStore, skillTreeStore]);

// Determine which tabs are disabled
const disabledTabs = itemStore.hasItems
? []
: filterTabUnlocked
? ['character', 'items']
: ['character', 'items', 'filter'];
? ['character', 'skilltree', 'items']
: ['character', 'skilltree', 'items', 'filter'];

return (
<div className="app">
Expand All @@ -127,11 +136,19 @@ export default function App() {
<CharacterTab
saveData={saveData}
itemStore={itemStore}
skillTreeStore={skillTreeStore}
onClearSave={handleClearSave}
onLog={log}
onStatusChange={handleStatusChange}
/>
)}
{activeTab === 'skilltree' && saveData && (
<SkillTreeTab
skillTreeStore={skillTreeStore}
saveData={saveData}
onLog={log}
/>
)}
{activeTab === 'items' && saveData && (
<ItemsTab saveData={saveData} itemStore={itemStore} onLog={log} />
)}
Expand Down
8 changes: 7 additions & 1 deletion src/components/character/CharacterTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react';
import { Button } from '../common';
import { CharacterPanel } from './CharacterPanel';

export function CharacterTab({ saveData, itemStore, onClearSave, onLog }) {
export function CharacterTab({ saveData, itemStore, skillTreeStore, onClearSave, onLog }) {
// Build characterData from itemStore (central source of truth)
// Falls back to saveData for backward compatibility
const characterData = itemStore?.hasItems ? {
Expand All @@ -17,6 +17,12 @@ export function CharacterTab({ saveData, itemStore, onClearSave, onLog }) {
timestamp: saveData.timestamp,
} : null;

// Attach skill tree effects if available
if (characterData && skillTreeStore?.isLoaded) {
characterData.skillTreeStats = skillTreeStore.effectiveSkillStats;
characterData.skillTreeConfigOverrides = skillTreeStore.skillConfigOverrides;
}

return (
<div className="tab-content active">
<div className="controls" style={{ padding: '0.75rem 1.25rem' }}>
Expand Down
39 changes: 39 additions & 0 deletions src/components/skilltree/CardsSection.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React from 'react';
import { getCardDef } from '../../utils/skillTreeRegistry';
import { getCardsByFamily } from '../../models/SkillTree';

/**
* Crystal cards display (visual only, effects TBD)
*
* @param {Object} props
* @param {Array} props.cards - Card skill entries from save
* @param {Object} props.skillTreeData - Full skill tree data for family grouping
*/
export function CardsSection({ cards, skillTreeData }) {
if (!cards || cards.length === 0) return null;

const families = skillTreeData ? getCardsByFamily(skillTreeData) : {};

return (
<div className="skill-section">
<div className="skill-section-header">
<span className="skill-section-title">Crystal Cards</span>
<span className="skill-section-subtitle">{cards.length} cards allocated (effects TBD)</span>
</div>

<div className="cards-grid">
{cards.map(card => {
const def = getCardDef(card.rowName);
const name = def?.name || card.rowName;

return (
<div key={card.rowName} className="card-item">
<span className="card-name">{name}</span>
<span className="card-level">Lv {card.level}{def?.maxLevel ? ` / ${def.maxLevel}` : ''}</span>
</div>
);
})}
</div>
</div>
);
}
119 changes: 119 additions & 0 deletions src/components/skilltree/CraftingSkillsSection.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import React, { useState, useCallback } from 'react';
import { SkillRow } from './SkillRow';
import { getCraftingSkillDef } from '../../utils/skillTreeRegistry';
import { STAT_REGISTRY } from '../../utils/statRegistry';

const BRANCH_LABELS = {
armor: 'Armor',
damage: 'Damage',
crafting: 'Crafting',
exp: 'Experience',
luck: 'Luck',
timeTear: 'Time Tear',
utility: 'Utility',
};

const BRANCH_ORDER = ['armor', 'damage', 'luck', 'crafting', 'exp', 'timeTear', 'utility'];

/**
* Crafting/Elven skills grouped by branch
*
* @param {Object} props
* @param {Array} props.crafting - Crafting skill entries from save
* @param {Object} props.skillOverrides - { [rowName]: { enabled, level } }
* @param {Function} props.onOverride - (rowName, override) => void
* @param {Object} props.skillValues - { [rowName]: number }
* @param {Function} props.onValueChange - (rowName, value) => void
* @param {Function} props.isSkillEnabled - (rowName) => boolean
* @param {Function} props.getEffectiveLevel - (rowName, saveLevel) => number
*/
export function CraftingSkillsSection({
crafting,
skillOverrides,
onOverride,
skillValues,
onValueChange,
isSkillEnabled,
getEffectiveLevel,
}) {
const [collapsed, setCollapsed] = useState({});

const toggleCollapse = useCallback((branch) => {
setCollapsed(prev => ({ ...prev, [branch]: !prev[branch] }));
}, []);

if (!crafting || crafting.length === 0) return null;

// Group by branch
const grouped = {};
for (const skill of crafting) {
const def = getCraftingSkillDef(skill.rowName);
const branch = def?.branch || 'utility';
if (!grouped[branch]) grouped[branch] = [];
grouped[branch].push(skill);
}

const activeBranches = BRANCH_ORDER.filter(b => grouped[b] && grouped[b].length > 0);

return (
<div className="skill-section">
<div className="skill-section-header">
<span className="skill-section-title">Crafting / Elven Tree</span>
<span className="skill-section-subtitle">Auto-detected from save</span>
</div>

{activeBranches.map(branch => {
const skills = grouped[branch];
const isCollapsed = collapsed[branch];
const label = BRANCH_LABELS[branch] || branch;

return (
<div key={branch} className="weapon-group">
<button
className="weapon-group-header"
onClick={() => toggleCollapse(branch)}
>
<span className="collapse-icon">{isCollapsed ? '▶' : '▼'}</span>
<span className="weapon-label">{label}</span>
<span className="weapon-count">{skills.length} skills</span>
</button>

{!isCollapsed && (
<div className="weapon-skills-list">
{skills.map(skill => {
const def = getCraftingSkillDef(skill.rowName);
const name = def?.name || skill.rowName;
const statId = def?.statId;
const isParagon = def?.paragon;
const statIsPercent = statId ? (STAT_REGISTRY[statId]?.isPercent ?? true) : true;
const enabled = isSkillEnabled(skill.rowName);
const effectiveLevel = getEffectiveLevel(skill.rowName, skill.level);

return (
<SkillRow
key={skill.rowName}
name={name}
level={skill.level}
type={isParagon ? 'paragon' : (statId ? 'stat' : 'utility')}
statId={statId}
perLevel={isParagon}
isPercent={statIsPercent}
enabled={enabled}
onToggle={() => onOverride(skill.rowName, { enabled: !enabled })}
userValue={skillValues[skill.rowName]}
onValueChange={statId ? (v) => onValueChange(skill.rowName, v) : undefined}
effectiveLevel={isParagon ? effectiveLevel : undefined}
onLevelChange={isParagon
? (v) => onOverride(skill.rowName, { level: v })
: undefined}
/>
);
})}
</div>
)}
</div>
);
})}
</div>
);
}
Loading