Add missing src/lib files and fix .gitignore

- Add codec.ts, transform.ts, and types.ts to src/lib/
- Fix .gitignore to only ignore Python lib/ in root, not nested lib/ directories
- Make pointsSpent optional in AscTree type to match JSON data structure

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-08 15:02:04 +01:00
parent cdc2d87d66
commit 7c2738948d
4 changed files with 185 additions and 2 deletions
+2 -2
View File
@@ -52,8 +52,8 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
/lib/
/lib64/
parts/
sdist/
var/
+81
View File
@@ -0,0 +1,81 @@
import type { GridSpec } from "./types";
// Build format: `${class}.${tree0}.${tree1}.${tree2}`
// Compact URL-safe format using base36: encode non-zero talents as "index_rank" pairs separated by underscores
// Example: "0_3_b_5" means slot 0 has rank 3, slot 11 (b in base36) has rank 5
// For ranks: 1-9 are literal, 10+ use base36 (a=10, b=11, etc)
// For indices: use base36 notation
const BASE36_CHARS = "0123456789abcdefghijklmnopqrstuvwxyz";
function encodeRank(rank: number): string {
if (rank < 10) return String(rank);
return BASE36_CHARS[rank] || "0";
}
function decodeRank(char: string): number {
const idx = BASE36_CHARS.indexOf(char.toLowerCase());
return idx >= 0 ? idx : 0;
}
export function encodeBuild(spec: GridSpec): string {
const parts = spec.trees.map((t) => {
const pairs: string[] = [];
t.slots.forEach((s, idx) => {
if (s.talent && s.talent.currentRank > 0) {
// Encode as index_rank using base36 for both
const idxStr = idx.toString(36);
const rankStr = encodeRank(s.talent.currentRank);
pairs.push(idxStr + rankStr);
}
});
return pairs.join("_");
});
return `${spec.className}.${parts.join(".")}`;
}
export function decodeBuild(spec: GridSpec, build: string): GridSpec {
const parts = build.split(".");
if (parts.length < 2) return spec;
const cls = parts[0];
const out = structuredClone(spec);
out.className = cls || spec.className;
// Parse each tree (parts[1], parts[2], parts[3])
out.trees.forEach((t, ti) => {
const treeStr = parts[ti + 1] || "";
if (!treeStr) return;
// Split by underscore to get index-rank pairs
const pairs = treeStr.split("_");
for (const pair of pairs) {
if (pair.length < 2) continue;
// Last character is rank, everything before is index
const rankChar = pair[pair.length - 1];
const idxStr = pair.substring(0, pair.length - 1);
const idx = parseInt(idxStr, 36);
const rank = decodeRank(rankChar);
if (!Number.isFinite(idx) || !Number.isFinite(rank)) continue;
if (idx < 0 || idx >= t.slots.length) continue;
const slot = t.slots[idx];
if (slot.talent) {
slot.talent.currentRank = clamp(rank, 0, slot.talent.maxRank);
}
}
});
return out;
}
function clampDigit(n: number): number {
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(9, Math.trunc(n)));
}
function clamp(n: number, min: number, max: number): number {
return Math.max(min, Math.min(max, n));
}
+66
View File
@@ -0,0 +1,66 @@
import type { AscClassTalents, GridSpec, GridTree, GridSlot, AscTalent } from "./types";
function toIndex(tier: number, column: number): number {
return (tier - 1) * 4 + (column - 1);
}
export function iconTextureToFilename(iconTexture: string): string {
// Input examples:
// - "Interface\\Icons\\Spell_Fire_SoulBurn"
// - "Interface/Icons/Spell_Fire_SoulBurn.blp"
// - " spell_fire_soulburn "
const normalized = (iconTexture || "").trim().replace(/\\/g, "/");
const parts = normalized.split("/");
let base = (parts[parts.length - 1] || normalized).trim();
// Strip any extension if present
base = base.replace(/\.(blp|png|jpg|jpeg)$/i, "");
const filename = base.toLowerCase() + ".png";
// Determine subdirectory based on filename prefix
// Icons are organized in: spells/, items/, professions/, classes/, talents/, ui/, misc/, raids/
if (filename.startsWith("spell_") || filename.startsWith("ability_")) {
return "spells/" + filename;
} else if (filename.startsWith("inv_")) {
return "items/" + filename;
} else if (filename.startsWith("trade_")) {
return "professions/" + filename;
} else if (filename.startsWith("achievement_")) {
return "ui/" + filename;
} else {
// Default to spells for unknown prefixes
return "spells/" + filename;
}
}
function buildEmptySlots(): GridSlot[] {
const slots: GridSlot[] = [];
for (let row = 0; row < 11; row++) {
for (let col = 0; col < 4; col++) {
const index = row * 4 + col;
slots.push({ index, row, col, talent: null });
}
}
return slots;
}
function placeTalents(slots: GridSlot[], talents: AscTalent[]): void {
for (const t of talents) {
const idx = toIndex(t.tier, t.column);
const slot = slots[idx];
if (!slot) continue;
slot.talent = t;
}
}
export function toGridSpec(className: string, data: AscClassTalents): GridSpec {
const trees: GridTree[] = Object.keys(data)
.sort((a, b) => (data[a].tabIndex ?? 0) - (data[b].tabIndex ?? 0))
.map((name) => {
const tree = data[name];
const slots = buildEmptySlots();
placeTalents(slots, tree.talents);
return { name, slots } as GridTree;
});
return { className, trees };
}
+36
View File
@@ -0,0 +1,36 @@
export type AscTalent = {
name: string;
spellId: number;
iconTexture: string; // e.g., Interface\\Icons\\Spell_Fire_FlameBolt
tier: number; // 1..11
column: number; // 1..4
maxRank: number;
currentRank: number;
tooltip: string;
requires?: number | null; // optional: index or spellId of prerequisite
};
export type AscTree = {
tabIndex: number;
pointsSpent?: number;
talents: AscTalent[];
};
export type AscClassTalents = Record<string, AscTree>; // keys: Fire/Frost/Arcane etc.
export type GridSlot = {
index: number; // 0..43 in 4x11
row: number; // 0..10
col: number; // 0..3
talent: AscTalent | null;
};
export type GridTree = {
name: string; // Fire/Frost/Arcane
slots: GridSlot[]; // 44 items
};
export type GridSpec = {
className: string; // mage, warrior, etc.
trees: GridTree[]; // length 3
};