Add equipment planner toolbox module
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-08-09 10:03:55 +02:00
parent ac33a55c8b
commit d53d4160af
33 changed files with 3522 additions and 20 deletions

View file

@ -1,5 +1,27 @@
// Rôle : normalise, compacte et sérialise les données toolbox persistées.
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", image: "s", link: "l", counter: "c", combo: "o", calc: "r", marker: "k", timer: "z", task: "a", relation: "e", stroke: "d" };
const ID_PREFIXES = {
tbx: "t",
mod: "m",
item: "i",
section: "g",
image: "s",
link: "l",
counter: "c",
combo: "o",
calc: "r",
marker: "k",
timer: "z",
task: "a",
relation: "e",
stroke: "d",
equipmentType: "y",
equipment: "q",
trait: "v",
material: "n",
socketItem: "j",
socketLink: "u",
socketBonus: "b"
};
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
const NOTEPAD_ALLOWED_COLORS = new Set(["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#101426", "#202745"]);
const NOTEPAD_ALLOWED_TAGS = new Set(["p", "div", "br", "strong", "b", "em", "i", "u", "s", "strike", "ul", "ol", "li", "h3", "h4", "span", "mark"]);
@ -21,6 +43,10 @@ const TIMER_MIN_AUTO_REFRESH_MS = 5 * 60 * 1000;
const TASK_TYPES = new Set(["unique", "daily", "weekly"]);
const COMBO_DEVICES = new Set(["playstation", "xbox", "switch", "n64", "keyboardMouse"]);
const COMBO_INPUT_KINDS = new Set(["button", "direction", "key", "mouse"]);
const EQUIPMENT_SOCKET_SHAPES = new Set(["ball", "jewel"]);
const EQUIPMENT_SOCKET_COLORS = new Set(["red", "orange", "amber", "yellow", "lime", "green", "teal", "cyan", "blue", "indigo", "violet", "purple", "pink", "rose", "white", "gray", "black"]);
const EQUIPMENT_TYPE_ICONS = new Set(["boot", "chest-armor", "helmet", "glove", "shield", "sword", "gun", "earring", "necklace", "ring"]);
const EQUIPMENT_TRAIT_ICONS = new Set(["sword", "shield"]);
const DEFAULT_TASK_PLANNER_WEEKLY_RESET_DAY = 1;
const DEFAULT_TASK_PLANNER_RESET_TIME = "00:00";
const TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/";
@ -56,6 +82,7 @@ const DEFAULT_MODULE_TITLES = {
table: "Tableau",
timer: "Timer",
taskPlanner: "Planificateur de tâches",
equipmentPlanner: "Planificateur d'équipements",
imageAnnotation: "Annotation d'images"
};
@ -678,6 +705,270 @@ export function normalizeTaskPlannerData(data) {
};
}
function normalizeEquipmentPlannerType(type) {
const title = String(type?.title || "").trim().slice(0, 80);
if (!title) return null;
const icon = EQUIPMENT_TYPE_ICONS.has(type?.icon) ? type.icon : "shield";
return {
id: type?.id || uid("equipmentType"),
title,
icon,
collapsed: type?.collapsed === true
};
}
function normalizeEquipmentPlannerValue(value) {
if (typeof value === "number") return Number.isFinite(value) ? Math.round(value * 1000) / 1000 : "";
const text = String(value ?? "").trim().slice(0, 80);
if (!text) return "";
const numeric = Number(text.replace(",", "."));
return Number.isFinite(numeric) && /^[-+]?\d+(?:[,.]\d+)?$/.test(text) ? Math.round(numeric * 1000) / 1000 : text;
}
function normalizeEquipmentPlannerTrait(trait, prefix = "trait") {
const name = String(trait?.name || "").trim().slice(0, 80);
if (!name) return null;
return {
id: trait?.id || uid(prefix),
category: String(trait?.category || "").trim().slice(0, 80),
icon: EQUIPMENT_TRAIT_ICONS.has(trait?.icon) ? trait.icon : "sword",
name,
value: normalizeEquipmentPlannerValue(trait?.value)
};
}
function normalizeEquipmentPlannerMaterial(material) {
const name = String(material?.name || "").trim().slice(0, 80);
if (!name) return null;
return {
id: material?.id || uid("material"),
name,
qty: Math.max(1, parsePositiveInt(material?.qty, 1))
};
}
function normalizeEquipmentPlannerSocketItem(socketItem) {
const name = String(socketItem?.name || "").trim().slice(0, 80);
if (!name) return null;
const shape = EQUIPMENT_SOCKET_SHAPES.has(socketItem?.shape) ? socketItem.shape : "jewel";
const color = EQUIPMENT_SOCKET_COLORS.has(socketItem?.color) ? socketItem.color : "yellow";
return {
id: socketItem?.id || uid("socketItem"),
name,
shape,
color,
bonuses: (Array.isArray(socketItem?.bonuses) ? socketItem.bonuses : [])
.map((bonus) => normalizeEquipmentPlannerTrait(bonus, "socketBonus"))
.filter(Boolean)
};
}
function normalizeEquipmentPlannerCategoryOrder(value, categories) {
const available = new Set(categories.filter(Boolean));
const ordered = [];
(Array.isArray(value) ? value : []).forEach((category) => {
const normalized = String(category || "").trim().slice(0, 80);
if (normalized && available.has(normalized) && !ordered.includes(normalized)) ordered.push(normalized);
});
categories.forEach((category) => {
if (category && !ordered.includes(category)) ordered.push(category);
});
return ordered;
}
function normalizeEquipmentPlannerEquipment(equipment, typeIds) {
const name = String(equipment?.name || "").trim().slice(0, 100);
const typeId = String(equipment?.typeId || "");
if (!name || !typeIds.has(typeId)) return null;
const characteristics = (Array.isArray(equipment?.characteristics) ? equipment.characteristics : [])
.map((trait) => normalizeEquipmentPlannerTrait(trait, "trait"))
.filter(Boolean);
const socketItems = (Array.isArray(equipment?.socketItems) ? equipment.socketItems : [])
.map(normalizeEquipmentPlannerSocketItem)
.filter(Boolean);
const socketItemIds = new Set(socketItems.map((socketItem) => socketItem.id));
const socketLinks = (Array.isArray(equipment?.socketLinks) ? equipment.socketLinks : [])
.map((link) => {
const fromSocketItemId = String(link?.fromSocketItemId || "");
const toSocketItemId = String(link?.toSocketItemId || "");
if (!socketItemIds.has(fromSocketItemId) || !socketItemIds.has(toSocketItemId) || fromSocketItemId === toSocketItemId) return null;
const pairKey = [fromSocketItemId, toSocketItemId].sort().join(":");
return {
id: link?.id || uid("socketLink"),
fromSocketItemId,
toSocketItemId,
pairKey
};
})
.filter(Boolean)
.filter((link, index, links) => links.findIndex((item) => item.pairKey === link.pairKey) === index)
.map(({ pairKey, ...link }) => link);
const categories = [
...characteristics.map((trait) => trait.category),
...socketItems.flatMap((socketItem) => socketItem.bonuses.map((bonus) => bonus.category))
].filter(Boolean);
const categoryOrder = normalizeEquipmentPlannerCategoryOrder(equipment?.categoryOrder, categories);
return {
id: equipment?.id || uid("equipment"),
typeId,
name,
icon: EQUIPMENT_TYPE_ICONS.has(equipment?.icon) ? equipment.icon : "shield",
active: equipment?.active !== false,
obtain: String(equipment?.obtain || "").trim().slice(0, 500),
characteristics,
socketItems,
socketLinks,
materials: (Array.isArray(equipment?.materials) ? equipment.materials : [])
.map(normalizeEquipmentPlannerMaterial)
.filter(Boolean),
categoryOrder,
collapsedCategories: (Array.isArray(equipment?.collapsedCategories) ? equipment.collapsedCategories : [])
.map((category) => String(category || "").trim().slice(0, 80))
.filter((category, index, collapsedCategories) => category && categoryOrder.includes(category) && collapsedCategories.indexOf(category) === index)
};
}
export function normalizeEquipmentPlannerData(data) {
const rawTypes = (Array.isArray(data?.types) ? data.types : []).map(normalizeEquipmentPlannerType).filter(Boolean);
const typeIds = new Set(rawTypes.map((type) => type.id));
const equipments = (Array.isArray(data?.equipments) ? data.equipments : [])
.map((equipment) => normalizeEquipmentPlannerEquipment(equipment, typeIds))
.filter(Boolean);
const usedTypeIds = new Set(equipments.map((equipment) => equipment.typeId));
const types = rawTypes.filter((type) => usedTypeIds.has(type.id));
const orderedTypeIds = (Array.isArray(data?.typeOrder) ? data.typeOrder : [])
.map((typeId) => String(typeId || ""))
.filter((typeId, index, order) => usedTypeIds.has(typeId) && order.indexOf(typeId) === index);
return {
types,
equipments,
typeOrder: [
...orderedTypeIds,
...types.map((type) => type.id).filter((typeId) => !orderedTypeIds.includes(typeId))
]
};
}
export function summarizeEquipmentPlannerData(data) {
const normalized = normalizeEquipmentPlannerData(data);
const totals = new Map();
const textBonuses = [];
const activeEquipments = normalized.equipments.filter((equipment) => equipment.active);
function incrementCount(counts, key) {
counts.set(key, (counts.get(key) || 0) + 1);
}
function pickMostUsedKey(counts) {
return [...counts.entries()].reduce((picked, [key, count]) => {
if (!picked) return { key, count };
if (count !== picked.count) return count > picked.count ? { key, count } : picked;
return key.localeCompare(picked.key) < 0 ? { key, count } : picked;
}, null);
}
function parseIconKey(key) {
const [icon, color = ""] = String(key || "").split(":");
return { icon: icon || "sword", color };
}
function pickSummaryIcon(iconSources) {
return parseIconKey(pickMostUsedKey(iconSources.direct)?.key || pickMostUsedKey(iconSources.socket)?.key || "sword:");
}
function getSummarySourceKind(sourceKinds) {
if (sourceKinds.size !== 1) return "mixed";
return sourceKinds.has("direct") ? "direct" : "socket";
}
function collectEntry(entry, contribution, icon, sourceKind, color = "") {
if (typeof entry.value === "number") {
const existing = totals.get(entry.name);
const iconKey = icon || "sword";
const colorKey = sourceKind === "socket" ? color || "gray" : "";
const iconSources = existing?.iconSources || { direct: new Map(), socket: new Map() };
const sourceKinds = existing?.sourceKinds || new Set();
const contributions = existing?.contributions || [];
sourceKinds.add(sourceKind);
incrementCount(iconSources[sourceKind], `${iconKey}:${colorKey}`);
const pickedIcon = pickSummaryIcon(iconSources);
totals.set(entry.name, {
name: entry.name,
icon: pickedIcon.icon,
color: pickedIcon.color,
sourceKind: getSummarySourceKind(sourceKinds),
iconSources,
sourceKinds,
contributions: [
...contributions,
{
id: entry.id,
sourceKind,
equipmentIcon: contribution.equipmentIcon,
equipmentName: contribution.equipmentName,
sourceName: contribution.sourceName,
value: entry.value
}
],
value: (existing?.value || 0) + entry.value
});
return;
}
textBonuses.push({
id: entry.id,
category: entry.category,
name: entry.name,
value: entry.value,
source: contribution.sourceName
});
}
activeEquipments.forEach((equipment) => {
equipment.characteristics.forEach((trait) => collectEntry(
trait,
{ equipmentIcon: equipment.icon || "shield", equipmentName: equipment.name, sourceName: equipment.name },
trait.icon || "sword",
"direct"
));
equipment.socketItems.forEach((socketItem) => {
socketItem.bonuses.forEach((bonus) => collectEntry(
bonus,
{ equipmentIcon: equipment.icon || "shield", equipmentName: equipment.name, sourceName: `${equipment.name} · ${socketItem.name}` },
socketItem.shape || "jewel",
"socket",
socketItem.color
));
});
});
const numericTotals = [...totals.values()]
.map(({ iconSources, sourceKinds, color, sourceKind, ...total }) => ({
...total,
...(color ? { color } : {}),
sourceKind
}))
.sort((a, b) => a.name.localeCompare(b.name));
return {
activeEquipmentCount: activeEquipments.length,
totals: numericTotals,
equipmentTotals: numericTotals.filter((total) => total.sourceKind === "direct"),
socketTotals: numericTotals.filter((total) => total.sourceKind === "socket"),
mixedTotals: numericTotals.filter((total) => total.sourceKind === "mixed"),
textBonuses,
socketItems: activeEquipments.flatMap((equipment) => equipment.socketItems.map((socketItem) => ({
...socketItem,
equipmentId: equipment.id,
equipmentName: equipment.name
}))),
socketLinks: activeEquipments.flatMap((equipment) => equipment.socketLinks.map((link) => ({
...link,
equipmentId: equipment.id,
equipmentName: equipment.name
})))
};
}
function clampPercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 0;
@ -716,6 +1007,64 @@ function compactChecklistSectionForStorage(section) {
return compact;
}
function compactEquipmentPlannerTraitForStorage(trait, prefix = "trait") {
const normalized = normalizeEquipmentPlannerTrait(trait, prefix);
if (!normalized) return null;
const compact = { id: normalized.id, name: normalized.name };
if (normalized.category) compact.category = normalized.category;
if (normalized.icon !== "sword") compact.icon = normalized.icon;
if (normalized.value !== "") compact.value = normalized.value;
return compact;
}
function compactEquipmentPlannerMaterialForStorage(material) {
const normalized = normalizeEquipmentPlannerMaterial(material);
return normalized ? normalized : null;
}
function compactEquipmentPlannerSocketItemForStorage(socketItem) {
const normalized = normalizeEquipmentPlannerSocketItem(socketItem);
if (!normalized) return null;
const compact = {
id: normalized.id,
name: normalized.name,
shape: normalized.shape,
color: normalized.color
};
if (normalized.bonuses.length) {
compact.bonuses = normalized.bonuses
.map((bonus) => compactEquipmentPlannerTraitForStorage(bonus, "socketBonus"))
.filter(Boolean);
}
return compact;
}
function compactEquipmentPlannerEquipmentForStorage(equipment) {
const compact = {
id: equipment.id,
typeId: equipment.typeId,
name: equipment.name
};
if (equipment.icon !== "shield") compact.icon = equipment.icon;
if (equipment.active === false) compact.active = false;
if (equipment.obtain) compact.obtain = equipment.obtain;
if (equipment.characteristics.length) {
compact.characteristics = equipment.characteristics
.map((trait) => compactEquipmentPlannerTraitForStorage(trait, "trait"))
.filter(Boolean);
}
if (equipment.socketItems.length) {
compact.socketItems = equipment.socketItems
.map(compactEquipmentPlannerSocketItemForStorage)
.filter(Boolean);
}
if (equipment.socketLinks.length) compact.socketLinks = equipment.socketLinks;
if (equipment.materials.length) compact.materials = equipment.materials.map(compactEquipmentPlannerMaterialForStorage).filter(Boolean);
if (equipment.categoryOrder.length) compact.categoryOrder = equipment.categoryOrder;
if (equipment.collapsedCategories.length) compact.collapsedCategories = equipment.collapsedCategories;
return compact;
}
export function compactModuleDataForStorage(type, value) {
if (type === "notepad") {
const normalized = normalizeNotepadData(value);
@ -889,6 +1238,21 @@ export function compactModuleDataForStorage(type, value) {
}
return Object.keys(compact).length ? compact : null;
}
if (type === "equipmentPlanner") {
const normalized = normalizeEquipmentPlannerData(value);
if (!normalized.equipments.length) return null;
const compact = {
types: normalized.types.map((type) => {
const compactType = { id: type.id, title: type.title };
if (type.icon !== "shield") compactType.icon = type.icon;
if (type.collapsed) compactType.collapsed = true;
return compactType;
}),
equipments: normalized.equipments.map(compactEquipmentPlannerEquipmentForStorage)
};
if (normalized.typeOrder.length) compact.typeOrder = normalized.typeOrder;
return compact;
}
return value;
}
@ -1000,6 +1364,41 @@ function remapModuleDataForExport(type, data, nextId) {
return remapped;
}
if (type === "equipmentPlanner") {
const typeIdMap = new Map();
const equipmentIdMap = new Map();
compact.types?.forEach((typeEntry) => typeIdMap.set(typeEntry.id, nextId("equipmentType")));
compact.equipments?.forEach((equipment) => equipmentIdMap.set(equipment.id, nextId("equipment")));
const remapped = {
...compact,
types: (compact.types || []).map((typeEntry) => ({ ...typeEntry, id: typeIdMap.get(typeEntry.id) })),
equipments: (compact.equipments || []).map((equipment) => {
const socketItemIdMap = new Map();
equipment.socketItems?.forEach((socketItem) => socketItemIdMap.set(socketItem.id, nextId("socketItem")));
return {
...equipment,
id: equipmentIdMap.get(equipment.id),
typeId: typeIdMap.get(equipment.typeId) || "",
characteristics: equipment.characteristics?.map((trait) => ({ ...trait, id: nextId("trait") })),
materials: equipment.materials?.map((material) => ({ ...material, id: nextId("material") })),
socketItems: equipment.socketItems?.map((socketItem) => ({
...socketItem,
id: socketItemIdMap.get(socketItem.id),
bonuses: socketItem.bonuses?.map((bonus) => ({ ...bonus, id: nextId("socketBonus") }))
})),
socketLinks: equipment.socketLinks?.map((link) => ({
...link,
id: nextId("socketLink"),
fromSocketItemId: socketItemIdMap.get(link.fromSocketItemId),
toSocketItemId: socketItemIdMap.get(link.toSocketItemId)
})).filter((link) => link.fromSocketItemId && link.toSocketItemId)
};
}).filter((equipment) => equipment.typeId)
};
if (compact.typeOrder) remapped.typeOrder = compact.typeOrder.map((typeId) => typeIdMap.get(typeId)).filter(Boolean);
return remapped;
}
return compact;
}