// Immagine di anteprima per la modale di /addons: una per addon, in
// public/addon-img/<chiave>.webp. Si lancia a mano quando aggiungi o cambi un addon:
//
// node scripts/addon-images.mjs scarica le mancanti e toglie le orfane
// node scripts/addon-images.mjs --rifai riscarica anche quelle che ci sono gia'
//
// ⚠️ E' in JS e non in PowerShell per la stessa ragione di mount-images.mjs: l'immagine
// arriva in PNG/JPEG e va convertita in webp, e l'unico convertitore che il repo ha e'
// `sharp`. PowerShell non ha nulla per scrivere un webp.
//
// DUE FONTI, in ordine:
//
// 1. **`preview` nel manifest** — l'URL SCELTO A MANO, di norma uno screenshot preso
// dalla galleria del progetto su CurseForge. E' la fonte buona: la galleria mostra
// l'addon in gioco, che e' quello che serve vedere.
// ⚠️ **La galleria NON si puo' leggere in automatico**, verificato il 2026-08-04 su
// quattro strade: la pagina del progetto risponde 403 (Cloudflare); l'API ufficiale
// `api.curseforge.com/v1/mods/<id>` — l'unica che espone `screenshots[]` — risponde
// 403 senza chiave; la vecchia API aperta `addons-ecs.forgesvc.net` ha il DNS morto,
// e' stata dismessa; l'API interna del sito risponde 403 (e la richiesta il backend
// lo raggiunge, visto che una rotta inesistente da' un 404 applicativo, quindi e'
// autorizzazione e non firewall). Il proxy pubblico cfwidget risponde ma **non ha
// alcun campo galleria**. Percio' l'URL lo si incolla nel manifest: e' un dato
// redazionale come `desc`, e il manifest degli addon e' gia' tenuto a mano.
//
// 2. **Ripiego automatico**: il logo ufficiale del progetto, dal campo `thumbnail` di
// **api.cfwidget.com** (proxy pubblico dei dati CurseForge, senza chiave). Serve
// perche' un addon appena aggiunto non resti senza immagine finche' non gli si
// sceglie uno screenshot. Non e' uno screenshot: identifica l'addon e basta.
// `thumbnail` arriva come miniatura 256x256; togliendo dal percorso il segmento
// `thumbnails/<w>/<h>` si ottiene l'ORIGINALE, fra 298x190 e 1200x1200.
//
// ⚠️ Il file su disco NON basta come cache, perche' la fonte puo' cambiare senza che il
// nome del file cambi: se cambi il `preview` nel manifest, il .webp vecchio resterebbe li'
// in silenzio. Per questo c'e' `addons/img-fonti.json`, che ricorda da quale URL viene
// ognuna: se non combacia, l'immagine si rifa' da sola.
import { readFileSync, existsSync, writeFileSync, unlinkSync, mkdirSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import sharp from 'sharp';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const dir = join(root, 'public', 'addon-img');
const rifai = process.argv.includes('--rifai');
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) WowManagerAddonImages/1.0';
// Lato massimo del webp: la fascia della modale mostra al piu' 180px di altezza, quindi
// 640 e' gia' la riserva per gli schermi a densita' doppia. Le sorgenti arrivano a
// 1200x1200 e a 1.9 MB l'una: tenerle intere sarebbe peso puro.
const LATO = 640;
const manifest = JSON.parse(readFileSync(join(root, 'addons', 'manifest.json'), 'utf8'));
const addons = Object.entries(manifest.addons);
mkdirSync(dir, { recursive: true });
// Slug del progetto dall'url del manifest (.../wow/addons/<slug>).
const slugDi = (url) => (url || '').replace(/\/+$/, '').split('/').pop() || '';
// .../avatars/thumbnails/174/668/256/256/<id>.png -> .../avatars/174/668/<id>.png
const originale = (u) => u.replace(/\/avatars\/thumbnails\/(\d+)\/(\d+)\/\d+\/\d+\//, '/avatars/$1/$2/');
async function scarica(url) {
const r = await fetch(url, { headers: { 'User-Agent': UA } });
if (!r.ok) return null;
return Buffer.from(await r.arrayBuffer());
}
// ⚠️ Alla prima richiesta per un progetto mai visto, cfwidget risponde 202 e mette in
// coda l'indicizzazione: il JSON arriva solo al giro dopo. Senza questa attesa un addon
// nuovo resterebbe senza immagine e sembrerebbe che la fonte non ce l'abbia.
async function metadati(slug) {
for (let i = 0; i < 6; i++) {
const r = await fetch(`https://api.cfwidget.com/wow/addons/${slug}`, { headers: { 'User-Agent': UA } });
if (r.status === 200) return r.json();
if (r.status !== 202) return null;
await new Promise((s) => setTimeout(s, 3000));
}
return null;
}
// `fit: inside` non ritaglia e non deforma: i loghi degli addon hanno proporzioni molto
// diverse fra loro (da 298x190 a 1200x1200) e ritagliarli al quadrato taglierebbe via
// meta' del nome scritto sul logo. Ci pensa la fascia in pagina a centrarli.
// `withoutEnlargement` per non gonfiare un originale piu' piccolo del lato massimo.
const converti = (buf) =>
sharp(buf)
.resize(LATO, LATO, { fit: 'inside', withoutEnlargement: true, kernel: 'lanczos3' })
.webp({ quality: 82, effort: 5 })
.toBuffer();
// ⚠️ Su Windows la scrittura fallisce ogni tanto con un errno opaco (-4094 UNKNOWN): il
// file e' aperto da qualcun altro in quell'istante — l'antivirus che lo scandisce appena
// creato, o il server di anteprima che lo sta servendo. E' una lezione gia' imparata da
// mount-images.mjs, dove una volta ha buttato giu' un giro intero a due terzi: si riprova
// invece di morire.
async function scrivi(file, buf, tentativi = 4) {
for (let i = 1; ; i++) {
try {
writeFileSync(file, buf);
return;
} catch (e) {
if (i >= tentativi) throw e;
await new Promise((r) => setTimeout(r, 150 * i));
}
}
}
// `addons/img-fonti.json`: chiave -> URL da cui viene il .webp. Senza, un `preview`
// cambiato nel manifest non si accorgerebbe di nulla e resterebbe l'immagine vecchia, in
// silenzio.
// ⚠️ Sta accanto al manifest e NON dentro public/: e' un file di servizio, e tutto quello
// che sta in public/ viene servito dalla radice — finirebbe pubblicato e copiato in dist.
const fonteFile = join(root, 'addons', 'img-fonti.json');
const fonti = existsSync(fonteFile) ? JSON.parse(readFileSync(fonteFile, 'utf8')) : {};
// Sequenziale e non in parallelo: sono 16 addon, e cfwidget e' un servizio gratuito — non
// vale la pena martellarlo per risparmiare qualche secondo una volta ogni tanto.
let prese = 0, saltate = 0;
const senza = [];
for (const [key, a] of addons) {
const file = join(dir, `${key}.webp`);
// L'URL scelto a mano vince sempre; il logo e' solo il ripiego per chi non ce l'ha.
const scelto = (a.preview || '').trim();
let url = scelto;
try {
if (!url) {
const meta = await metadati(slugDi(a.url));
const thumb = meta?.thumbnail || '';
// Sul logo si prova prima l'originale e si ripiega sulla miniatura.
url = thumb ? originale(thumb) : '';
if (!url) { senza.push(`${key} (nessun \`preview\` nel manifest e la fonte non ha logo)`); continue; }
}
// Si rifa' solo se manca il file, se l'URL e' cambiato, o con --rifai. Cosi' cambiare
// un `preview` nel manifest basta: non c'e' niente da cancellare a mano.
if (!rifai && existsSync(file) && fonti[key] === url) { saltate++; continue; }
const buf = (await scarica(url)) ?? (scelto ? null : await scarica((await metadati(slugDi(a.url)))?.thumbnail || ''));
if (!buf) { senza.push(`${key} (immagine non scaricabile: ${url})`); continue; }
await scrivi(file, await converti(buf));
fonti[key] = url;
prese++;
console.log(` ${key}: ${scelto ? 'galleria' : 'logo (ripiego)'}`);
} catch (e) {
// Un addon che va storto non deve buttare giu' il giro: si conta fra i mancanti.
senza.push(`${key} (${e.message})`);
}
}
console.log(`immagini addon: ${prese} scaricate, ${saltate} gia' a posto, ${senza.length} senza`);
for (const t of senza) console.log(` senza immagine: ${t}`);
const curate = addons.filter(([, a]) => (a.preview || '').trim()).length;
console.log(`fonte: ${curate} dalla galleria (campo \`preview\`), ${addons.length - curate} dal logo di ripiego`);
// Orfane: un addon rimosso dal manifest lascerebbe il suo file nel repo.
const vivi = new Set(addons.map(([key]) => key));
let orfane = 0;
for (const f of readdirSync(dir)) {
if (f.endsWith('.webp') && !vivi.has(f.slice(0, -5))) {
unlinkSync(join(dir, f));
orfane++;
}
}
for (const k of Object.keys(fonti)) if (!vivi.has(k)) delete fonti[k];
if (orfane) console.log(`immagini addon orfane rimosse: ${orfane}`);
await scrivi(fonteFile, JSON.stringify(fonti, null, 2) + '\n');Extra
#Requires -Version 5.1
# Applica l'override del font base di WoW: copia un unico .ttf sotto i nomi-override
# che il client carica da _retail_/Fonts/, cosi' tutta la UI Blizzard usa quel font.
# Nomi/percorsi presi da fonts/manifest.json (nessun elenco hardcoded: segue il manifest).
# Si carica all'AVVIO del client, non basta /reload. Usa -Revert per rimuoverli.
[CmdletBinding()]
param([switch]$Revert)
$ErrorActionPreference = 'Stop'
# Trova fonts/manifest.json: relativo allo script (repo) o alla cartella corrente.
$candidates = @()
if ($PSScriptRoot) { $candidates += (Join-Path (Split-Path $PSScriptRoot -Parent) 'fonts\manifest.json') }
$candidates += (Join-Path (Get-Location).Path 'fonts\manifest.json')
$manifestPath = $candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
if (-not $manifestPath) { throw "fonts/manifest.json non trovato: esegui dalla cartella del repo." }
$m = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
$fontsDir = $m._meta.wow_fonts_dir
$source = $m.override.source_file
$files = $m.override.files
if ($Revert) {
$n = 0
foreach ($name in $files) {
$dest = Join-Path $fontsDir $name
if (Test-Path -LiteralPath $dest) { Remove-Item -LiteralPath $dest -Force; Write-Host "Rimosso: $name"; $n++ }
}
Write-Host ""
Write-Host "Revert completato ($n file). RIAVVIA il client per tornare al font di default."
return
}
if (-not (Test-Path -LiteralPath $source)) { throw "Font sorgente non trovato: $source" }
if (-not (Test-Path -LiteralPath $fontsDir)) { New-Item -ItemType Directory -Path $fontsDir -Force | Out-Null }
foreach ($name in $files) {
Copy-Item -LiteralPath $source -Destination (Join-Path $fontsDir $name) -Force
Write-Host "Copiato: $($m.override.source_font) -> $name"
}
Write-Host ""
Write-Host "Fatto ($($files.Count) file). RIAVVIA il client (non basta /reload)."#Requires -Version 5.1
# Backup e ripristino di impostazioni e addon di WoW: crea uno .zip datato di
# WTF (impostazioni, keybind, macro, SavedVariables) + Interface (addon), oppure
# ripristina da uno .zip esistente. Nessuna dipendenza: usa Compress/Expand-Archive.
# Adattato dai tool community: smashedr/wow-backup, eTzmNcbkrng/Backup-WTF.
# Backup: .\backup-restore.ps1
# Ripristino: .\backup-restore.ps1 -Restore -Zip "...\wow-2026-07-13_2140.zip"
[CmdletBinding()]
param(
[switch]$Restore,
[string]$Zip
)
$ErrorActionPreference = 'Stop'
$WowRoot = 'C:\Program Files (x86)\World of Warcraft\_retail_'
$BackupDir = Join-Path $WowRoot 'Backups'
$Folders = @('WTF', 'Interface') # cosa salviamo/ripristiniamo
# Non operare col client aperto: i file sono in uso.
if (Get-Process -Name 'Wow', 'WowClassic' -ErrorAction SilentlyContinue) {
throw "WoW e' in esecuzione: chiudi il client prima di backup/ripristino."
}
if ($Restore) {
if (-not $Zip -or -not (Test-Path -LiteralPath $Zip)) {
throw "Ripristino: passa -Zip con il percorso di uno .zip valido."
}
Write-Warning "Il ripristino SOVRASCRIVE $($Folders -join ' e ') in $WowRoot."
if ((Read-Host "Procedere? Scrivi 'si' per confermare") -ne 'si') { Write-Host "Annullato."; return }
Expand-Archive -LiteralPath $Zip -DestinationPath $WowRoot -Force
Write-Host "Ripristinato da: $Zip"
return
}
# --- Backup ---
$sources = $Folders | ForEach-Object { Join-Path $WowRoot $_ } | Where-Object { Test-Path -LiteralPath $_ }
if (-not $sources) { throw "Niente da salvare: WTF/Interface non trovate in $WowRoot." }
New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null
$stamp = Get-Date -Format 'yyyy-MM-dd_HHmm'
$out = Join-Path $BackupDir "wow-$stamp.zip"
Compress-Archive -Path $sources -DestinationPath $out -CompressionLevel Optimal
$mb = [math]::Round(((Get-Item -LiteralPath $out).Length / 1MB), 1)
Write-Host "Backup creato: $out ($mb MB)"
Write-Host "Ripristino: .\backup-restore.ps1 -Restore -Zip `"$out`""-- WowManagerProfDump - addon locale, NON pubblico. Gemello di WowManagerTierDump
-- (scripts/transmog-tier-dump.lua), ma per gli ALBERI DI SPECIALIZZAZIONE delle
-- professioni: C_ProfSpecs / C_Traits. Estrae, per ogni professione leggibile dal
-- PG loggato, il nome esatto di ogni nodo, la descrizione, il RANK MASSIMO (i punti
-- che entrano nel nodo), il rank gia' speso e i figli di ogni nodo.
--
-- Perche' in gioco e non dal web: il rank massimo -- il numero che serve a dire
-- "quanti punti mettere" -- nessuna pagina lo pubblica. Il client si'. E la struttura
-- padre-figlio viene dal gioco (visibleEdges), non dedotta dagli ID.
--
-- ⚠️ PER PERSONAGGIO. Gli alberi COMPLETI (nodi + cap) escono solo per le professioni
-- che il PG ha imparato; per le altre si ottiene il solo nome delle spec di primo
-- livello. Per coprirle tutte, lancia su piu' PG e unisci i dump.
--
-- ⚠️ Ogni professione ha un albero PER ESPANSIONE (Dragonflight, TWW, Midnight...):
-- per questo ogni tree porta `professionInfo` con `expansionName`. Al momento di
-- scrivere il manifest si tiene la sola riga Midnight.
--
-- Installazione (addon NUOVO -> RIAVVIO del client la prima volta; dopo basta /reload):
-- _retail_/Interface/AddOns/WowManagerProfDump/WowManagerProfDump.lua (questo file)
-- _retail_/Interface/AddOns/WowManagerProfDump/WowManagerProfDump.toc
--
-- ⚠️ E' un addon `LoadOnDemand`: lo carica il lanciatore WowManagerDump (vedi
-- scripts/dump-launcher.lua) quando dai /wmprof, oppure al login SE le professioni
-- del PG sono cambiate rispetto all'ultimo dump. Prima girava a ogni PLAYER_LOGIN --
-- che scatta anche a ogni /reload -- e quindi ripercorreva gli alberi per riscrivere
-- quasi sempre lo stesso file: la firma che decide sta nel lanciatore perche' va
-- calcolata senza caricare nulla.
--
-- Uso: /wmprof poi /reload (o logout) -> scrive
-- WTF/Account/<ACC>/SavedVariables/WowManagerProfDump.lua
-- Da li' si legge il campo `json`, gia' pulito e ordinato.
-- /wmprof tutto percorre ANCHE gli alberi senza configID e riscrive i campi
-- diagnostici (`trees` grezzo, `api`) -- vedi il perche' in fondo a Dump.
-- ── Utility ────────────────────────────────────────
local function ApiNames(...)
local out = {}
for _, ns in ipairs({ ... }) do
local tbl = _G[ns]
if type(tbl) == "table" then
for k, v in pairs(tbl) do
if type(v) == "function" then out[#out + 1] = ns .. "." .. k end
end
end
end
table.sort(out)
return out
end
-- JSON minimale con chiavi ordinate: due dump si confrontano con un diff pulito.
local ESC = { ['"'] = '\\"', ['\\'] = '\\\\', ['\n'] = '\\n', ['\r'] = '\\r', ['\t'] = '\\t' }
local function esc(s)
return (tostring(s):gsub('[%z\1-\31\\"]', function(c)
return ESC[c] or string.format('\\u%04x', c:byte())
end))
end
local function toJson(v, indent)
indent = indent or ""
local t = type(v)
if t == "nil" then return "null"
elseif t == "number" then
if v ~= v or v == math.huge or v == -math.huge then return "null" end
return (v == math.floor(v)) and string.format("%d", v) or tostring(v)
elseif t == "boolean" then return v and "true" or "false"
elseif t == "string" then return '"' .. esc(v) .. '"'
elseif t == "table" then
local n, isArr = 0, true
for k in pairs(v) do n = n + 1; if type(k) ~= "number" then isArr = false end end
if n == 0 then return "{}" end
local ni = indent .. " "
if isArr then
local parts = {}
for i = 1, #v do parts[i] = ni .. toJson(v[i], ni) end
return "[\n" .. table.concat(parts, ",\n") .. "\n" .. indent .. "]"
end
local keys = {}
for k in pairs(v) do keys[#keys + 1] = k end
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
local parts = {}
for _, k in ipairs(keys) do
parts[#parts + 1] = ni .. '"' .. esc(k) .. '": ' .. toJson(v[k], ni)
end
return "{\n" .. table.concat(parts, ",\n") .. "\n" .. indent .. "}"
end
return '"<' .. t .. '>"'
end
-- ── Nodo: nome, descrizione, cap, figli ────────────
-- Il nome/descrizione di un nodo stanno sulla ENTRY -> DEFINITION -> spell; il cap
-- (maxRanks) sta sulla entry. Un nodo ha PIU' entry: quella di progressione (il cap
-- vero, es. 50) e il perk di sblocco (maxRanks 1). Si tiene il nome condiviso e il
-- maxRanks PIU' ALTO -- il perk a 1 e' rumore per l'infografica.
local function ResolveNode(configID, node)
local name, desc, maxRanks, spellID
for _, eid in ipairs(node.entryIDs or {}) do
local ok, entry = pcall(C_Traits.GetEntryInfo, configID, eid)
if ok and type(entry) == "table" then
if entry.maxRanks and (not maxRanks or entry.maxRanks > maxRanks) then
maxRanks = entry.maxRanks
end
local defID = entry.definitionID
if defID then
local ok2, def = pcall(C_Traits.GetDefinitionInfo, defID)
if ok2 and type(def) == "table" then
if not name or name == "" then
name = def.overrideName
if (not name or name == "") and def.spellID and C_Spell then
local okS, si = pcall(C_Spell.GetSpellInfo, def.spellID)
if okS and type(si) == "table" then name = si.name end
spellID = def.spellID
end
end
if (not desc or desc == "") then
desc = def.overrideDescription
if (not desc or desc == "") and def.spellID and C_Spell
and C_Spell.GetSpellDescription then
local okD, d = pcall(C_Spell.GetSpellDescription, def.spellID)
if okD and d and d ~= "" then desc = d end
end
end
end
end
end
end
return name, desc, maxRanks, spellID
end
-- I figli di un nodo: il gioco li da' in visibleEdges[].targetNode. Struttura vera,
-- non dedotta dalla contiguita' degli ID come nei tentativi via Wowhead.
local function Children(node)
local out = {}
for _, e in ipairs(node.visibleEdges or {}) do
if e.targetNode then out[#out + 1] = e.targetNode end
end
return out
end
local function WalkNode(configID, nodeID, seen, out)
if not nodeID or seen[nodeID] then return end
seen[nodeID] = true
local ok, node = pcall(C_Traits.GetNodeInfo, configID, nodeID)
if ok and type(node) == "table" then
local name, desc, maxRanks, spellID = ResolveNode(configID, node)
local kids = Children(node)
out[#out + 1] = {
nodeID = nodeID,
name = name,
desc = desc,
maxRanks = maxRanks,
spentRanks = node.activeRank or node.ranksPurchased or 0,
spellID = spellID,
children = kids,
}
for _, kid in ipairs(kids) do WalkNode(configID, kid, seen, out) end
-- Backup: alcuni figli possono non essere in visibleEdges.
local okc, extra = pcall(C_ProfSpecs.GetChildrenForPath, nodeID)
if okc and type(extra) == "table" then
for _, kid in ipairs(extra) do WalkNode(configID, kid, seen, out) end
end
else
-- Albero senza configID (professione non del PG): niente rank, ma il nodo e
-- la sua discendenza restano utili per la struttura.
out[#out + 1] = { nodeID = nodeID, noConfig = true }
local okc, extra = pcall(C_ProfSpecs.GetChildrenForPath, nodeID)
if okc and type(extra) == "table" then
for _, kid in ipairs(extra) do WalkNode(configID, kid, seen, out) end
end
end
end
-- ── Dump ───────────────────────────────────────────
local function Dump(tutto)
local t0 = debugprofilestop()
local db = {
generated = date("%Y-%m-%d %H:%M:%S"),
build = GetBuildInfo(),
character = (UnitName("player")) .. "-" .. (GetRealmName() or "?"),
knownProfessions = {},
trees = {},
notes = {},
}
-- L'elenco delle funzioni esposte serve quando un'API sparisce o cambia nome, cioe'
-- a diagnosi, non a ogni dump: pesa 10 KB dentro `json`, e `json` e' il campo che
-- si legge a mano.
if tutto then
db.api = {
ProfSpecs = ApiNames("C_ProfSpecs"),
Traits = ApiNames("C_Traits"),
TradeSkillUI = ApiNames("C_TradeSkillUI"),
}
end
-- GetProfessions() ritorna nil negli slot vuoti: `ipairs` si fermerebbe al primo nil
-- saltando le professioni negli slot successivi. Si scandisce per indice con select.
for i = 1, select('#', GetProfessions()) do
local idx = select(i, GetProfessions())
if idx then
local name, _, _, _, _, _, skillLine = GetProfessionInfo(idx)
if skillLine then
db.knownProfessions[#db.knownProfessions + 1] = { name = name, skillLine = skillLine }
end
end
end
local candidateLines, seenLine = {}, {}
local function addLine(l)
if l and not seenLine[l] then seenLine[l] = true; candidateLines[#candidateLines + 1] = l end
end
if C_TradeSkillUI and C_TradeSkillUI.GetAllProfessionTradeSkillLines then
local ok, all = pcall(C_TradeSkillUI.GetAllProfessionTradeSkillLines)
if ok and type(all) == "table" then for _, l in ipairs(all) do addLine(l) end end
end
for _, p in ipairs(db.knownProfessions) do addLine(p.skillLine) end
if not C_ProfSpecs then
db.notes[#db.notes + 1] = "C_ProfSpecs assente: API rinominata? Vedi `api`."
end
for _, line in ipairs(candidateLines) do
local tabs
if C_ProfSpecs and C_ProfSpecs.GetSpecTabIDsForSkillLine then
local ok, res = pcall(C_ProfSpecs.GetSpecTabIDsForSkillLine, line)
if ok then tabs = res end
end
if type(tabs) == "table" and #tabs > 0 then
local configID
if C_ProfSpecs.GetConfigIDForSkillLine then
local okc, cfg = pcall(C_ProfSpecs.GetConfigIDForSkillLine, line)
if okc then configID = cfg end
end
-- professione + espansione: serve a tenere la sola riga Midnight fra le
-- varie espansioni della stessa professione.
local profInfo
if C_TradeSkillUI and C_TradeSkillUI.GetProfessionInfoBySkillLineID then
local okp, pi = pcall(C_TradeSkillUI.GetProfessionInfoBySkillLineID, line)
if okp then profInfo = pi end
end
local tree = {
skillLine = line,
configID = configID,
profession = profInfo and profInfo.professionName,
expansion = profInfo and profInfo.expansionName,
tabs = {},
}
for _, tabID in ipairs(tabs) do
local tabInfo
if C_ProfSpecs.GetTabInfo then
local okt, ti = pcall(C_ProfSpecs.GetTabInfo, tabID)
if okt then tabInfo = ti end
end
local tab = {
tabID = tabID,
name = tabInfo and tabInfo.name,
desc = tabInfo and tabInfo.description,
rootNodeID = tabInfo and tabInfo.rootNodeID,
nodes = {},
}
-- ⚠️ L'albero si percorre SOLO se la professione e' speccata su questa
-- skill line (configID valido). Senza configID GetNodeInfo non risponde
-- e ogni nodo esce come { nodeID, noConfig = true }: nessun nome,
-- nessuna descrizione, nessun maxRanks -- cioe' proprio i tre dati per
-- cui questo dump esiste. Misurato sul dump di ieri: 576 nodi vuoti
-- contro 151 pieni, meta' abbondante del file e la gran parte del
-- lavoro, per zero informazione. Il nome delle spec di primo livello
-- resta comunque, perche' viene da `tab.name`, non dai nodi.
-- Con `/wmprof tutto` si torna a percorrerli, se un giorno servisse.
local usabile = configID and configID ~= 0
if tabInfo and tabInfo.rootNodeID and (usabile or tutto) then
WalkNode(usabile and configID or nil, tabInfo.rootNodeID, {}, tab.nodes)
end
tree.tabs[#tree.tabs + 1] = tab
end
db.trees[#db.trees + 1] = tree
end
end
if #db.trees == 0 then
db.notes[#db.notes + 1] =
"Nessun albero. O il PG non ha professioni con spec, o le API non combaciano: rifai con /wmprof tutto e guarda `api`."
end
-- ⚠️ Guardia anti-dump-vuoto (come il dump transmog). Se GetProfessions non ha
-- risposto (knownProfessions vuoto), i dati del PG non erano ancora caricati --
-- succede se si slogga/reloada troppo presto dopo il login: ogni config esce 0 e il
-- dump sembra valido-ma-vuoto. Non si sovrascrive un dump buono precedente, e si urla.
if #db.knownProfessions == 0 then
db.sospetto = "letto a vuoto: knownProfessions vuoto, dati non ancora caricati. NON incollare, rifai /wmprof."
if WowManagerProfDumpDB and WowManagerProfDumpDB.knownProfessions
and #WowManagerProfDumpDB.knownProfessions > 0 then
print(("|cffff2020WowManagerProfDump: letto a vuoto -- tengo il dump di %s (%d prof). Aspetta che i dati carichino e rifai /wmprof.|r")
:format(tostring(WowManagerProfDumpDB.character), #WowManagerProfDumpDB.knownProfessions))
return
end
WowManagerProfDumpDB = db
WowManagerProfDumpDB.json = toJson(db, "")
print("|cffff2020WowManagerProfDump: letto a vuoto (dati non caricati). Aspetta qualche secondo e rifai /wmprof prima del /reload.|r")
return
end
-- ⚠️ `trees` NON si tiene come tabella: e' lo stesso dato che sta gia' dentro
-- `json`, che e' il campo da cui si legge. Erano 100 KB di doppione su 271, riletti
-- a ogni caricamento dell'addon e riscritti a ogni /reload. Con `/wmprof tutto`
-- torna, per quando si vuole guardare la struttura senza sciogliere gli escape.
local json = toJson(db, "")
WowManagerProfDumpDB = {
generated = db.generated,
build = db.build,
character = db.character,
knownProfessions = db.knownProfessions, -- lo legge la guardia qui sopra
notes = db.notes,
json = json,
}
if tutto then
WowManagerProfDumpDB.trees = db.trees
WowManagerProfDumpDB.api = db.api
end
local nNodes, nCap = 0, 0
for _, tree in ipairs(db.trees) do
for _, tab in ipairs(tree.tabs) do
for _, node in ipairs(tab.nodes) do
nNodes = nNodes + 1
if node.maxRanks then nCap = nCap + 1 end
end
end
end
print(("|cff33ff99WowManagerProfDump|r: %s -- %d prof note, %d alberi, %d nodi (%d con cap) in %d ms%s. /reload per scrivere.")
:format(db.character, #db.knownProfessions, #db.trees, nNodes, nCap,
math.floor(debugprofilestop() - t0), tutto and " (tutto)" or ""))
end
-- Il punto d'ingresso per il lanciatore WowManagerDump.
function WowManagerProfDump_Run(msg)
Dump(type(msg) == "string" and msg:lower():find("tutto", 1, true) ~= nil)
end
-- ⚠️ Il comando lo registra il lanciatore: qui si registra SOLO se non c'e'.
local Caricato = (C_AddOns and C_AddOns.IsAddOnLoaded) or IsAddOnLoaded
if not Caricato("WowManagerDump") then
SLASH_WMPROF1 = "/wmprof"
SlashCmdList["WMPROF"] = WowManagerProfDump_Run
end
-- ⚠️ NIENTE dump su PLAYER_LOGOUT: quell'evento scatta anche alla chiusura vera del
-- gioco, dove il client si sta gia' smontando, e da li' escono dump che sembrano
-- buoni ma hanno campi a nil (nel dump mount ci sono costate 1184 icone). Qui il
-- rischio e' lo stesso: `knownProfessions` vuoto lo intercetta la guardia qui sopra,
-- ma i `maxRanks` dei nodi -- i punti, cioe' il motivo per cui questo dump esiste --
-- potrebbero mancare mentre tutto il resto arriva, e nessuna guardia se ne
-- accorgerebbe.
--
-- ⚠️ Il dump al LOGIN invece resta, e non e' un'incoerenza con gli addon mount e
-- transmog, dove l'ho tolto. Li' fotografava la collezione all'ingresso e si perdeva
-- quel che prendevi durante la sessione; qui le professioni note e l'albero speccato
-- non cambiano mentre giochi, quindi lo scatto al login E' il dato. Ed e' anche il
-- modo in cui un alt entra nel tracker professioni: basta loggarlo, senza doversi
-- ricordare /wmprof su ognuno. Se cambi una spec a meta' sessione, /wmprof.
--
-- ⚠️ Ma l'evento non lo aggancia piu' questo file: un addon LoadOnDemand non e'
-- caricato quando PLAYER_LOGIN scatta, quindi un listener scritto qui non partirebbe
-- mai. Sta nel lanciatore (scripts/dump-launcher.lua), che al login calcola una FIRMA
-- delle professioni -- poche chiamate, senza caricare questo modulo -- e lo carica
-- solo se e' cambiata. Prima invece si ripercorrevano tutti gli alberi a ogni login E
-- a ogni /reload per riscrivere quasi sempre lo stesso identico dump.-- WowManagerMountDump - addon locale, NON un addon pubblico.
-- Legge il diario delle cavalcature (C_MountJournal) e scrive in SavedVariables
-- l'elenco COMPLETO delle mount che il gioco conosce -- prese e mancanti -- gia'
-- serializzato in JSON, pronto per mounts/manifest.json.
--
-- Installazione: crea _retail_/Interface/AddOns/WowManagerMountDump/ e copiaci
-- questo file, rinominato -> WowManagerMountDump.lua
-- scripts/WowManagerMountDump.toc -> WowManagerMountDump.toc (invariato)
-- Un addon NUOVO richiede il RIAVVIO del client (il /reload non lo vede).
--
-- ⚠️ E' un addon `LoadOnDemand`: a ogni sessione non viene caricato affatto finche'
-- non serve. Il comando lo registra il lanciatore WowManagerDump (vedi
-- scripts/dump-launcher.lua), che carica questo modulo e chiama
-- WowManagerMountDump_Run. Cosi' i 596 KB di SavedVariables di questo dump non si
-- leggono al login, non stanno in memoria tutta la sessione e non si riscrivono a
-- ogni /reload. Se il lanciatore non c'e', il modulo si registra il comando da se'
-- (in fondo) e resta usabile caricandolo a mano.
--
-- Uso: /wmmount per calcolare il dump, poi /reload (o logout) per scriverlo in
-- WTF/Account/<ACC>/SavedVariables/WowManagerMountDump.lua
-- /wmmount diag aggiunge al file i campi diagnostici (vedi in fondo a Dump)
-- Da li' ci pensa scripts/mount-sync.ps1: il campo che serve e' uno solo,
-- ["mountsJson"] -> il blocco `mounts` del manifest.
--
-- ⚠️ IL DUMP SI CALCOLA A CLIENT VIVO: col comando, oppure da se' quando la
-- collezione cambia (NEW_MOUNT_ADDED, che oggi aggancia il lanciatore perche' questo
-- modulo non e' caricato quando l'evento scatta). Mai in chiusura -- li' `PLAYER_LOGOUT` scatta
-- come sul /reload, ma il client si sta gia' smontando: spellID e
-- creatureDisplayInfoID tornano nil su TUTTE le mount mentre nome, provenienza,
-- tipo e collezione restano giusti. Ne esce un dump che sembra buono in tutto il
-- resto, ed e' cosi' che il 2026-08-04 sono state cancellate 1184 icone.
--
-- Conseguenza da tenere a mente: WoW scrive il SavedVariables al /reload e
-- all'uscita PRENDENDO QUEL CHE STA IN MEMORIA. Se dall'ultimo calcolo non e'
-- cambiato nulla riscrive lo stesso file con gli stessi dati e una data di modifica
-- nuova, quindi la data del file NON dice se il dump e' fresco. Lo dice il campo
-- `generated`, ed e' quello che guardano i due script di sync.
--
-- ⚠️ La collezione mount e' ACCOUNT-WIDE (Warband), come le apparenze transmog:
-- i numeri valgono per l'account, non per il PG loggato.
-- NIENTE mappa mountTypeID -> categoria scritta a mano. Ce n'era una, dedotta dai
-- nomi e da una wiki, e sbagliava: dava 424 = skyriding, cioe' 708 volanti normali
-- nella chip sbagliata. Ora la mappa si IMPARA dal gioco (vedi ImparaTipi): il
-- diario classifica le mount che vede, e da quelle si ricava che categoria ha ogni
-- mountTypeID. Cio' che resta senza categoria resta "?", non si tira a indovinare.
-- sourceType e' un indice, non un testo: il diario delle cavalcature riusa le
-- stringhe BATTLE_PET_SOURCE_n del diario delle mascotte. Si leggono dal client
-- invece di trascriverle, cosi' non si sbaglia ne' l'ordine ne' le voci nuove.
-- Esce gia' in JSON: lo script di sync non deve leggere tabelle Lua.
local function SorgentiJson()
local parti = {}
for i = 1, 40 do
local s = _G["BATTLE_PET_SOURCE_" .. i]
if s and s ~= "" then parti[#parti + 1] = ('"%d": "%s"'):format(i, s) end
end
return "{ " .. table.concat(parti, ", ") .. " }"
end
-- Escape JSON: virgolette, backslash e i caratteri di controllo. Gli a capo del
-- testo di provenienza si tengono come \n -- il sito li usa per separare la riga
-- "Drop: <boss>" dalla zona -- il resto dei controlli si butta.
local function esc(s)
return (tostring(s):gsub('[%c"\\]', function(c)
if c == '"' then return '\\"' end
if c == '\\' then return '\\\\' end
if c == '\n' then return '\\n' end
return ''
end))
end
-- ⚠️ REQUISITI DI CLASSE: NON provare a leggerli dal client, e' gia' stato fatto.
-- L'idea era il tooltip della spell (C_TooltipInfo.GetSpellByID + SurfaceArgs),
-- cercando le righe "Requires ...". MISURATO su tutte le 1532 mount: zero requisiti
-- trovati E zero righe "Requires" non riconosciute, cioe' quelle righe nel tooltip
-- di gioco non esistono proprio. La provenienza non aiuta: cita una classe in 23
-- casi su 1532. Il requisito lo da' Wowhead, e lo legge mount-sync.ps1 dallo stesso
-- endpoint da cui prende l'icona (blocco `wowhead-tooltip-requirements`).
-- La RAZZA non la sa nessuna delle due fonti: per le cavalcature razziali dei
-- paladini Wowhead dichiara solo "Requires Paladin".
-- Il testo di provenienza e' pieno del markup del client, non solo dei colori:
-- |cFFFFD200Drop:|r colore
-- |n a capo -- NON e' \n, e senza tradurlo le righe si fondono
-- |TInterface\ICONS\...|t icona inline (la si trova nel costo dei vendor)
-- |Hcurrency:3303|h...|h link (il testo visibile e' dentro)
-- Va tolto tutto: il sito mostra questa riga cosi' com'e', e un dump grezzo ci
-- portava dentro pezzi di path di texture.
local residui = {}
local costiGrezzi = {}
local function pulisci(s)
if not s or s == "" then return nil end
-- ⚠️ LA VALUTA PRIMA DI TUTTO IL RESTO: il costo di un vendor arriva come
-- "Cost: 7|Hcurrency:3303|h|TInterface\ICONS\...|t|h", cioe' un LINK che come
-- testo visibile ha solo un'icona. Passandolo dallo spoglia-texture e dallo
-- spoglia-link (sotto) resta il numero nudo -- "Cost: 7" -- e non si capisce 7
-- di cosa. Qui il link diventa il nome vero della valuta, chiesto al client.
-- Diagnostica: una riga "Cost:" per FORMA di markup, non le prime che capitano.
-- Senza deduplicare uscivano quindici volte lo stesso "Cost: 1" in oro e non si
-- vedeva mai il caso con la valuta.
if s:find("Cost:", 1, true) then
for riga in (s .. "|n"):gmatch("(.-)|n") do
if riga:find("Cost:", 1, true) then
local forma = riga:gsub("%d", "")
if not costiGrezzi[forma] then costiGrezzi[forma] = riga end
end
end
end
-- ⚠️ NIENTE pattern che dipendano dalle maiuscole: i pattern Lua non hanno il
-- case-insensitive e il client NON e' coerente -- il link e' "|Hcurrency:" in
-- minuscolo, ma il path della texture arriva tutto MAIUSCOLO
-- ("|TINTERFACE\MONEYFRAME\UI-GOLDICON.BLP"). Un primo tentativo scritto sulla
-- grafia "Interface\MoneyFrame\UI-GoldIcon" non agganciava niente.
--
-- Prima i LINK: quello di valuta ha come testo visibile solo un'icona, quindi
-- va risolto qui o si perde. Il nome vero lo da' il client.
s = s:gsub("|H(.-)|h(.-)|h", function(link, testo)
local tipo, id = link:match("^([^:]+):(%d+)")
tipo = tipo and tipo:lower()
if tipo == "currency" then
local info = C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo(tonumber(id))
return " " .. ((info and info.name) or ("valuta " .. id))
end
-- Non si paga solo in valute: parecchi vendor vogliono OGGETTI (Primal Water,
-- Gladiator's Medallion, Stormlord's Favor...), e li' il link e' |Hitem:.
--
-- ⚠️ Il NOME non si chiede al client: GetItemInfo risponde nil finche'
-- l'oggetto non e' in cache, e per roba mai vista non ci arriva. Provato con
-- RequestLoadItemDataByID: dopo un giro completo 31 nomi su 32 mancavano
-- ancora. Quindi qui si scrive un segnaposto con l'ID e il nome lo risolve
-- mount-sync.ps1 su Wowhead, una volta sola. Deterministico: non dipende piu'
-- da cosa il client si e' trovato in cache in quel momento.
if tipo == "item" then
return " {item:" .. id .. "}"
end
return testo
end)
-- Poi le TEXTURE: quelle delle monete diventano il simbolo (letto dal client,
-- non trascritto), tutte le altre icone inline si buttano.
s = s:gsub("|T([^|]*)|t", function(path)
local P = path:upper()
if P:find("GOLDICON", 1, true) then return GOLD_AMOUNT_SYMBOL or "g" end
if P:find("SILVERICON", 1, true) then return SILVER_AMOUNT_SYMBOL or "s" end
if P:find("COPPERICON", 1, true) then return COPPER_AMOUNT_SYMBOL or "c" end
-- Qualche valuta e' disegnata come texture NUDA, senza link che la nomini:
-- l'onore lo dice il path stesso ("PVPCurrency-Honor-Alliance").
if P:find("PVPCURRENCY-HONOR", 1, true) then return " " .. (HONOR or "Honor") end
return ""
end)
s = s:gsub("|A.-|a", "") -- atlas
s = s:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")
s = s:gsub("|n", "\n")
-- Riga per riga: spazi doppi via (il gioco scrive "Drop: Boss"), righe vuote via.
local out = {}
for riga in (s .. "\n"):gmatch("(.-)\n") do
riga = riga:gsub("%s+", " "):gsub("^ ", ""):gsub(" $", "")
if riga ~= "" then out[#out + 1] = riga end
end
s = table.concat(out, "\n")
-- Diagnostica: se resta una barra verticale c'e' un markup che non conosco.
-- Meglio saperlo dal dump che scoprirlo in pagina.
if s:find("|", 1, true) and #residui < 8 then residui[#residui + 1] = s end
return s ~= "" and s or nil
end
-- Segnaposto di Blizzard rimasti nel diario: non sono cavalcature, sono voci di
-- lavorazione ("(PH)" = placeholder, "[DND]" = do not distribute). Si scartano
-- perche' sul sito sarebbero righe finte, e si contano per non farlo in silenzio.
-- ⚠️ Le PARENTESI QUADRE vanno accettate quanto le tonde: la prima stesura cercava il
-- solo "(PH)" e lasciava passare cinque voci "[PH] ..." -- fra cui "[PH] Horse with
-- Hat" -- che sono finite in pagina come cavalcature mancanti da collezionare.
-- ⚠️ E il marchio NON sta sempre in testa: "Green Rocket Mount [PH]" e "Pink Rocket
-- Mount [PH]" ce l'hanno in CODA, quindi con i pattern ancorati a "^" sono rimaste in
-- pagina per tutto il tempo, anche col filtro a quattro forme. Si cerca ovunque nel
-- nome: verificato sul manifest, le sole voci con quelle parentesi sono i 7 segnaposto.
local function segnaposto(name)
return name:find("%(PH%)") ~= nil or name:find("%[PH%]") ~= nil
or name:find("%[DND%]") ~= nil or name:find("%(DND%)") ~= nil
end
-- Indice del filtro "Type" del diario -> chiave usata dal sito.
--
-- ⚠️ Gli indici NON si indovinano dall'ordine del menu: e' gia' costato due errori.
-- Il 5 sembrava "Ride Along" perche' il menu mostra quella voce per quarta, ma le 14
-- mount che contiene sono i draghi personalizzabili (Renewed Proto-Drake, Highland
-- Drake, i Delver's...): e' SKYRIDING. Si guarda cosa c'e' dentro, non come si
-- chiama la voce -- per questo il dump riporta in `filtriEsempi` i primi nomi di
-- ogni indice.
--
-- Si prova ogni indice anche quando IsValidTypeFilter dice di no (l'indice 4 lo
-- dichiara, ma costa nulla chiederglielo lo stesso): se un giorno risponde, la
-- categoria arriva gratis invece di restare "Altro".
local TIPO_FILTRO = {
[1] = "terra", [2] = "volo", [3] = "acqua",
[4] = "passeggero", -- Ride Along: oggi si dichiara non valido e non risponde
[5] = "skyriding", -- i draghi personalizzabili, 14 in Midnight
}
-- Categorie che dipendono dal TIPO della cavalcatura, e che quindi il dizionario
-- imparato puo' estendere alle mount nascoste al personaggio loggato.
-- ⚠️ "passeggero" resta fuori: e' una proprieta' della singola mount (un mammut da
-- carico ha lo stesso mountTypeID di uno normale), quindi estenderla per tipo
-- marcherebbe come passeggero tutte le mount di quel tipo.
local CAT_DA_TIPO = { terra = true, volo = true, acqua = true, skyriding = true }
-- LE CATEGORIE LE DA' IL GIOCO, non si deducono.
-- Il diario ha i suoi filtri per tipo, gli stessi quattro che si vedono nel menu, e
-- l'API per pilotarli: si accende un tipo alla volta e si legge chi resta in elenco.
-- E' l'unica strada autorevole -- il mountTypeID e' un numero senza documentazione, e
-- dedurlo aveva gia' portato a mettere 708 volanti sotto "skyriding".
--
-- ⚠️ Si toccano i filtri del giocatore, quindi si salva e si rimette tutto: per tipi,
-- sorgenti e stato "collezionate" esistono i getter (IsTypeChecked, IsSourceChecked,
-- GetCollectedFilterSetting). L'unico senza getter e' la ricerca testuale, che va
-- azzerata per forza (una ricerca attiva nasconderebbe mount e falserebbe tutto): se
-- avevi del testo scritto nel diario, lo ritrovi da riscrivere.
--
-- Una mount puo' stare in PIU' categorie (una volante che porta passeggeri e' in
-- "volo" e in "passeggero"), quindi il risultato e' una lista, non un valore solo.
local function CategorieDalDiario(diag)
local J = C_MountJournal
if not (J.SetTypeFilter and J.IsTypeChecked and J.GetNumDisplayedMounts and J.GetDisplayedMountID) then
return nil, {}, "API dei filtri assente"
end
-- Si prova OGNI indice noto, valido o no: IsValidTypeFilter serve solo a sapere
-- se lo stato attuale va salvato, non a decidere se interrogarlo.
-- ⚠️ Ma "ogni indice noto" sono i 5 di TIPO_FILTRO, non 8: ogni indice in piu' e'
-- un altro giro completo di filtraggio del diario su 1600 cavalcature, e i tre
-- oltre il quinto non hanno mai risposto. Restano interrogabili con `diag`, che
-- e' il momento in cui la domanda "ne e' comparso uno nuovo?" ha senso.
local salvaTipi, salvaSorg, salvaColl, tipi = {}, {}, {}, {}
-- ⚠️ 5 scritto, non `#TIPO_FILTRO`: quella tabella ha le chiavi numeriche
-- esplicite, e su un costruttore fatto cosi' l'operatore # puo' rispondere 0.
for i = 1, (diag and 8 or 5) do
tipi[#tipi + 1] = i
local ok, valido = pcall(J.IsValidTypeFilter, i)
if ok and valido then
local ok2, stato = pcall(J.IsTypeChecked, i)
if ok2 then salvaTipi[i] = stato end
end
end
for i = 1, 24 do
local ok, valido = pcall(J.IsValidSourceFilter, i)
if ok and valido then salvaSorg[i] = J.IsSourceChecked(i) end
end
for i = 1, 3 do
local ok, v = pcall(J.GetCollectedFilterSetting, i)
if ok then salvaColl[i] = v end
end
-- Tutto visibile: non collezionate e "non utilizzabili" comprese, altrimenti
-- restano fuori dall'elenco e non vengono classificate.
for i, _ in pairs(salvaColl) do J.SetCollectedFilterSetting(i, true) end
J.SetAllSourceFilters(true)
pcall(J.SetSearch, "")
local perMount, conta, esempi = {}, {}, {}
for _, t in ipairs(tipi) do
J.SetAllTypeFilters(false)
if pcall(J.SetTypeFilter, t, true) then
local n = J.GetNumDisplayedMounts() or 0
conta[t] = n
for j = 1, n do
local id = J.GetDisplayedMountID(j)
if id then
perMount[id] = perMount[id] or {}
perMount[id][#perMount[id] + 1] = TIPO_FILTRO[t] or ("tipo" .. t)
-- Tre nomi per indice: e' cosi' che si controlla l'accoppiamento
-- indice/categoria, invece di fidarsi dell'ordine del menu.
local ex = esempi[t] or {}
if #ex < 3 then
ex[#ex + 1] = (C_MountJournal.GetMountInfoByID(id))
esempi[t] = ex
end
end
end
end
end
-- Rimettere le cose com'erano, nell'ordine inverso.
J.SetAllTypeFilters(false)
for i, v in pairs(salvaTipi) do J.SetTypeFilter(i, v) end
for i, v in pairs(salvaSorg) do J.SetSourceFilter(i, v) end
for i, v in pairs(salvaColl) do J.SetCollectedFilterSetting(i, v) end
return perMount, conta, nil, esempi
end
-- Le stringhe del menu filtri, cosi' com'e' scritto in gioco: servono a confermare
-- che l'indice 4 sia davvero "Ride Along" e non qualcos'altro.
local function EtichetteFiltri()
local out = {}
for k, v in pairs(_G) do
if type(k) == "string" and type(v) == "string" and k:find("^MOUNT_JOURNAL") then
out[k] = v
end
end
return out
end
-- Quali funzioni C_MountJournal esistono davvero: e' cosi' che si e' scoperto che i
-- filtri per tipo sono pilotabili (SetTypeFilter/GetDisplayedMountID) invece di
-- dover dedurre la categoria dal mountTypeID. Serve a saperlo senza indovinare.
local function ApiNames()
local out = {}
for k, v in pairs(C_MountJournal) do
if type(v) == "function" then out[#out + 1] = k end
end
table.sort(out)
return out
end
-- ⚠️ Il diario elenca solo le cavalcature VISIBILI al personaggio loggato: quelle
-- di un'altra fazione o di un'altra classe non compaiono, filtri o non filtri
-- (misurato: 1223 classificate su 1624). Quindi la classificazione diretta da sola
-- lascerebbe fuori centinaia di mount.
--
-- La toppa non e' una mappa scritta a mano -- gia' provata, gia' sbagliata -- ma
-- IMPARARLA: le mount che il diario classifica dicono che categoria ha ogni
-- mountTypeID, e quel dizionario copre anche le nascoste. Ogni voce viene comunque
-- dal gioco, nessuna deduzione da wiki.
--
-- Solo per le categorie di CAT_DA_TIPO: "passeggero" non dipende dal tipo (il mammut
-- da carico ha lo stesso tipo di uno normale), quindi non si estende per tipo e
-- resta noto per le sole mount che il diario mostra.
local function ImparaTipi(raccolta, ambigui)
local conteggi = {}
for _, r in ipairs(raccolta) do
if r.cats and #r.cats > 0 then
local base = {}
for _, c in ipairs(r.cats) do
if CAT_DA_TIPO[c] then base[#base + 1] = c end
end
if #base > 0 then
table.sort(base)
local chiave = table.concat(base, "+")
conteggi[r.type] = conteggi[r.type] or {}
conteggi[r.type][chiave] = (conteggi[r.type][chiave] or 0) + 1
end
end
end
local out = {}
for tipo, combo in pairs(conteggi) do
local migliore, quante, distinte = nil, -1, 0
for chiave, n in pairs(combo) do
distinte = distinte + 1
if n > quante then migliore, quante = chiave, n end
end
-- Un tipo che classifica in due modi diversi e' sospetto: si sceglie il piu'
-- frequente ma lo si dichiara, invece di nasconderlo.
if distinte > 1 then
local dett = {}
for chiave, n in pairs(combo) do dett[#dett + 1] = chiave .. "=" .. n end
table.sort(dett)
ambigui[#ambigui + 1] = ("tipo %d: %s -> scelgo %s")
:format(tipo, table.concat(dett, ", "), migliore)
end
local lista = {}
for c in migliore:gmatch("[^+]+") do lista[#lista + 1] = c end
out[tipo] = lista
end
return out
end
-- ⚠️ IL DUMP GENERATO MENTRE IL GIOCO SI CHIUDE E' DIFETTOSO, e in un modo che le
-- guardie non vedevano. PLAYER_LOGOUT scatta sia sul /reload sia all'uscita vera, ma
-- nell'uscita vera il client si sta gia' smontando: spellID e creatureDisplayInfoID
-- tornano NIL su TUTTE le mount, mentre nome, provenienza, tipo e "collezionata"
-- continuano ad arrivare giusti. Non e' un cambio di API -- misurato: lo stesso
-- codice, sullo stesso client 12.1.0, a meta' sessione li da' tutti (Abyss Worm:
-- spell 232519, display 74315) -- ed e' l'errore di lettura che avevo fatto io
-- prima di confrontare i due dump.
--
-- E non e' un difetto qualsiasi: quei due sono le CHIAVI con cui il repo tiene
-- insieme il resto. La spell aggancia il nome dell'icona gia' risolto nel manifest
-- (a zero il sync le considera tutte orfane e cancella i file, 1184 nel 2026-08-04)
-- e il display nomina il file dell'immagine in public/mounts/.
--
-- Il dump transmog ha lo stesso gancio e si e' salvato da solo: li' all'uscita la
-- collezione si legge VUOTA, quindi scatta la guardia che gia' c'era e tiene il dump
-- buono di meta' sessione. Qui no: le mount prese si contavano ancora (702), a
-- mancare erano i soli ID. Da qui la guardia in fondo, che e' la difesa vera.
--
-- ⚠️ Questi due ripieghi invece NON sono verificati: all'uscita non e' detto che
-- queste altre strade rispondano meglio delle prime. Costano nulla, scattano solo
-- quando il campo e' gia' perso, e se anche loro tacciono e' la guardia a salvare il
-- dump buono. Se un giorno il campo `ripieghi` mostra numeri diversi da zero, allora
-- funzionano davvero e si potra' dirlo.
local function SpellDi(id, spellID)
if spellID and spellID > 0 then return spellID, false end
-- Il link della mount e' un link di spell: l'id sta li' dentro.
local ok, link = pcall(C_MountJournal.GetMountLink, id)
if ok and type(link) == "string" then
local s = link:match("Hspell:(%d+)")
if s then return tonumber(s), true end
end
return 0, false
end
-- Il display si ripiglia dalle due funzioni "tutti i display di questa mount": la
-- prima voce e' l'aspetto di base. Tornano una lista di numeri o di tabelle a
-- seconda della funzione, quindi si accettano entrambe le forme invece di
-- indovinare quale sia.
local function PrimoDisplay(v)
if type(v) == "number" then return v end
if type(v) == "table" then
return v.creatureDisplayID or v.creatureDisplayInfoID or v[1]
end
end
local function DisplayDi(id, displayID)
if displayID and displayID > 0 then return displayID, false end
for _, nome in ipairs({ "GetAllCreatureDisplayIDsForMountID", "GetMountAllCreatureDisplayInfoByID" }) do
local f = C_MountJournal[nome]
if f then
local ok, t = pcall(f, id)
if ok and type(t) == "table" then
local d = PrimoDisplay(t[1])
if type(d) == "number" and d > 0 then return d, true end
end
end
end
return 0, false
end
-- Sonda: la tupla GREZZA di ogni chiamata, posizione per posizione, con il tipo.
-- Serve a non dover piu' indovinare dove un campo sia finito -- e' cosi' che si e'
-- visto che spell e display sono nil e non spostati. Se un giorno tornano, o si
-- spostano davvero, si legge qui senza rimettere mano al codice.
local function Sonda(ids)
local function mostra(v)
if type(v) == "table" then
local n = 0
for _ in pairs(v) do n = n + 1 end
local d = PrimoDisplay(v[1])
return ("table(n=%d, primo=%s)"):format(n, tostring(d))
end
return tostring(v)
end
local out = {}
for _, id in ipairs(ids) do
local righe = {}
local function tupla(etichetta, ok, ...)
if not ok then righe[#righe + 1] = etichetta .. ": ERRORE"; return end
local pezzi = {}
for i = 1, select("#", ...) do
local v = select(i, ...)
pezzi[i] = ("%d=%s:%s"):format(i, type(v), mostra(v))
end
righe[#righe + 1] = etichetta .. ": " .. table.concat(pezzi, " ")
end
tupla("GetMountInfoByID", pcall(C_MountJournal.GetMountInfoByID, id))
tupla("GetMountInfoExtraByID", pcall(C_MountJournal.GetMountInfoExtraByID, id))
tupla("GetMountLink", pcall(C_MountJournal.GetMountLink, id))
for _, nome in ipairs({ "GetAllCreatureDisplayIDsForMountID", "GetMountAllCreatureDisplayInfoByID" }) do
if C_MountJournal[nome] then tupla(nome, pcall(C_MountJournal[nome], id)) end
end
out[tostring(id)] = righe
end
return out
end
local function Dump(diag)
local t0 = debugprofilestop()
local voci, presi = {}, 0
local tipi, scartate = {}, {}
local senzaCat, tipiAmbigui = {}, {}
local ripieghi = { spell = 0, display = 0, senzaSpell = 0, senzaDisplay = 0 }
costiGrezzi = {}
local cop = { diario = 0, perTipo = 0, perNome = 0, niente = 0, nascosteScoperte = 0 }
residui = {}
local daDiario, contaFiltri, erroreFiltri, esempiFiltri = CategorieDalDiario(diag)
-- Passata 1: si raccoglie tutto, categorie comprese dove il diario le da'.
local raccolta = {}
for _, id in ipairs(C_MountJournal.GetMountIDs() or {}) do
local name, spellID, _, _, _, sourceType, _, isFactionSpecific, faction,
shouldHideOnChar, isCollected, mountID = C_MountJournal.GetMountInfoByID(id)
-- creatureDisplayInfoID, description, source, isSelfMount, mountTypeID, ...
-- `description` e' il testo di colore ("These beasts of burden are known
-- to..."), diverso da `source` che e' la provenienza. Lo mostra la modale.
-- `creatureDisplayInfoID` identifica il MODELLO: e' la chiave con cui si
-- ritrova l'immagine della cavalcatura, che l'icona da sola non da'.
-- ⚠️ Questa posizione e la spellID qui sopra sono le due che si perdono nel
-- dump generato mentre il gioco si chiude: a meta' sessione arrivano giuste.
local displayID, descrizione, source, _, mountTypeID = C_MountJournal.GetMountInfoExtraByID(id)
if name and segnaposto(name) then
scartate[#scartate + 1] = name
elseif name then
mountID = mountID or id
local spell, spellDaLink = SpellDi(id, spellID)
local display, displayDaLista = DisplayDi(id, displayID)
if spellDaLink then ripieghi.spell = ripieghi.spell + 1 end
if displayDaLista then ripieghi.display = ripieghi.display + 1 end
if spell == 0 then ripieghi.senzaSpell = ripieghi.senzaSpell + 1 end
if display == 0 then ripieghi.senzaDisplay = ripieghi.senzaDisplay + 1 end
tipi[mountTypeID or 0] = (tipi[mountTypeID or 0] or 0) + 1
if isCollected then presi = presi + 1 end
raccolta[#raccolta + 1] = {
id = mountID, spell = spell, name = name,
src = sourceType or 0, srcText = pulisci(source) or "",
desc = pulisci(descrizione) or "",
display = display,
type = mountTypeID or 0,
nascosta = shouldHideOnChar and true or false,
faction = isFactionSpecific and ((faction == 0) and "horde" or "alliance") or nil,
got = isCollected and 1 or 0,
cats = daDiario and daDiario[mountID] or nil,
}
end
end
-- Tre mount a campione per la sonda: bastano a vedere la forma delle tuple, e
-- tenerle poche fa si' che il campo resti leggibile a occhio nel SavedVariables.
-- La sonda vera si esegue solo con `diag` o quando il dump si auto-segnala: e'
-- diagnostica, e a dump sano nessuno la legge.
local sondaIds = {}
for i = 1, math.min(3, #raccolta) do sondaIds[i] = raccolta[i].id end
-- Si impara il dizionario tipo -> categorie, poi lo si applica a chi e' rimasto
-- fuori dall'elenco del diario.
local perTipo = ImparaTipi(raccolta, tipiAmbigui)
for _, r in ipairs(raccolta) do
if r.cats and #r.cats > 0 then
cop.diario = cop.diario + 1
else
local dal = perTipo[r.type]
if dal and #dal > 0 then
r.cats = dal
cop.perTipo = cop.perTipo + 1
if r.nascosta then cop.nascosteScoperte = cop.nascosteScoperte + 1 end
end
end
end
-- Ultimo anello: eredita da un OMONIMO gia' classificato.
-- Il diario tiene piu' record per la stessa cavalcatura: `Cliffside Wylderdrake`
-- esiste come mountID 1591 (tipo 402, classificato volo+skyriding) e come 1788
-- (tipo 426, invisibile e senza categoria), con la stessa identica provenienza.
-- Senza questo passaggio lo stesso drago compare sia in Skyriding sia in «Altro».
-- ⚠️ NON e' una deduplicazione: i doppioni restano due voci -- e devono, perche'
-- altri omonimi sono mount davvero diverse (il `Black War Bear` di For The
-- Alliance! e quello di For The Horde!). Si copia la sola categoria.
local perNome = {}
for _, r in ipairs(raccolta) do
if r.cats and #r.cats > 0 and not perNome[r.name] then perNome[r.name] = r.cats end
end
for _, r in ipairs(raccolta) do
if not r.cats or #r.cats == 0 then
local dal = perNome[r.name]
if dal then
r.cats = dal
cop.perNome = cop.perNome + 1
else
r.cats = {}
cop.niente = cop.niente + 1
if #senzaCat < 20 then
senzaCat[#senzaCat + 1] = ("%s (tipo %d)"):format(r.name, r.type)
end
end
end
end
-- Passata 2: serializzazione.
for _, r in ipairs(raccolta) do
local catsJson = {}
for i, c in ipairs(r.cats) do catsJson[i] = '"' .. c .. '"' end
voci[#voci + 1] = {
name = r.name,
-- `class` non c'e': non e' un dato del client. Lo aggiunge mount-sync.ps1
-- leggendolo da Wowhead, insieme al nome dell'icona.
json = ('{"id":%d,"spell":%d,"display":%d,"name":"%s","src":%d,"srcText":"%s","desc":"%s","type":%d,"cats":[%s],"faction":%s,"got":%d}')
:format(r.id, r.spell, r.display, esc(r.name), r.src, esc(r.srcText), esc(r.desc), r.type,
table.concat(catsJson, ","),
r.faction and ('"' .. r.faction .. '"') or "null", r.got),
}
end
-- Ordine alfabetico: il file finisce in git, e un ordine stabile fa si' che il
-- diff mostri le mount nuove invece di un rimescolamento completo.
table.sort(voci, function(a, b) return a.name < b.name end)
-- ⚠️ GUARDIA, come nel dump transmog: se il client non ha ancora caricato la
-- collezione, l'elenco esce completo ma con TUTTE le mount a zero. E' un dump
-- internamente coerente -- nulla lo distingue da uno buono -- e incollarlo
-- azzererebbe la collezione. Se c'e' gia' un dump buono NON lo si sovrascrive.
if #voci > 0 and presi == 0 then
local avviso = "collezione non ancora caricata"
if WowManagerMountDumpDB and (WowManagerMountDumpDB.presi or 0) > 0 then
print(("|cffff2020WowManagerMountDump: %s -- tengo il dump di %s (%d mount). Rilancia fra un minuto.|r")
:format(avviso, tostring(WowManagerMountDumpDB.generated), WowManagerMountDumpDB.presi))
return
end
print("|cffff2020WowManagerMountDump: " .. avviso .. ". NON sincronizzare.|r")
WowManagerMountDumpDB = {
generated = date("%Y-%m-%d %H:%M:%S"),
sospetto = avviso .. ": nessuna mount risulta collezionata. NON sincronizzare.",
presi = presi, totale = #voci,
}
return
end
-- ⚠️ Stessa guardia, per il guasto gemello: la spell e' la chiave con cui il
-- manifest fa da cache ai nomi delle icone, quindi un dump che la perde e' pieno
-- di nomi e provenienze giuste e intanto manda in fumo public/icons/mount. Un
-- dump del genere e' internamente coerente: senza questo controllo passerebbe.
if #voci > 0 and ripieghi.senzaSpell > #voci / 20 then
local avviso = ("%d mount su %d senza spellID, ripescate dal link %d")
:format(ripieghi.senzaSpell, #voci, ripieghi.spell)
-- ⚠️ Ma si tiene il dump vecchio solo se e' sano DAVVERO. Quello dell'altra
-- guardia basta che abbia mount prese; qui no: il dump di stanotte ha 702
-- prese, nessun `sospetto` e le spell tutte a zero, cioe' passerebbe per
-- buono e la guardia si incastrerebbe da sola, tenendo per sempre proprio il
-- file che stiamo cercando di rifare. Il difetto si vede nei dati, non nei
-- contatori: si guarda se la spell c'e'.
local vecchio = WowManagerMountDumpDB
if vecchio and (vecchio.presi or 0) > 0 and not vecchio.sospetto
and type(vecchio.mountsJson) == "string"
and not vecchio.mountsJson:find('"spell":0,', 1, true) then
print(("|cffff2020WowManagerMountDump: %s -- tengo il dump di %s.|r")
:format(avviso, tostring(vecchio.generated)))
return
end
print("|cffff2020WowManagerMountDump: " .. avviso .. ". NON sincronizzare.|r")
WowManagerMountDumpDB = {
generated = date("%Y-%m-%d %H:%M:%S"),
sospetto = avviso .. ". NON sincronizzare.",
presi = presi, totale = #voci,
build = GetBuildInfo(),
api = ApiNames(),
ripieghi = ripieghi,
sonda = Sonda(sondaIds),
}
return
end
local parti = {}
for i, v in ipairs(voci) do parti[i] = " " .. v.json end
-- ⚠️ QUI STA IL DUMP, e sono i soli campi che mount-sync.ps1 legge davvero
-- (mountsJson, sorgentiJson, generated, sospetto, presi, totale) piu' quelli
-- piccoli che dicono se qualcosa e' andato storto senza doverlo chiedere.
WowManagerMountDumpDB = {
generated = date("%Y-%m-%d %H:%M:%S"),
sospetto = nil, -- nil = dump buono. Se valorizzato, NON sincronizzare.
presi = presi, totale = #voci,
build = GetBuildInfo(),
mountsJson = "[\n" .. table.concat(parti, ",\n") .. "\n ]",
sorgentiJson = SorgentiJson(), -- indice sourceType -> etichetta, presa dal client
residui = residui, -- provenienze con markup non riconosciuto (dovrebbe essere vuoto)
ripieghi = ripieghi, -- quante spell/display sono arrivate per la strada alternativa
copertura = cop, -- quante dal diario, quante dal tipo imparato, quante niente
tipiAmbigui = tipiAmbigui, -- tipi che classificano in piu' modi (dovrebbe essere vuoto)
filtriErrore = erroreFiltri, -- valorizzato = il diario non ha classificato nulla
senzaCat = senzaCat, -- mount rimaste senza nessuna categoria
}
-- ⚠️ Il resto e' DIAGNOSTICA, e si scrive solo con `/wmmount diag`. Non e' roba
-- da buttare -- e' con questi campi che si e' scoperto che gli indici dei filtri
-- non seguono l'ordine del menu, e che 424 non era skyriding -- ma sono domande
-- che ci si fa quando qualcosa non torna, non a ogni sync. Tenerli sempre voleva
-- dire calcolarli sempre: EtichetteFiltri scandisce tutto _G, Sonda interroga sei
-- API per mount, ApiNames elenca C_MountJournal intero.
if diag then
local d = WowManagerMountDumpDB
d.tipi = tipi -- mountTypeID -> quante mount, per vedere cosa esiste
d.scartate = scartate -- segnaposto di Blizzard tolti dall'elenco
d.costiGrezzi = costiGrezzi -- righe "Cost:" GREZZE: dicono se la valuta e' stata letta
d.api = ApiNames() -- funzioni C_MountJournal davvero esposte
d.sonda = Sonda(sondaIds) -- la tupla grezza delle chiamate, per non indovinare
d.filtri = contaFiltri -- indice del filtro Type -> quante mount ci stanno
d.filtriEsempi = esempiFiltri -- e i primi nomi: e' cosi' che si verifica l'accoppiamento
d.filtriEtichette = EtichetteFiltri() -- le stringhe del menu, per confermare gli indici
d.tipiImparati = perTipo -- il dizionario ricavato: mountTypeID -> categorie
end
print(("|cff33ff99WowManagerMountDump|r: %d mount, %d collezionate in %d ms%s. Categorie: %d dal diario, %d dal tipo, %d da omonimo, %d senza. /reload per scrivere.")
:format(#voci, presi, math.floor(debugprofilestop() - t0), diag and " (diag)" or "",
cop.diario, cop.perTipo, cop.perNome, cop.niente))
if ripieghi.spell > 0 or ripieghi.display > 0 or ripieghi.senzaDisplay > 0 then
print(("|cffffcc00WowManagerMountDump|r: spell dal link %d, display dalla lista %d; restano senza: spell %d, display %d.")
:format(ripieghi.spell, ripieghi.display, ripieghi.senzaSpell, ripieghi.senzaDisplay))
end
end
-- ⚠️ IL DUMP SI RICALCOLA A CLIENT VIVO, MAI IN CHIUSURA -- ed e' tutta qui la
-- differenza. Prima c'erano PLAYER_LOGIN (timer di 5s) e PLAYER_LOGOUT ("rigenera
-- prima della scrittura, cosi' il file e' sempre fresco"): il secondo scattava anche
-- all'uscita vera, dove il client si sta gia' smontando, ed e' quello che il
-- 2026-08-04 ha prodotto il dump con gli spellID a zero e cancellato 1184 icone.
--
-- Adesso l'unico innesco automatico e' la collezione che cambia DAVVERO, mentre
-- giochi -- ed e' agganciato dal lanciatore WowManagerDump, non da qui: questo modulo
-- e' LoadOnDemand, quindi quando NEW_MOUNT_ADDED scatta non e' nemmeno caricato, e un
-- evento registrato in questo file non lo vedrebbe mai. Li' stanno anche il ritardo
-- anti-raffica e il riguardo per il diario aperto (che questo dump manipola).
-- All'uscita non si ricalcola nulla: WoW versa su disco quel che sta in memoria, che
-- a quel punto e' un dump fatto col client integro. Ne segue una cosa comoda: dopo
-- aver chiuso il gioco il file e' gia' buono, e il sync gira con -Subito.
-- Il punto d'ingresso per il lanciatore. `msg` arriva dal comando: "diag" accende i
-- campi diagnostici, tutto il resto vale dump normale.
function WowManagerMountDump_Run(msg)
Dump(type(msg) == "string" and msg:lower():find("diag", 1, true) ~= nil)
end
-- ⚠️ Il comando lo registra il lanciatore. Qui si registra SOLO se il lanciatore non
-- c'e' -- altrimenti sarebbero due handler sulla stessa stringa -- cosi' il modulo
-- caricato a mano (LoadAddOn) resta utilizzabile da solo.
local Caricato = (C_AddOns and C_AddOns.IsAddOnLoaded) or IsAddOnLoaded
if not Caricato("WowManagerDump") then
SLASH_WMMOUNT1 = "/wmmount"
SlashCmdList["WMMOUNT"] = WowManagerMountDump_Run
end-- WowManagerTierDump - addon locale, NON un addon pubblico.
-- Legge dal journal transmog del gioco (C_TransmogSets) quanti pezzi di ogni
-- tier set / class set sono collezionati sull'account (Warband) e genera il
-- blocco `collected` di transmog/manifest.json, gia' pronto da incollare.
--
-- Installazione: crea _retail_/Interface/AddOns/WowManagerTierDump/ e copiaci
-- questo file, rinominato -> WowManagerTierDump.lua
-- scripts/WowManagerTierDump.toc -> WowManagerTierDump.toc (invariato)
-- Il .toc sta nel repo accanto a questo file: prima era solo trascritto qui in
-- forma abbreviata, quindi una reinstallazione da zero lo perdeva.
-- Un addon nuovo richiede il RIAVVIO del client (il /reload non lo vede).
--
-- ⚠️ E' un addon `LoadOnDemand`: nelle sessioni in cui non lo usi non viene caricato
-- affatto. Il comando lo registra il lanciatore WowManagerDump (vedi
-- scripts/dump-launcher.lua), che carica questo modulo e chiama
-- WowManagerTierDump_Run. E' il dump piu' pesante del repo -- 1561 KB di
-- SavedVariables -- che prima si leggevano al login, stavano in memoria per tutta la
-- sessione e si riscrivevano a ogni /reload anche senza aver ricalcolato niente.
--
-- Uso: /wmtier per calcolare il dump, poi /reload (o logout) per scriverlo in
-- WTF/Account/<ACC>/SavedVariables/WowManagerTierDump.lua
-- Il calcolo lo fa SOLO il comando: niente inneschi automatici, vedi in fondo.
-- /wmtier diag aggiunge i campi diagnostici (`sets` compreso) -- vedi in fondo.
-- ⚠️ Il calcolo avviene sempre a CLIENT VIVO, mai in chiusura: la scrittura si
-- limita a versare su disco quel che sta in memoria. La data del file quindi non
-- dice se il dump e' fresco -- lo dice il campo `generated`, che e' cio' che
-- guarda transmog-sync.ps1.
-- Da li' copia DUE campi dentro il manifest:
-- ["collectedJson"] -> blocco `collected` (pezzi posseduti / totali)
-- ["piecesJson"] -> blocco `pieceList` (tutti i pezzi, presi e mancanti)
local CLASSES = {
[1] = "warrior", [2] = "paladin", [3] = "hunter", [4] = "rogue", [5] = "priest",
[6] = "death-knight", [7] = "shaman", [8] = "mage", [9] = "warlock", [10] = "monk",
[11] = "druid", [12] = "demon-hunter", [13] = "evoker",
}
local CLASS_ORDER = { "warrior", "paladin", "hunter", "rogue", "priest", "death-knight",
"shaman", "mage", "warlock", "monk", "druid", "demon-hunter", "evoker" }
-- expansionID .. "|" .. label (il raid, come lo chiama il gioco) -> chiave tier del manifest
local TIER = {
["0|Molten Core"] = "t1", ["0|Blackwing Lair"] = "t2",
["0|Temple of Ahn'Qiraj"] = "t25", ["0|Naxxramas"] = "t3",
["1|Gruul's Lair"] = "t4", ["1|Serpentshrine Cavern"] = "t5",
["1|Black Temple"] = "t6", ["1|Sunwell Plateau"] = "t6-swp",
["2|Naxxramas"] = "t7", ["2|Ulduar"] = "t8",
["2|Trial of the Crusader"] = "t9", ["2|Icecrown Citadel"] = "t10",
["3|The Bastion of Twilight"] = "t11", ["3|Firelands"] = "t12",
["3|Dragon Soul"] = "t13", ["4|Heart of Fear"] = "t14",
["4|Throne of Thunder"] = "t15", ["4|Siege of Orgrimmar"] = "t16",
["5|Blackrock Foundry"] = "t17", ["5|Hellfire Citadel"] = "t18",
["6|The Nighthold"] = "t19", ["6|Tomb of Sargeras"] = "t20",
["6|Antorus, the Burning Throne"] = "t21",
["8|Sepulcher of the First Ones"] = "t28",
["9|Vault of the Incarnates"] = "t29", ["9|Aberrus, the Shadowed Crucible"] = "t30",
["9|Amirdrassil, the Dream's Hope"] = "t31",
["10|Nerub-ar Palace"] = "t32", ["10|Liberation of Undermine"] = "t33",
["10|Manaforge Omega"] = "t34", ["11|The Voidspire"] = "t35",
-- Righe senza asse di difficolta': un set solo per classe.
["6|Legion Order Hall"] = "ohall",
}
-- Set per TIPO DI ARMATURA: uno solo per stoffa/cuoio/maglia/piastre, condiviso da
-- tutte le classi che portano quel tipo. Si riconoscono dal classMask con piu' bit
-- accesi -- 400 stoffa, 3592 cuoio, 4164 maglia, 35 piastre -- e vanno su una riga
-- loro, distinta da quella dei set di classe dello stesso raid.
--
-- ⚠️ Per questo la mappa e' separata da TIER invece di stare li' dentro: Hellfire
-- Citadel compare in tutte e due. Le difficolta' normali danno il tier di classe
-- (t18), il solo Raid Finder da' il set condiviso (hfc-lfr), perche' in WoD in LFR
-- il tier di classe non esisteva. Con una mappa sola le quattro varianti LFR
-- finivano su t18 e venivano buttate come "multiclasse".
local TIER_ARMOR = {
["5|Hellfire Citadel"] = "hfc-lfr", ["6|Trial of Valor"] = "tov",
["7|Uldir"] = "uldir", ["7|Battle of Dazar'alor"] = "bod",
["7|The Eternal Palace"] = "tep", ["7|Ny'alotha, the Waking City"] = "nya",
["8|Castle Nathria"] = "nathria", ["8|Sanctum of Domination"] = "sod",
}
-- Ordine dei tier nel manifest (serve solo a rendere l'output ordinato).
local TIER_ORDER = { "t0", "t05", "t1", "t2", "t25", "t3", "t4", "t5", "t6", "t6-swp", "t7", "t8",
"t9", "t10", "t11", "t12", "t13", "t14", "t15", "t16", "t17", "t18",
"hfc-lfr", "ohall", "tov", "t19", "t20", "t21", "uldir", "bod", "tep", "nya", "nathria",
"sod", "t28", "t29", "t30", "t31", "t32", "t33", "t34", "t35" }
-- Righe senza asse: il gioco non da' `description` (l'Order Hall e' uno solo per
-- classe), quindi lo slot non si puo'
-- dedurre da li' e si dichiara qui. Senza questo finivano in dropped come
-- "slot ignoto nil" e la riga restava vuota.
local SLOT_UNICO = { ohall = "normal", ["t6-swp"] = "normal" }
-- Le classi accese in un classMask. Per un set di classe e' una sola; per un set per
-- tipo di armatura sono tutte quelle che lo portano, ed e' li' che serve: lo stesso
-- set va scritto sotto ognuna, altrimenti la riga resta vuota per tutti.
local function ClassiIn(mask)
local out = {}
for i = 1, 13 do
if bit.band(mask or 0, bit.lshift(1, i - 1)) ~= 0 then out[#out + 1] = CLASSES[i] end
end
return out
end
-- description in-game -> slot colonna. Le 4 colonne sono SLOT, non difficolta'
-- letterali: prima di Cataclysm l'asse era 10/25 uomini o la fazione.
local SLOT = {
["Raid Finder"] = "lfr", ["Normal"] = "normal", ["Heroic"] = "heroic", ["Mythic"] = "mythic",
["10 Player (Normal)"] = "normal", ["25 Player (Normal)"] = "heroic",
["25 Player (Heroic)"] = "mythic", ["Sunwell"] = "heroic",
}
local FACTION_SLOT = { Alliance = "normal", Horde = "heroic" } -- T9: l'asse e' la fazione
local IGNORE = { Timerunning = true, Timewarped = true, ["Trading Post"] = true }
local SLOT_ORDER = { "lfr", "normal", "heroic", "mythic" }
-- Restituisce anche la lista delle apparenze primarie, non solo la frazione: e' la
-- stessa che serve subito dopo a PiecesIn, e richiederla due volte al gioco per lo
-- stesso set e' una chiamata (e una tabella) buttata per ognuno dei set tenuti.
local function CountSet(setID)
local have, total = 0, 0
local apps = C_TransmogSets.GetSetPrimaryAppearances(setID)
if apps then
for _, a in ipairs(apps) do
total = total + 1
if a.collected then have = have + 1 end
end
end
return have, total, apps
end
-- ⚠️ Memo di GetSourceInfo, azzerato a ogni dump. Non e' micro-ottimizzazione: dentro
-- PiecesIn ogni source del set viene interrogata una volta per costruire il gruppo
-- per visualID, e POI di nuovo per ogni apparenza primaria -- che di quelle source e'
-- un sottoinsieme. Erano ~11400 chiamate ripetute, una per pezzo dichiarato.
-- Sicuro perche' dentro un dump la collezione non cambia (e comunque lo stato "preso"
-- non viene da qui, ma da `a.collected` delle primarie).
local infoMemo = {}
local function SourceInfo(sid)
if not sid then return nil end
local v = infoMemo[sid]
if v == nil then
v = C_TransmogCollection.GetSourceInfo(sid) or false
infoMemo[sid] = v
end
return v or nil
end
-- invType -> nome dello slot, per l'elenco dei pezzi mancanti.
-- GetSourceInfo restituisce `invType` NUMERICO, mai la stringa INVTYPE_*.
-- ATTENZIONE alla numerazione: l'invType di GetSourceInfo e' spostato di +1 rispetto
-- alla numerazione classica (Testa = 2, non 1). Verificato sui dati reali: il T9
-- warrior usa {2,4,6,7,8,9,10,11}, che con questa mappa sono esattamente gli 8 slot
-- del tier; con la numerazione classica uscivano "Anello", "Collo", "Maglietta".
-- Nomi in INGLESE come li scrive il client, stessa convenzione delle professioni.
local SLOT_NAME = {
[2] = "Head", [3] = "Neck", [4] = "Shoulder", [5] = "Shirt", [6] = "Chest",
[7] = "Waist", [8] = "Legs", [9] = "Feet", [10] = "Wrist", [11] = "Hands",
[12] = "Finger", [13] = "Trinket", [17] = "Back", [20] = "Tabard", [21] = "Chest",
-- Alcuni set (soprattutto Legion in poi) includono armi.
[14] = "Weapon", [15] = "Shield", [16] = "Ranged", [18] = "Two-Hand",
[19] = "Bag", [22] = "Main Hand", [23] = "Off Hand", [24] = "Held In Off-hand",
[25] = "Ammo", [26] = "Thrown", [27] = "Ranged", [28] = "Quiver", [29] = "Relic",
}
-- Dal T28 il pezzo di classe non cade: dal boss cade un TOKEN che lo crea. Le API di
-- transmog non conoscono quel legame, e il fallback per visualID (vedi PiecesIn)
-- risponde col boss di un'altra source della stessa famiglia visuale: plausibile e
-- sbagliato. Misurato su Manaforge Omega, sulle 84 voci che un boss ce l'avevano: 64
-- indicavano un altro boss dello stesso raid, 4 un world boss di un'altra zona
-- (Reshanor), e nessuna il boss che droppa davvero quel token. Le 16 buone
-- nominavano l'ultimo boss, ma per caso: droppa un omni-token valido per ogni slot.
--
-- Quindi per i 5 slot da token di questi tier il boss NON si chiede al gioco, si
-- legge da qui. La struttura reale e' semplice: un boss droppa il token di UNO slot
-- per tutte le classi -- i quattro tipi (Dreadful/Mystic/Venerated/Zenith) cadono
-- dallo stesso boss -- quindi bastano cinque righe per raid.
--
-- ⚠️ Gli altri slot del set (Back, Feet, Waist, Wrist) NON sono token: quelli cadono
-- davvero dal boss, il gioco li sa e vanno lasciati stare. Per questo l'override e'
-- limitato ai cinque slot elencati e non all'intero set.
--
-- Non e' registrato l'omni-token dell'ultimo boss (Sarkareth, Fyrakk, Gallywix,
-- Ansurek, Dimensius, Midnight Falls...), che vale per qualunque slot: la voce
-- mostra un boss solo, e quello specifico dello slot e' la risposta utile a chi
-- deve farmare. Nemmeno i primi boss di ogni raid, che token non ne droppano.
--
-- Fonti: due indipendenti e concordi per raid (icy-veins.com, warcraft.wiki.gg,
-- method.gg, maxroll.gg); per il T29 verificato a livello di item sui token
-- "Forgestone", uno per slot, con tutti e quattro i tipi nella loot table di ogni
-- boss. I nomi sono nella grafia dell'Encounter Journal: ognuno e' stato ricontrollato
-- contro i nomi che il client stesso ha gia' scritto nel dump per gli altri slot.
local TOKEN_BOSS = {
-- WotLK e Cataclysm: qui il set si COMPRAVA (Emblemi, poi Valor Point), quindi per
-- molti slot un boss non esiste proprio e la voce giusta e' vuota: vedi
-- TOKEN_SENZA_FONTE. Il T9 e il T10 non compaiono affatto qui sotto -- erano
-- interamente da vendor e restano senza boss su tutti e cinque gli slot.
--
-- Guanti e gambali di T9/T10/T11/T12 cadono dai raid PvP da un boss solo (Koralon
-- e Toravon a Vault of Archavon, Argaloth e Occu'thar a Baradin Hold). Sono
-- boss "generici" -- droppano i pezzi di tutte le classi -- ma il journal li
-- conosce e la regola qui e' stare aderenti al journal, quindi si scrivono. Nei
-- dati toccano solo Hands e Legs: se compaiono su altri slot, quello e' un errore.
--
t9 = { raid = "Vault of Archavon",
Hands = "Koralon the Flame Watcher", Legs = "Koralon the Flame Watcher" },
t10 = { raid = "Vault of Archavon",
Hands = "Toravon the Ice Watcher", Legs = "Toravon the Ice Watcher" },
-- T11 e T12 sono ibridi: elmo e spalle avevano il token da boss in entrambe le
-- difficolta', il resto si comprava in normal e diventava token solo in heroic.
t11 = { raid = "Blackwing Descent",
Head = "Nefarian's End",
Shoulder = { "Cho'gall", "The Bastion of Twilight" },
Chest = { heroic = { "Halfus Wyrmbreaker", "The Bastion of Twilight" } },
Hands = { normal = { "Argaloth", "Baradin Hold" }, heroic = "Magmaw" },
Legs = { normal = { "Argaloth", "Baradin Hold" }, heroic = "Maloriak" } },
t12 = { raid = "Firelands",
Head = "Ragnaros", Shoulder = "Majordomo Staghelm",
Chest = { heroic = "Alysrazor" },
Hands = { normal = { "Occu'thar", "Baradin Hold" }, heroic = "Baleroc, the Gatekeeper" },
Legs = { normal = { "Occu'thar", "Baradin Hold" }, heroic = "Shannox" } },
-- Pandaria: stessa struttura dei moderni, un boss per slot valido per tutti e tre i
-- gruppi (Conqueror/Protector/Vanquisher). Verificato aprendo le pagine dei singoli
-- token: le tre varianti dello stesso slot nominano lo stesso boss.
t14 = { raid = "Heart of Fear",
Head = { "Sha of Fear", "Terrace of Endless Spring" },
Shoulder = { "Lei Shi", "Terrace of Endless Spring" },
Chest = "Grand Empress Shek'zeer", Hands = "Wind Lord Mel'jarak",
Legs = "Amber-Shaper Un'sok" },
-- ⚠️ Head: il client chiama quell'incontro "Twin Empyreans", le guide "Twin
-- Consorts". Vale la grafia del client, che e' quella usata ovunque nel manifest.
t15 = { raid = "Throne of Thunder",
Head = "Twin Empyreans", Shoulder = "Iron Qon", Chest = "Dark Animus",
Hands = "Council of Elders", Legs = "Ji-Kun" },
t16 = { raid = "Siege of Orgrimmar",
Head = "Thok the Bloodthirsty", Shoulder = "Siegecrafter Blackfuse",
Chest = "Sha of Pride", Hands = "General Nazgrim",
Legs = "Paragons of the Klaxxi" },
t28 = { raid = "Sepulcher of the First Ones",
Head = "Anduin Wrynn", Shoulder = "Lords of Dread", Chest = "Rygelon",
Hands = "Lihuvim, Principal Architect", Legs = "Halondrus the Reclaimer" },
t29 = { raid = "Vault of the Incarnates",
Head = "Raszageth the Storm-Eater", Shoulder = "Broodkeeper Diurna",
Chest = "Kurog Grimtotem", Hands = "Dathea, Ascended",
Legs = "Sennarth, the Cold Breath" },
t30 = { raid = "Aberrus, the Shadowed Crucible",
Head = "Magmorax", Shoulder = "Echo of Neltharion",
Chest = "The Vigilant Steward, Zskarn", Hands = "The Forgotten Experiments",
Legs = "Rashok, the Elder" },
t31 = { raid = "Amirdrassil, the Dream's Hope",
Head = "Tindral Sageswift, Seer of the Flame", Shoulder = "Smolderon",
Chest = "Nymue, Weaver of the Cycle", Hands = "Igira the Cruel",
Legs = "Larodar, Keeper of the Flame" },
t32 = { raid = "Nerub-ar Palace",
Head = "The Silken Court", Shoulder = "Rasha'nan",
Chest = "Broodtwister Ovi'nax", Hands = "Sikran, Captain of the Sureki",
Legs = "Nexus-Princess Ky'veza" },
t33 = { raid = "Liberation of Undermine",
Head = "The One-Armed Bandit", Shoulder = "Rik Reverb",
Chest = "Sprocketmonger Lockenstock", Hands = "Cauldron of Carnage",
Legs = "Stix Bunkjunker" },
t34 = { raid = "Manaforge Omega",
Head = "Forgeweaver Araz", Shoulder = "The Soul Hunters", Chest = "Fractillus",
Hands = "Soulbinder Naazindhri", Legs = "Loom'ithar" },
-- Il T35 e' l'unico sparso su piu' raid: il Chest viene dal Dreamrift, non dal
-- Voidspire. Quando il raid non e' quello di `raid`, si scrive accanto al boss.
t35 = { raid = "The Voidspire",
Head = "Lightblinded Vanguard", Shoulder = "Fallen-King Salhadaar",
Chest = { "Chimaerus the Undreamt God", "The Dreamrift" },
Hands = "Vorasius", Legs = "Vaelgor & Ezzorak" },
}
-- Boss e raid del token di uno slot, se quel tier ne ha. Nil = il gioco resta
-- l'autorita'. Il valore di uno slot puo' avere tre forme, in ordine di complessita':
-- "Boss" -- boss nel raid di default del tier
-- { "Boss", "Raid" } -- boss in un altro raid (T35: il Chest sta nel Dreamrift)
-- { normal = <una delle due>, heroic = ... } -- cambia con la versione
-- L'ultima serve dal T12, dove il set normal si comprava coi Valor Point e solo
-- l'heroic aveva i token dai boss: stesso slot, provenienza diversa per difficolta'.
local function TokenBossFor(tier, slot, versione)
local t = tier and TOKEN_BOSS[tier]
local v = t and t[slot]
-- Mappa per versione: si riconosce perche' NON e' la coppia {boss, raid}, che
-- ha una stringa in posizione 1.
if type(v) == "table" and type(v[1]) ~= "string" then v = versione and v[versione] end
if not v then return nil end
if type(v) == "table" then return v[1], v[2] end
return v, t.raid
end
-- I 5 slot che nei tier a token non cadono dal boss ma nascono dalla conversione:
-- sono i soli su cui il fallback per visualID sbaglia, e i soli che TOKEN_BOSS e
-- TOKEN_SENZA_FONTE possono toccare.
local SLOT_TOKEN = { Head = true, Shoulder = true, Chest = true, Hands = true, Legs = true }
-- Tier dove sui 5 slot da token il gioco risponde con un boss inaffidabile e la
-- ricerca NON ha trovato quello vero (o non esiste: set da vendor, token generico
-- non legato allo slot). Li' si lascia il solo slot.
--
-- Come si riconoscono: dentro una stessa cella (tier, slot, difficolta') le classi
-- devono convergere su uno stesso boss -- al massimo tre, uno per gruppo di token.
-- Dove invece se ne contano 5-13, tutti dentro il raid del tier, e' la firma del
-- fallback per visualID, la stessa misurata su Manaforge Omega.
--
-- ⚠️ NON usare come criterio "un boss che compare su molti slot": e' sbagliato e
-- cancellerebbe i dati buoni. Un boss di raid droppa legittimamente piu' pezzi
-- dello stesso set -- Forgeweaver Araz da' l'elmo come token e anche cintura,
-- stivali e polsi come loot normale, e infatti risulta su 3 slot pur essendo
-- corretto. Misurato: anche i tier sani (T17-T21) hanno 2-4 slot per boss.
-- Il valore e' `true` (tutti e 5 gli slot) oppure l'elenco dei soli slot da
-- sopprimere: serve la granularita' per slot perche' un tier puo' essere misto. Il
-- T9 e' il caso tipico -- set da vendor, quindi Head/Shoulder/Chest non li droppa
-- nessuno, ma Hands e Legs cadono davvero da Koralon a Vault of Archavon e li' il
-- gioco ha ragione (20 voci su 20 corrette).
local TOKEN_SENZA_FONTE = {
-- ⚠️ "Vendor" da solo non basta a decidere: ci sono TRE casi diversi, e solo due
-- vanno soppressi. Non collassarli, e' l'errore facile da fare qui.
-- 1. Token dal boss, convertito dal vendor -- tutta Pandaria (T14-T16): il
-- vendor e' solo un banco di scambio, la fonte resta il boss. Il boss SI
-- TIENE, infatti quei tier stanno in TOKEN_BOSS.
-- 2. Acquisto puro con valuta, nessun boss coinvolto -- T9/T10 base, e petto,
-- guanti, gambali del T11/T12 in normal.
-- 3. Token dal boss ma GENERICO: cade davvero (Trophy of the Crusade da ogni
-- boss del Trial, Mark of Sanctification da cinque boss di ICC) pero' vale
-- per uno slot QUALSIASI. E' il caso che sembra un errore e non lo e': un
-- boss c'e', ma non si puo' dire quale slot dia, quindi la mappa boss->slot
-- non esiste lo stesso.
-- T9 e T10 stanno nel 2 e nel 3, mai nell'1: elmo, spalle e petto non li droppa
-- nessuno. Guanti e gambali invece cadono a Vault of Archavon e stanno in
-- TOKEN_BOSS: il journal li conosce, quindi si riportano.
t9 = { Head = true, Shoulder = true, Chest = true },
t10 = { Head = true, Shoulder = true, Chest = true },
-- T11 e T12: in normal il petto si comprava coi Valor Point e basta. Guanti e
-- gambali no, quelli cadevano a Baradin Hold.
t11 = { Chest = { normal = true } },
t12 = { Chest = { normal = true } },
}
-- Il boss di questo slot va soppresso? Vale solo sui 5 slot da token. Come sopra, la
-- soppressione puo' dipendere dalla versione: `true` (sempre) o l'elenco delle sole
-- versioni in cui il pezzo non aveva una provenienza da boss.
local function BossInaffidabile(tier, slot, versione)
local v = tier and TOKEN_SENZA_FONTE[tier]
if not v or not SLOT_TOKEN[slot] then return false end
if v == true then return true end
local s = v[slot]
if s == nil then return false end
return s == true or (versione ~= nil and s[versione] == true)
end
-- Diagnostica: quale API risponde e con che forma. Finisce nel DB.
local probe = {}
-- Statistiche del recupero boss (finiscono nel DB, servono a validare il dump).
local stats = { viaVariante = 0, daToken = 0, soppressi = 0 }
-- ⚠️ Strada dell'Encounter Journal: PROVATA E SCARTATA, non riproporla.
-- L'idea era leggere dal journal il boss che droppa il token, visto che dal T28 e' il
-- token a cadere e non il pezzo di classe.
--
-- Misurata due volte, la seconda con una sonda che ha spazzato TUTTO il journal (73
-- raid, 493 boss, ogni difficolta', 80.899 indici di loot) contro i 1315 itemID che
-- il dump lascia senza boss: 20 agganci, l'1,5%. E nessuno dei venti viene dal raid
-- del proprio tier -- sono tutti sorgenti alternative (Vault of Archavon per il T10,
-- Baradin Hold per l'T11, Sha of Anger e Chi-Ji per T14/T16, M'uru a Sunwell per il
-- T6), cioe' i casi "luogo fuori dal raid del tier". Valgono 20 voci su 2975: 0,7%.
--
-- Il motivo e' strutturale, non un difetto di come si interroga: nelle liste di loot
-- del journal c'e' il TOKEN, il pezzo di classe non compare proprio. Tre scuse
-- plausibili gia' escluse, per non rifarle:
-- * NON e' il load-on-demand: Blizzard_EncounterJournal si carica senza problemi e
-- le EJ_* rispondono (raidSenzaLoot e' uscito vuoto, tutti i raid espongono loot);
-- * NON e' il filtro: EJ_SetLootFilter(classID, spec) e' un no-op in 12.0.7, con e
-- senza filtro escono le stesse voci nello stesso ordine;
-- * NON sono itemID falsi: GetLootInfoByIndex restituisce itemID reali e usabili
-- (una nota precedente diceva il contrario, era sbagliata). Espone solo itemID,
-- encounterID e i flag displayAs*: name e slot mancano perche' arrivano a item
-- caricato, ma per una mappa itemID -> boss non servono.
-- Se ci si torna, serve un'API nuova che leghi pezzo e token, non un giro diverso su
-- queste. Nota di metodo: enumerare i boss e chiamare EJ_SetDifficulty nello stesso
-- giro non funziona -- una difficolta' inesistente per quel raid fa perdere al
-- journal il contesto dell'istanza e l'enumerazione muore al primo raid (84 boss
-- invece di 493). Vanno tenuti in due passate separate.
--
-- Il boss si recupera invece dalle altre source della stessa apparenza, vedi PiecesIn.
-- Le API di transmog cambiano nome fra le espansioni (GetSetSources e' sparita in
-- 12.0.x): elencare cosa esiste davvero evita di indovinare al giro dopo.
local function ApiNames()
local out = {}
for _, ns in ipairs({ "C_TransmogSets", "C_TransmogCollection" }) do
local tbl = _G[ns]
if type(tbl) == "table" then
for k, v in pairs(tbl) do
if type(v) == "function" then out[#out + 1] = ns .. "." .. k end
end
end
end
table.sort(out)
return out
end
-- Boss che droppa una source, se il gioco lo sa. Il nome dell'API e' cambiato negli
-- anni: proviamo i candidati noti, sotto pcall, e ricordiamo quale ha funzionato.
local DROP_FNS = {
{ "C_TransmogCollection.GetAppearanceSourceDrops", function(id) return C_TransmogCollection.GetAppearanceSourceDrops(id) end },
{ "C_TransmogCollection.GetAppearanceSourceInfo", function(id)
-- Fallback: da qui si ricava almeno il nome dell'oggetto, non il boss.
local _, _, _, _, _, itemLink = C_TransmogCollection.GetAppearanceSourceInfo(id)
return itemLink and { { encounter = nil } } or nil
end },
}
local function BossFor(sourceID)
for _, cand in ipairs(DROP_FNS) do
local name, fn = cand[1], cand[2]
local ok, res = pcall(fn, sourceID)
if not ok then
probe[name] = probe[name] or ("errore: " .. tostring(res))
elseif type(res) == "table" and res[1] then
probe[name] = probe[name] or ("ok, campi: " .. table.concat((function()
local ks = {}
for k, v in pairs(res[1]) do ks[#ks + 1] = k .. "=" .. tostring(v) end
table.sort(ks)
return ks
end)(), ", "))
-- Il primo drop basta: le voci extra sono le altre difficolta' dello stesso boss.
if res[1].encounter then return res[1].encounter, res[1].instance end
else
probe[name] = probe[name] or ("vuoto/" .. type(res))
end
end
return nil
end
-- Slot non ancora mappati, per correggere SLOT_NAME senza tirare a indovinare.
local unmapped = {}
-- Set dove l'elenco dei mancanti non combacia con la frazione posseduti/totale.
local mismatches = {}
-- Pezzi NON collezionati di un set, con il boss che li droppa quando il gioco lo sa.
-- Restituisce una lista di stringhe tipo "Shoulder (Ragnaros, Firelands)".
--
-- Si itera GetSetPrimaryAppearances, la stessa lista che CountSet usa per la
-- frazione: cosi' i mancanti sono per costruzione esattamente `total - have`.
--
-- ⚠️ Il campo `appearanceID` di quelle entry contiene in realta' una **sourceID**
-- (verificato: gli id delle primarie stanno nell'intervallo delle sourceID, non dei
-- visualID), quindi si passa dritti a GetSourceInfo/GetAppearanceSourceDrops senza
-- alcun join. Le vie scartate: GetSetSources non esiste piu' in 12.0.x,
-- GetAllAppearanceSources restituiva oggetti di altri set, e GetAllSourceIDs include
-- le varianti di difficolta' (42 voci per un set da 8).
--
-- `have`/`total` arrivano da CountSet: verifica che l'elenco combaci con la frazione
-- mostrata nella cella, altrimenti il tooltip contraddirebbe il numero.
local function PiecesIn(setID, have, total, tier, versione, apps)
local out = {}
-- Un'apparenza ha piu' source (le varie difficolta', e dal T28 anche il Catalyst).
-- Su quella primaria GetAppearanceSourceDrops tace, ma la variante che cade in raid
-- il boss ce l'ha: si raggruppano le source per visual per poterle provare tutte.
local byVisual = {}
for _, sid in ipairs(C_TransmogSets.GetAllSourceIDs(setID) or {}) do
local si = SourceInfo(sid)
if si and si.visualID then
byVisual[si.visualID] = byVisual[si.visualID] or {}
table.insert(byVisual[si.visualID], sid)
end
end
-- `apps` arriva da CountSet, che la lista l'ha gia' chiesta. Il ripiego resta per
-- chi chiamasse PiecesIn senza passarla.
for _, a in ipairs(apps or C_TransmogSets.GetSetPrimaryAppearances(setID) or {}) do
local sourceID = a.appearanceID
local info = SourceInfo(sourceID)
local invType = info and info.invType
local slot = SLOT_NAME[invType]
if not slot then
unmapped[tostring(invType)] = (info and info.itemID) or "?"
slot = "Slot " .. tostring(invType)
end
-- Slot da token di un tier moderno: la risposta la da' TOKEN_BOSS, non il
-- gioco. Va PRIMA di BossFor, non dopo come fallback: il giro per visualID
-- una risposta la trova quasi sempre, solo che e' quella sbagliata.
local boss, instance = TokenBossFor(tier, slot, versione)
if boss then stats.daToken = stats.daToken + 1 end
-- Niente fonte attendibile per questo slot: si sopprime invece di chiedere al
-- gioco, che risponderebbe col boss sbagliato. Resta il solo slot.
--
-- ⚠️ `sopprimi` deve spegnere ENTRAMBE le vie, non solo la prima: la seconda
-- e' il giro per visualID poco sotto, che una risposta la trova quasi sempre.
-- Proteggendo solo BossFor la voce veniva soppressa e subito ripescata li',
-- e il contatore diceva 310 soppressioni mentre nei dati non cambiava nulla.
local sopprimi = not boss and BossInaffidabile(tier, slot, versione)
if sopprimi then
stats.soppressi = stats.soppressi + 1
elseif not boss and sourceID then
boss, instance = BossFor(sourceID)
end
-- Nessun drop sulla primaria: si provano le altre source dello stesso
-- visual (stesso aspetto, difficolta' diversa), dove il boss c'e'.
if not sopprimi and not boss and info and info.visualID then
for _, alt in ipairs(byVisual[info.visualID] or {}) do
if alt ~= sourceID then
boss, instance = BossFor(alt)
if boss then
stats.viaVariante = stats.viaVariante + 1
break
end
end
end
end
-- Formato: "Shoulder (Ragnaros, Firelands)". Il raid serve al sito per
-- mostrarne la sigla; il taglio fra boss e raid NON e' sulla virgola,
-- perche' ne contengono entrambi: ci pensa formatMissing lato sito.
local testo
if boss and instance then
testo = ("%s (%s, %s)"):format(slot, boss, instance)
elseif boss then
testo = ("%s (%s)"):format(slot, boss)
else
testo = slot
end
-- Si emettono TUTTI i pezzi, non solo i mancanti: il tooltip li mostra tutti
-- e colora di verde i presi. `preso` e' 1/0 perche' nel JSON pesa meno di
-- true/false ed e' sommabile per la verifica contro la frazione.
-- L'itemID viaggia accanto: serve a cercare altrove i drop che il client non
-- conosce. Restano appaiati perche' l'ordinamento scombinerebbe liste parallele.
out[#out + 1] = {
testo = testo,
preso = a.collected and 1 or 0,
itemID = (not boss) and info and info.itemID or nil,
}
end
-- Prima i mancanti, poi i presi; dentro ogni gruppo in ordine alfabetico: cosi'
-- il tooltip apre con quello che resta da prendere.
table.sort(out, function(a, b)
if a.preso ~= b.preso then return a.preso < b.preso end
return a.testo < b.testo
end)
-- Discrepanza = elenco non fidato: meglio saperlo che pubblicare numeri diversi
-- fra cella e tooltip.
local presi = 0
for _, v in ipairs(out) do presi = presi + v.preso end
if total and (#out ~= total or presi ~= have) then
mismatches[#mismatches + 1] = ("set %d: elenco %d/%d presi, attesi %d/%d")
:format(setID, presi, #out, have, total)
end
return out
end
local function Dump(diag)
local t0 = debugprofilestop()
local raw, data, dropped, pieces, errors = {}, {}, {}, {}, {}
-- Set in cui NESSUN pezzo ottiene un boss. Una riga di raid a copertura zero e'
-- anomala: Trial of Valor ha 468 voci e nemmeno un boss, mentre Tomb of Sargeras
-- sta al 95% e Uldir al 100%. Qui si registra quali set sono, e per il primo di
-- ognuno cosa rispondono davvero le API sulle sue source.
local senzaBoss, sondaSenzaBoss = {}, {}
local seen = {}
local letti = 0
-- Dump() gira piu' volte per sessione: senza azzerare, i contatori si sommano fra
-- una passata e l'altra e sembrano il triplo. Il memo delle source va azzerato per
-- lo stesso motivo -- e perche' fra un dump e l'altro la collezione e' cambiata.
stats.viaVariante, stats.daToken, stats.soppressi = 0, 0, 0
infoMemo = {}
local function consider(info)
if not info or seen[info.setID] then return end
seen[info.setID] = true
letti = letti + 1
-- ⚠️ IL CONTEGGIO SI CHIEDE DOPO AVER DECISO CHE IL SET CI INTERESSA. Il
-- journal ne elenca 3483 (misurato) e le righe del manifest ne usano una
-- frazione: contarli tutti in testa voleva dire scorrere 27201 apparenze per
-- poi buttarne la maggior parte due righe piu' sotto, al `if not tier`.
-- In `diag` invece si conta prima, come faceva sempre: il blocco `sets` serve
-- a confrontare i NOMI col client, e deve restare identico a com'era.
local have, total, apps
if diag then
have, total, apps = CountSet(info.setID)
raw[#raw + 1] = {
setID = info.setID, name = info.name, label = info.label,
description = info.description, classMask = info.classMask,
requiredFaction = info.requiredFaction, expansionID = info.expansionID,
patchID = info.patchID, uiOrder = info.uiOrder,
collected = have, total = total,
}
end
local chiave = (info.expansionID or -1) .. "|" .. (info.label or "")
local mask = info.classMask or 0
-- Piu' bit accesi = set per tipo di armatura, che ha una riga sua. Non e' piu'
-- motivo di scarto: prima finiva in dropped come "multiclasse" e le righe di
-- BfA, Nathria, Sanctum e l'LFR di WoD restavano vuote per tutti.
local condiviso = bit.band(mask, mask - 1) ~= 0
local tier = condiviso and TIER_ARMOR[chiave] or TIER[chiave]
if not tier then return end
if IGNORE[info.description] then
dropped[#dropped + 1] = tier .. ": " .. tostring(info.description)
return
end
local classi = ClassiIn(mask)
if #classi == 0 then
dropped[#dropped + 1] = tier .. ": classMask vuoto"
return
end
local slot = SLOT_UNICO[tier]
or ((tier == "t9") and FACTION_SLOT[info.requiredFaction])
or SLOT[info.description]
if not slot then
dropped[#dropped + 1] = tier .. ": slot ignoto " .. tostring(info.description)
return
end
-- Il set ci interessa: adesso i pezzi si contano (se `diag` non l'ha gia' fatto).
if not have then have, total, apps = CountSet(info.setID) end
-- L'elenco dei pezzi si calcola UNA volta: e' lo stesso set, e per un set
-- condiviso rifarlo per ognuna delle classi sarebbe solo lavoro ripetuto.
-- Sotto pcall: un errore qui deve degradare il solo elenco dei pezzi,
-- non far saltare tutto il dump (e con esso `collected`).
local ok, list = pcall(PiecesIn, info.setID, have, total, tier, slot, apps)
if not ok then
errors[#errors + 1] = tier .. "/" .. classi[1] .. ": " .. tostring(list)
list = nil
end
-- Nessun pezzo con un boss: si annota il set, e sul PRIMO che capita si
-- guarda source per source cosa risponde l'API. Serve a distinguere "il
-- gioco non lo sa" da "lo chiediamo male".
-- ⚠️ Domanda da diagnostica, non da sync: la risposta e' gia' scritta in
-- CLAUDE.md (Trial of Valor e' a zero perche' quei set non cadono dai boss).
-- Rifarla a ogni dump costava un giro su tutte le source dei set a copertura
-- zero, GetAppearanceSourceDrops compreso.
if diag and list and #list > 0 then
local conBoss = 0
for _, v in ipairs(list) do
if v.testo:find(" %(") then conBoss = conBoss + 1 end
end
if conBoss == 0 then
senzaBoss[#senzaBoss + 1] = ("%s | setID %d | %s | %s | %s"):format(
tier, info.setID, tostring(info.name), tostring(info.label),
tostring(info.description))
if #sondaSenzaBoss == 0 then
for _, sid in ipairs(C_TransmogSets.GetAllSourceIDs(info.setID) or {}) do
local si = SourceInfo(sid)
local okd, drops = pcall(C_TransmogCollection.GetAppearanceSourceDrops, sid)
sondaSenzaBoss[#sondaSenzaBoss + 1] = ("src %d | item %s | visual %s | drops %s"):format(
sid, tostring(si and si.itemID), tostring(si and si.visualID),
okd and (type(drops) == "table" and #drops or tostring(drops)) or "errore")
end
end
end
end
for _, class in ipairs(classi) do
data[class] = data[class] or {}
data[class][tier] = data[class][tier] or {}
data[class][tier][slot] = { have, total }
-- Anche i set completi: il tooltip li mostra tutti verdi, non vuoti.
if list then
pieces[class] = pieces[class] or {}
pieces[class][tier] = pieces[class][tier] or {}
pieces[class][tier][slot] = list
end
end
end
for _, info in ipairs(C_TransmogSets.GetAllSets() or {}) do
consider(info)
for _, v in ipairs(C_TransmogSets.GetVariantSets(info.setID) or {}) do consider(v) end
end
-- Serializza il blocco `collected` con l'indentazione del manifest (2 spazi).
local out = { ' "collected": {' }
for ci, class in ipairs(CLASS_ORDER) do
local tiers = {}
for _, tier in ipairs(TIER_ORDER) do
local slots = data[class] and data[class][tier]
if slots then
local parts = {}
for _, slot in ipairs(SLOT_ORDER) do
local v = slots[slot]
if v then parts[#parts + 1] = ('"%s": [%d, %d]'):format(slot, v[1], v[2]) end
end
tiers[#tiers + 1] = (' "%s": { %s }'):format(tier, table.concat(parts, ", "))
end
end
out[#out + 1] = (' "%s": {\n%s\n }%s'):format(
class, table.concat(tiers, ",\n"), ci < #CLASS_ORDER and "," or "")
end
out[#out + 1] = " }"
-- Blocco `pieceList`: TUTTI i pezzi di ogni set, come coppie [testo, preso], dove
-- preso e' 1 se gia' collezionato. Il sito li mostra tutti, rossi o verdi.
-- Stessa struttura anche per gli itemID (0 dove il boss e' noto o il pezzo e'
-- preso): resta nello stesso ordine perche' nasce dalla stessa lista.
local function esc(s) return (tostring(s):gsub('[\\"]', '\\%0')) end
local function Serializza(chiave, campo)
local mo = { (' "%s": {'):format(chiave) }
local classLines = {}
for _, class in ipairs(CLASS_ORDER) do
if pieces[class] then
local tiers = {}
for _, tier in ipairs(TIER_ORDER) do
local slots = pieces[class][tier]
if slots then
local parts = {}
for _, slot in ipairs(SLOT_ORDER) do
local list = slots[slot]
if list and #list > 0 then
local items = {}
for i, v in ipairs(list) do
items[i] = (campo == "itemID")
and tostring(v.itemID or 0)
or ('["%s", %d]'):format(esc(v.testo), v.preso)
end
parts[#parts + 1] = ('"%s": [%s]'):format(slot, table.concat(items, ", "))
end
end
if #parts > 0 then
tiers[#tiers + 1] = (' "%s": { %s }'):format(tier, table.concat(parts, ", "))
end
end
end
if #tiers > 0 then
classLines[#classLines + 1] = (' "%s": {\n%s\n }'):format(class, table.concat(tiers, ",\n"))
end
end
end
mo[#mo + 1] = table.concat(classLines, ",\n")
mo[#mo + 1] = " }"
return table.concat(mo, "\n")
end
-- ⚠️ GUARDIA: se il client non ha ancora caricato la collezione transmog, ogni
-- set risulta a zero pezzi presi ma con il totale giusto. Il dump resta
-- internamente COERENTE -- mismatches ed errors restano vuoti -- quindi nulla
-- lo distingue da un dump buono, e incollarlo azzera la collezione nel manifest.
-- Successo davvero: 4610 pezzi diventati 0 su tutte e 13 le classi.
-- L'addon parte 5 secondi dopo il login e a volte non basta: qui si conta quanto
-- si e' collezionato e, se e' zero mentre i pezzi esistono, si urla.
local presi, pezzi = 0, 0
for _, tiers in pairs(data) do
for _, slots in pairs(tiers) do
for _, v in pairs(slots) do presi = presi + v[1]; pezzi = pezzi + v[2] end
end
end
-- Lettura a vuoto: NON si sovrascrive. Se c'e' gia' un dump buono lo si lascia
-- dov'e' e si esce -- il SavedVariables conserva quello di prima. E' il caso che
-- capita ricaricando troppo presto dopo il login, quando la collezione non e'
-- ancora arrivata: prima bastava quello per azzerare 4610 pezzi.
if pezzi > 0 and presi == 0 then
local avviso = "collezione non ancora caricata"
if WowManagerTierDumpDB and (WowManagerTierDumpDB.presi or 0) > 0 then
print(("|cffff2020WowManagerTierDump: %s -- tengo il dump di %s (%d pezzi). Rilancia fra un minuto.|r")
:format(avviso, tostring(WowManagerTierDumpDB.generated), WowManagerTierDumpDB.presi))
return
end
-- Nessun dump buono da difendere: si scrive, ma marcato.
print("|cffff2020WowManagerTierDump: " .. avviso .. ". NON incollare.|r")
WowManagerTierDumpDB = {
generated = date("%Y-%m-%d %H:%M:%S"),
sospetto = avviso .. ": tutti i pezzi risultano a zero. NON incollare.",
presi = presi, pezzi = pezzi,
}
return
end
local sospetto = nil
-- I due blocchi che vanno nel manifest, piu' i campi piccoli che dicono se il
-- dump e' da buttare (`sospetto`, `mismatches`, `errors`: li legge il sync) o se
-- una mappa e' rimasta indietro (`dropped`, `unmapped`). Sono quelli che si
-- guardano senza doverli chiedere.
WowManagerTierDumpDB = {
generated = date("%Y-%m-%d %H:%M:%S"),
sospetto = sospetto, -- nil = dump buono. Se valorizzato, NON incollare.
presi = presi, pezzi = pezzi,
build = GetBuildInfo(),
collectedJson = table.concat(out, "\n"),
piecesJson = Serializza("pieceList", "testo"),
dropped = dropped,
errors = errors, -- set il cui elenco pezzi e' fallito (dump comunque valido)
unmapped = unmapped, -- invType senza nome in SLOT_NAME -> un itemID d'esempio
mismatches = mismatches, -- set con elenco incoerente col conteggio
stats = stats, -- boss recuperati dalle varianti della stessa apparenza
}
-- ⚠️ La DIAGNOSTICA si scrive solo con `/wmtier diag`, ed e' la meta' abbondante
-- del file: `sets` da solo pesa 904 KB dei 1561 misurati, missingItemIdsJson altri
-- 72. Sono i campi con cui si risponde a domande che ci si fa una volta -- come si
-- chiama davvero questo set nel client, quali invType non sono mappati, perche'
-- questo raid non ha boss -- non a ogni sincronizzazione della collezione.
if diag then
local d = WowManagerTierDumpDB
d.sets = raw -- dump grezzo: e' qui che si controllano i NOMI contro il client
d.missingItemIdsJson = Serializza("missingItemIds", "itemID") -- lista di lavoro per i drop ignoti
d.probe = probe -- quale API ha risposto, e con che campi
d.api = ApiNames() -- funzioni davvero esposte: serve quando un'API sparisce
d.senzaBoss = senzaBoss -- set in cui nessun pezzo ha un boss
d.sondaSenzaBoss = sondaSenzaBoss -- cosa rispondono le API sul primo di quelli
end
print(("|cff33ff99WowManagerTierDump|r: %d set letti, %d scartati, %d errori in %d ms%s. /reload per scrivere il file.")
:format(letti, #dropped, #errors, math.floor(debugprofilestop() - t0), diag and " (diag)" or ""))
end
-- Il punto d'ingresso per il lanciatore WowManagerDump. "diag" nel comando accende i
-- campi diagnostici.
function WowManagerTierDump_Run(msg)
Dump(type(msg) == "string" and msg:lower():find("diag", 1, true) ~= nil)
end
-- ⚠️ Il comando lo registra il lanciatore: qui si registra SOLO se non c'e', per non
-- avere due handler sulla stessa stringa. Cosi' il modulo caricato a mano resta
-- utilizzabile da solo.
local Caricato = (C_AddOns and C_AddOns.IsAddOnLoaded) or IsAddOnLoaded
if not Caricato("WowManagerDump") then
SLASH_WMTIER1 = "/wmtier"
SlashCmdList["WMTIER"] = WowManagerTierDump_Run
end
-- ⚠️ SI RICALCOLA A CLIENT VIVO, MAI IN CHIUSURA, come il gemello delle mount e per
-- lo stesso motivo: PLAYER_LOGOUT scatta anche all'uscita vera, e li' il client si
-- sta gia' smontando. Qui il danno si vedeva meno perche' in chiusura la collezione
-- si legge VUOTA e la guardia dentro Dump() tiene il dump di meta' sessione -- ma
-- era una combinazione, non una difesa: bastava che si perdesse un campo diverso
-- mentre i pezzi si contavano ancora (e' esattamente cio' che e' successo al dump
-- delle mount) perche' passasse inosservato.
--
-- ⚠️ E NESSUN INNESCO AUTOMATICO, nemmeno sulla collezione che cambia. C'era --
-- TRANSMOG_COLLECTION_UPDATED e _SOURCE_ADDED con 60s di attesa anti-raffica -- ed e'
-- durato meno di un giorno: quegli eventi scattano a OGNI apparenza che entra in
-- collezione, quindi in istanza sono una raffica continua, e il ricalcolo qui gira su
-- 11403 pezzi. L'attesa non basta a renderlo innocuo: rimanda la partenza, non la
-- evita, e il conto lo paghi mentre giochi.
--
-- ⚠️ Non estendere il ragionamento al gemello delle mount senza guardare i numeri:
-- li' l'evento e' NEW_MOUNT_ADDED, che scatta quando impari una cavalcatura, cioe'
-- qualche volta al mese e mai a raffica. Sono due situazioni diverse dietro la stessa
-- parola "evento".
--
-- Resta quindi il solo /wmtier. Il dump non e' un dato che serva minuto per minuto:
-- lo si vuole quando si sincronizza, ed e' esattamente quando il comando si da'.
--
-- ⚠️ Ed e' anche il motivo per cui questo addon e' LoadOnDemand: un attrezzo che si
-- usa solo col comando non ha ragione di stare in memoria nelle sessioni in cui il
-- comando non lo dai. Non e' una scelta indipendente da quella qui sopra -- e' la
-- stessa portata alle conseguenze.-- WowManagerCVarDump -- fotografa TUTTE le impostazioni (CVar) del client.
--
-- In gioco:
-- /wmcvar riscrive il dump e dice quante sono fuori dal default
-- /wmcvar <parola> cerca in chat fra nome, categoria e testo d'aiuto
--
-- ⚠️ Il dump lo calcola SOLO /wmcvar: poi serve un /reload (o l'uscita) perche' WoW
-- scriva il file. Il /wmcvar non e' piu' facoltativo come prima -- non c'e' piu' la
-- rigenerazione al logout, vedi il perche' in fondo -- e per lo stesso motivo la
-- data del FILE non dice se il dump e' fresco: lo dice il campo `data`, ed e' quello
-- che guarda settings-report.ps1. Due reload dopo aver modificato questo file: il
-- primo scrive ancora col codice vecchio.
-- SavedVariables: WTF/Account/<ACC>/SavedVariables/WowManagerCVarDump.lua
--
-- Valore e default arrivano da DUE chiamate a un solo ritorno (GetCVar e
-- GetCVarDefault) invece che dai sette ritorni di GetCVarInfo: cosi' la coppia
-- valore/default non dipende dall'ordine dei ritorni, che e' facile sbagliare.
-- Dall'ordine dipendono i soli flag di scope, che sono informativi.
--
-- Ogni riga e' testo con separatore " ~|~ ". Nei campi le virgolette diventano
-- apostrofi, i ritorni a capo spazi e i backslash barre: cosi' il file NON ha
-- escape da sciogliere a valle (il doppio-escape che gli altri dump del repo si
-- portano dietro). Il prezzo e' che l'aiuto non e' verbatim al carattere.
local SEP = " ~|~ "
local function Pulisci(s)
if s == nil then return "" end
s = tostring(s)
s = s:gsub("[\r\n\t]", " ")
s = s:gsub('"', "'")
s = s:gsub("\\", "/")
s = s:gsub("~|~", "/")
return s
end
-- Un comando della console e' un CVar se GetCVar risponde: non ci si fida di
-- commandType, il cui enum non e' garantito fra le patch.
local function Raccogli()
local righe, tot, diversi = {}, 0, 0
local cmds = ConsoleGetAllCommands and ConsoleGetAllCommands() or {}
for _, c in ipairs(cmds) do
local nome = c.command
if nome and nome ~= "" then
local okv, v = pcall(C_CVar.GetCVar, nome)
if okv and v ~= nil then
tot = tot + 1
local okd, def = pcall(C_CVar.GetCVarDefault, nome)
local d = (okd and def ~= nil) and tostring(def) or ""
local val = tostring(v)
local diverso = (val ~= d) and 1 or 0
if diverso == 1 then diversi = diversi + 1 end
local srvA, srvC, ro = "", "", ""
local oki, _, _, a, ch, _, _, r = pcall(C_CVar.GetCVarInfo, nome)
if oki then
srvA = a and 1 or 0
srvC = ch and 1 or 0
ro = r and 1 or 0
end
righe[#righe + 1] = table.concat({
Pulisci(nome), Pulisci(val), Pulisci(d), diverso,
srvA, srvC, ro, Pulisci(c.category), Pulisci(c.help),
}, SEP)
end
end
end
table.sort(righe)
return righe, tot, diversi
end
local function Scrivi(zitto)
local righe, tot, diversi = Raccogli()
-- Stessa guardia degli altri dump: una lettura a vuoto e' internamente
-- coerente e a valle nessuno la distingue da una buona, quindi non si
-- sovrascrive un dump valido con zero righe.
if tot == 0 then
if type(WowManagerCVarDump) == "table" and (WowManagerCVarDump.totali or 0) > 0 then
print("|cffff5555WowManagerCVarDump: zero CVar letti, dump buono NON sovrascritto.|r")
return
end
end
WowManagerCVarDump = {
data = date("%Y-%m-%d %H:%M:%S"),
build = select(2, GetBuildInfo()),
pg = (UnitName("player") or "?") .. " - " .. (GetRealmName() or "?"),
totali = tot,
diversi = diversi,
campi = table.concat({ "nome", "valore", "default", "diverso",
"srvAccount", "srvChar", "soloLettura", "categoria", "aiuto" }, SEP),
righe = righe,
}
if not zitto then
print(("|cff88ff88WowManagerCVarDump|r: %d CVar, %d fuori dal default. /reload per scriverlo.")
:format(tot, diversi))
end
end
-- ⚠️ NIENTE dump su PLAYER_LOGOUT, e nessun evento affatto: quell'evento scatta
-- anche alla chiusura vera del gioco, dove il client si sta gia' smontando. Sulle
-- CVar il danno non l'ho misurato -- sono stato locale, non dati dal server, quindi
-- probabilmente reggono -- ma "probabilmente" e' esattamente cio' che si pensava del
-- dump mount prima che perdesse spellID e display su 1659 voci su 1659.
--
-- Qui il calcolo automatico non serve nemmeno: il report lo lanci dopo aver toccato
-- le impostazioni, e /wmcvar e' gia' il modo documentato di vederle subito. Non c'e'
-- nessun momento buono che il comando non copra gia'.
--
-- Il file lo scrive comunque WoW al /reload e all'uscita: la scrittura non e' mai
-- stata il problema, perche' li' versa su disco quel che sta in memoria.
--
-- ⚠️ E siccome nessun evento serve, questo addon e' `LoadOnDemand`: fuori dai momenti
-- in cui dai /wmcvar non ha motivo di stare in memoria, ne' di farsi rileggere e
-- riscrivere i 193 KB del suo SavedVariables a ogni /reload. Il comando lo registra
-- il lanciatore WowManagerDump (scripts/dump-launcher.lua), che carica questo modulo
-- e chiama WowManagerCVarDump_Run.
function WowManagerCVarDump_Run(msg)
msg = (msg or ""):gsub("^%s+", ""):gsub("%s+$", "")
if msg == "" then
Scrivi(false)
return
end
local q = msg:lower()
local righe = Raccogli()
local n = 0
for _, r in ipairs(righe) do
if r:lower():find(q, 1, true) then
n = n + 1
if n <= 25 then
local nome, val, def = r:match("^(.-) ~|~ (.-) ~|~ (.-) ~|~")
print(("|cff88ff88%s|r = %s (default %s)"):format(nome, val, def == "" and "?" or def))
end
end
end
print(("WowManagerCVarDump: %d risultati per '%s'%s"):format(n, msg, n > 25 and " (mostrati i primi 25)" or ""))
end
-- ⚠️ Il comando lo registra il lanciatore: qui si registra SOLO se non c'e', per non
-- avere due handler sulla stessa stringa.
local Caricato = (C_AddOns and C_AddOns.IsAddOnLoaded) or IsAddOnLoaded
if not Caricato("WowManagerDump") then
SLASH_WMCVAR1 = "/wmcvar"
SlashCmdList["WMCVAR"] = WowManagerCVarDump_Run
end#Requires -Version 5.1
# Riscarica i font self-hostati del sito in public/fonts/ e rigenera fonts.css.
#
# .\scripts\fetch-fonts.ps1
#
# Serve solo quando cambi i font o i pesi usati: i file gia' scaricati stanno nel
# repo e non vanno riscaricati a ogni build.
#
# Se cambi i font, aggiorna anche public/fonts/OFL.txt: la SIL OFL 1.1 vuole che
# la licenza (con gli header di copyright) sia spedita insieme ai woff2.
#
# Perche' self-hostati: caricarli da fonts.googleapis.com manda a Google l'IP di
# ogni visitatore, ed e' l'unico host esterno del sito. In locale sparisce anche
# la dipendenza di rendering da un terzo.
#
# Inter e JetBrains Mono sono font VARIABILI: lo stesso woff2 copre tutti i pesi,
# quindi i 12 blocchi @font-face puntano a 4 file soli. Deduplicati per hash.
$ErrorActionPreference = 'Stop'
$Dest = Join-Path (Split-Path $PSScriptRoot -Parent) 'public\fonts'
$Url = 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'
# Con uno User-Agent moderno Google serve woff2; con quello di PowerShell darebbe ttf.
$UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'
$css = (Invoke-WebRequest -Uri $Url -UserAgent $UA -UseBasicParsing).Content
# I blocchi sono preceduti dal commento col nome del subset. Servono solo i latini.
$blocchi = [regex]::Matches($css, '/\* ([a-z-]+) \*/\s*(@font-face \{.*?\})', 'Singleline')
$tenuti = @($blocchi | Where-Object { $_.Groups[1].Value -in @('latin', 'latin-ext') })
if ($tenuti.Count -eq 0) { throw "nessun blocco latin nel CSS: Google ha cambiato formato?" }
New-Item -ItemType Directory -Force -Path $Dest | Out-Null
Get-ChildItem -Path $Dest -Filter '*.woff2' -ErrorAction SilentlyContinue | Remove-Item -Force
$perHash = @{} # hash del contenuto -> nome file
$perUrl = @{} # url -> hash, per non riscaricare due volte lo stesso
$righe = New-Object System.Collections.ArrayList
[void]$righe.Add("/* Inter e JetBrains Mono self-hostati: nessuna richiesta a Google, quindi")
[void]$righe.Add(" nessun IP di visitatore inviato a terzi e un host esterno in meno.")
[void]$righe.Add(" Solo i subset latin e latin-ext, gli unici che servono all'italiano.")
[void]$righe.Add(" Sono font VARIABILI: un file solo copre tutti i pesi, per questo i")
[void]$righe.Add(" blocchi per peso puntano allo stesso woff2.")
[void]$righe.Add(" Rigenerato da scripts/fetch-fonts.ps1. */")
[void]$righe.Add("")
foreach ($m in $tenuti) {
$subset = $m.Groups[1].Value
$blocco = $m.Groups[2].Value
$url = [regex]::Match($blocco, 'url\((https://[^)]+\.woff2)\)').Groups[1].Value
$fam = [regex]::Match($blocco, "font-family: '([^']+)'").Groups[1].Value
if (-not $perUrl.ContainsKey($url)) {
$tmp = [System.IO.Path]::GetTempFileName()
Invoke-WebRequest -Uri $url -UserAgent $UA -OutFile $tmp -UseBasicParsing
$h = (Get-FileHash -Path $tmp -Algorithm MD5).Hash
$perUrl[$url] = $h
if (-not $perHash.ContainsKey($h)) {
$nome = ($fam -replace ' ', '') + "-$subset.woff2"
$perHash[$h] = $nome
Move-Item -LiteralPath $tmp -Destination (Join-Path $Dest $nome) -Force
} else {
Remove-Item -LiteralPath $tmp -Force
}
}
$nome = $perHash[$perUrl[$url]]
[void]$righe.Add(($blocco -replace [regex]::Escape($url), "/fonts/$nome"))
[void]$righe.Add("")
}
$testo = ($righe -join "`n")
[System.IO.File]::WriteAllText(
(Join-Path $Dest 'fonts.css'), $testo, (New-Object System.Text.UTF8Encoding($false)))
$tot = (Get-ChildItem -Path $Dest -Filter '*.woff2' | Measure-Object -Property Length -Sum).Sum
Write-Host ("{0} file, {1:N1} KB, {2} blocchi @font-face" -f $perHash.Count, ($tot / 1KB), $tenuti.Count) -ForegroundColor Green#Requires -Version 5.1
# Smaltisce TUTTO il lavoro che richiede Node, dalla postazione che ce l'ha.
#
# .\scripts\node-pending.ps1
# -> git pull, i quattro passi in JS, commit, push
#
# -NoGit fa i passi e si ferma li' (niente pull, niente commit)
# -NoPull non tocca git in entrata, ma committa e pusha il risultato
#
# Perche' esiste uno script apposta. Il repo si lavora da due macchine con capacita'
# DISGIUNTE: su quella con WoW ci sono i dump e i sync, ma non c'e' Node; su questa
# c'e' Node per il dev server, ma WoW non e' installato e un sync non partirebbe
# nemmeno. Tutto cio' che ha bisogno di `sharp` o di una ricerca sul web resta quindi
# indietro sull'altra macchina, e si smaltisce di qua.
#
# ⚠️ NON e' solo roba di mount, anche se sono le mount a segnalarlo. I lavori sono
# quattro, e per tre di essi nessuno stampa niente quando restano indietro:
# 1. vincoli di classe/razza delle mount mount-classes.mjs (lo dice mount-sync)
# 2. immagini dei modelli delle mount mount-images.mjs (lo dice mount-sync)
# 3. anteprime degli addon addon-images.mjs (non lo dice nessuno)
# 4. miniature degli screenshot UI make-thumbs.mjs (lo dice npm run validate)
# Questo script li conta tutti e quattro e lancia solo quelli che servono: se non c'e'
# niente da fare dice "niente in sospeso" ed esce senza toccare nulla.
param(
[switch]$NoGit,
[switch]$NoPull
)
$ErrorActionPreference = "Stop"
$RepoRoot = Split-Path $PSScriptRoot -Parent
function Errore($msg) {
Write-Host "STOP: $msg" -ForegroundColor Red
exit 1
}
function Percorso($rel) { return (Join-Path $RepoRoot $rel) }
# ── Quanto manca, lavoro per lavoro ───────────────────────────────────────────
# Le mount: gli stessi due conteggi che stampa mount-sync.ps1 al §5a, cioe' le voci
# che il campo `class` non ce l'hanno ancora (stringa vuota = "cercata, nessun
# vincolo", quindi NON conta) e i displayID senza il .webp sul disco.
function PendentiMount {
$classe = 0
$img = 0
$manifest = Percorso "mounts\manifest.json"
$imgDir = Percorso "public\mounts"
if (-not (Test-Path $manifest)) { return @{ classe = 0; img = 0 } }
$correnti = @{}
foreach ($l in (Get-Content -Encoding UTF8 $manifest)) {
if ($l -match '"spell":(\d+)' -and $l -notmatch '"class":') { $classe++ }
if ($l -match '"display":(\d+)') {
$correnti[$matches[1]] = $true
if (-not (Test-Path (Join-Path $imgDir ($matches[1] + ".webp")))) { $img++ }
}
}
# Le forme alternative contano quanto il display corrente: mount-images.mjs le
# scarica e non le considera orfane, quindi contando i soli correnti si direbbe
# "niente in sospeso" mentre un render manca davvero. Vedi mounts/display-noti.json.
$noti = Percorso "mounts\display-noti.json"
if (Test-Path $noti) {
$j = Get-Content -Raw -Encoding UTF8 $noti | ConvertFrom-Json
foreach ($p in $j.PSObject.Properties) {
foreach ($d in $p.Value) {
if ($correnti.ContainsKey([string]$d)) { continue }
if (-not (Test-Path (Join-Path $imgDir ("$d.webp")))) { $img++ }
}
}
}
return @{ classe = $classe; img = $img }
}
# Le anteprime degli addon: manca il file, OPPURE il `preview` nel manifest non e'
# piu' quello da cui l'immagine e' stata fatta. Il secondo caso e' il motivo per cui
# esiste addons/img-fonti.json: il file si chiama con la chiave dell'addon, quindi
# cambiando l'URL il .webp vecchio resterebbe li' in silenzio.
function PendentiAddon {
$manifest = Percorso "addons\manifest.json"
if (-not (Test-Path $manifest)) { return 0 }
$addons = (Get-Content -Raw -Encoding UTF8 $manifest | ConvertFrom-Json).addons
$fonti = $null
$fFonti = Percorso "addons\img-fonti.json"
if (Test-Path $fFonti) { $fonti = Get-Content -Raw -Encoding UTF8 $fFonti | ConvertFrom-Json }
$n = 0
foreach ($p in $addons.PSObject.Properties) {
$webp = Percorso ("public\addon-img\" + $p.Name + ".webp")
if (-not (Test-Path $webp)) { $n++; continue }
$preview = $p.Value.preview
if ($preview -and $fonti) {
$usata = $fonti.PSObject.Properties | Where-Object { $_.Name -eq $p.Name }
if ($usata -and $usata.Value -ne $preview) { $n++ }
}
}
return $n
}
# Le miniature degli screenshot: e' l'unico dei quattro che `npm run validate` gia'
# controlla, quindi qui si arriva di solito con zero.
function PendentiMiniature {
$dir = Percorso "public\screenshots"
if (-not (Test-Path $dir)) { return 0 }
$thumb = Join-Path $dir "thumb"
$n = 0
foreach ($f in Get-ChildItem $dir -Filter *.webp -File) {
if (-not (Test-Path (Join-Path $thumb $f.Name))) { $n++ }
}
return $n
}
# ⚠️ NON chiamarla `Node`: in PowerShell le funzioni hanno la precedenza sugli
# eseguibili, quindi `& node` dentro una funzione chiamata Node richiama se stessa
# all'infinito ("call depth overflow"). Preso al primo giro di prova.
function Lancia($script) {
& node (Join-Path $PSScriptRoot $script)
if ($LASTEXITCODE -ne 0) {
Write-Host (" ({0} ha segnalato un errore: quella parte resta com'era)" -f $script) -ForegroundColor Yellow
}
}
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
Errore ("node non e' installato qui. Questo script va lanciato dalla postazione col dev server: " +
"su quella con WoW ci sono i dump e i sync, non Node.")
}
Push-Location $RepoRoot
try {
# --- 1. allinearsi ---------------------------------------------------------------
# I manifest li scrive l'altra postazione: senza pull si lavorerebbe su dati vecchi e
# il commit finirebbe in conflitto. Con la copia di lavoro sporca non si tira, per non
# trascinare dentro un rebase il lavoro di qualcun altro.
if (-not $NoGit -and -not $NoPull) {
$sporchi = @(git status --porcelain | ForEach-Object { $_.Substring(3) })
if ($sporchi.Count -gt 0) {
Write-Host "copia di lavoro sporca: salto il pull." -ForegroundColor Yellow
$sporchi | ForEach-Object { Write-Host " $_" }
} else {
git pull --rebase -q
if ($LASTEXITCODE -ne 0) { Errore "git pull fallito: allinea a mano e rilancia." }
Write-Host "aggiornato da origin." -ForegroundColor Green
}
}
# --- 2. cosa c'e' da fare --------------------------------------------------------
$mount = PendentiMount
$addon = PendentiAddon
$mini = PendentiMiniature
$totale = $mount.classe + $mount.img + $addon + $mini
if ($totale -eq 0) {
Write-Host "niente in sospeso: nessun lavoro da fare qui." -ForegroundColor Green
exit 0
}
Write-Host "in sospeso:" -ForegroundColor Cyan
if ($mount.classe -gt 0) { Write-Host (" {0,4} mount da controllare (vincolo di classe/razza)" -f $mount.classe) }
if ($mount.img -gt 0) { Write-Host (" {0,4} immagini di modelli da scaricare" -f $mount.img) }
if ($addon -gt 0) { Write-Host (" {0,4} anteprime di addon da rifare" -f $addon) }
if ($mini -gt 0) { Write-Host (" {0,4} miniature di screenshot da generare" -f $mini) }
# --- 3. i quattro passi, solo quelli che servono ---------------------------------
# Ognuno tiene la propria cache sul disco (il campo `class` nel manifest, il file
# col nome del displayID, img-fonti.json, la cartella thumb), quindi rilanciarli non
# rifa' lavoro gia' fatto e un'interruzione non fa perdere niente.
if ($mount.classe -gt 0) { Write-Host "vincoli di classe e razza..."; Lancia "mount-classes.mjs" }
if ($mount.img -gt 0) { Write-Host "immagini dei modelli..."; Lancia "mount-images.mjs" }
if ($addon -gt 0) { Write-Host "anteprime degli addon..."; Lancia "addon-images.mjs" }
if ($mini -gt 0) { Write-Host "miniature degli screenshot..."; Lancia "make-thumbs.mjs" }
# --- 4. cosa resta ---------------------------------------------------------------
# ⚠️ Restare indietro su una parte e' NORMALE e non e' un errore: 7 modelli Blizzard
# non li pubblica (403 stabile, non un intoppo di rete) e il 98% delle mount un
# vincolo di classe non ce l'ha -- per quelle il campo resta stringa vuota, che vuol
# dire "cercata, nessun vincolo", e infatti al giro dopo non si ricercano.
$mountDopo = PendentiMount
$addonDopo = PendentiAddon
$miniDopo = PendentiMiniature
Write-Host ("fatto: vincoli {0} -> {1}, immagini {2} -> {3}, anteprime {4} -> {5}, miniature {6} -> {7}" -f
$mount.classe, $mountDopo.classe, $mount.img, $mountDopo.img,
$addon, $addonDopo, $mini, $miniDopo) -ForegroundColor Green
# --- 5. git ----------------------------------------------------------------------
if ($NoGit) {
Write-Host "-NoGit: mi fermo qui. Committa a mano quando vuoi."
exit 0
}
$miei = @("mounts/manifest.json", "public/mounts", "public/addon-img",
"addons/img-fonti.json", "public/screenshots/thumb")
git add -- $miei
git diff --cached --quiet
if ($LASTEXITCODE -eq 0) {
Write-Host "niente di nuovo da committare." -ForegroundColor Yellow
exit 0
}
# Non si committa il lavoro altrui: se ci sono altri file modificati ci si ferma,
# come fanno i due sync.
$altri = @(git status --porcelain | ForEach-Object { $_.Substring(3) } | Where-Object {
$f = $_
-not ($miei | Where-Object { $f -eq $_ -or $f -like ($_ + "/*") })
})
if ($altri.Count -gt 0) {
Write-Host "altri file modificati, non committo da solo:" -ForegroundColor Yellow
$altri | ForEach-Object { Write-Host " $_" }
Write-Host "il lavoro e' fatto lo stesso: committa tu."
exit 0
}
$pezzi = @()
if (($mount.classe - $mountDopo.classe) -gt 0) { $pezzi += ("{0} vincoli mount" -f ($mount.classe - $mountDopo.classe)) }
if (($mount.img - $mountDopo.img) -gt 0) { $pezzi += ("{0} immagini mount" -f ($mount.img - $mountDopo.img)) }
if (($addon - $addonDopo) -gt 0) { $pezzi += ("{0} anteprime addon" -f ($addon - $addonDopo)) }
if (($mini - $miniDopo) -gt 0) { $pezzi += ("{0} miniature" -f ($mini - $miniDopo)) }
$titolo = "Node: " + $(if ($pezzi.Count -gt 0) { $pezzi -join ", " } else { "pendente smaltito" })
# L'hook commit-msg rifiuta i titoli oltre i 72 caratteri.
if ($titolo.Length -gt 72) { $titolo = $titolo.Substring(0, 69) + "..." }
git commit -q -m $titolo
# L'esito del push si GUARDA: e' il push a far ripartire GitHub Actions, quindi un
# commit rimasto in locale vuol dire sito fermo con la data "sync" vecchia.
git push -q
if ($LASTEXITCODE -ne 0) {
Write-Host "committato, ma il PUSH E' FALLITO (git ha risposto $LASTEXITCODE)." -ForegroundColor Red
Write-Host " il commit e' in locale: rilancia 'git push' quando la rete torna." -ForegroundColor Yellow
exit 1
}
Write-Host ("committato e pushato: " + $titolo) -ForegroundColor Green
} finally { Pop-Location }# Genera public/og-image.png (1200x630), l'anteprima social linkata da Base.astro
# (og:image / twitter:image). Tipografico e sobrio: fondo scuro come il favicon, barra
# oro, titolo + dominio + tagline. Rilancialo per rigenerarlo dopo un cambio di brand.
#
# ASCII-only di proposito: niente accenti/middot nel sorgente (PS 5.1 legge i .ps1 senza
# BOM come ANSI e li corromperebbe). Le separazioni della tagline usano " - ".
Add-Type -AssemblyName System.Drawing
$W = 1200
$H = 630
$bmp = New-Object System.Drawing.Bitmap $W, $H
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::ClearTypeGridFit
# Sfondo scuro (stesso del favicon) + barra oro a sinistra.
$g.Clear([System.Drawing.Color]::FromArgb(23, 21, 15))
$gold = [System.Drawing.Color]::FromArgb(224, 178, 76)
$g.FillRectangle((New-Object System.Drawing.SolidBrush $gold), 0, 0, 16, $H)
# Titolo, dominio, tagline.
$fTitle = New-Object System.Drawing.Font('Segoe UI', 82, [System.Drawing.FontStyle]::Bold)
$fDomain = New-Object System.Drawing.Font('Segoe UI', 34, [System.Drawing.FontStyle]::Regular)
$fTag = New-Object System.Drawing.Font('Segoe UI', 27, [System.Drawing.FontStyle]::Regular)
$bWhite = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(245, 246, 247))
$bGold = New-Object System.Drawing.SolidBrush $gold
$bMuted = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(150, 156, 166))
$g.DrawString('WoW Manager', $fTitle, $bWhite, 78, 196)
$g.DrawString('wow.danilofruttaldo.com', $fDomain, $bGold, 86, 336)
$g.DrawString('Addon - Macro - Professioni - PG - Transmog - UI', $fTag, $bMuted, 86, 416)
$out = Join-Path (Get-Location).Path 'public\og-image.png'
$bmp.Save($out, [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose()
$bmp.Dispose()
Write-Output "Scritto $out"// Immagini del modello per la pagina /mount: una per creatureDisplayInfoID, in
// public/mounts/<display>.webp. Lo lancia mount-sync.ps1 dopo aver riscritto il
// manifest, oppure a mano:
//
// node scripts/mount-images.mjs scarica le mancanti e toglie le orfane
// node scripts/mount-images.mjs --rifai riscarica anche quelle che ci sono gia'
//
// ⚠️ E' in JS e non in PowerShell -- controcorrente rispetto al resto di scripts/ --
// per una ragione sola: il render arriva in JPEG e va convertito, e l'unico
// convertitore che il repo ha gia' e' `sharp` (dipendenza di Astro, dichiarata anche
// in package.json perche' qui la si usa direttamente). PowerShell non ha nulla per
// scrivere un webp.
//
// DUE FONTI, in ordine:
// 1. RENDER UFFICIALE Blizzard, 600x600 su fondo scuro:
// https://render.worldofwarcraft.com/us/npcs/zoom/creature-display-<id>.jpg
// Nessuna chiave, nessun token. Copre 1509 modelli su 1516.
// 2. Miniatura del model viewer di Wowhead, 300x300 su fondo trasparente:
// https://wow.zamimg.com/modelviewer/live/webthumbs/npc/<id % 256>/<id>.webp
// Era la fonte unica: meta' risoluzione e inquadratura piu' stretta. Resta come
// ripiego per i 7 modelli che Blizzard non pubblica (403, non 404: e' una
// risposta stabile, non un intoppo di rete -- riprovarli non serve).
//
// Il file su disco E' la cache: un'interruzione non fa perdere lavoro, si riprende da
// dove era rimasto. Per rifare un'immagine basta cancellarla.
import { readFileSync, existsSync, writeFileSync, unlinkSync, mkdirSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import sharp from 'sharp';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const dir = join(root, 'public', 'mounts');
const rifai = process.argv.includes('--rifai');
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) WowManagerMountSync/1.0';
// Concorrenza bassa e deliberata: a 8 richieste in parallelo il CDN di Blizzard
// risponde 403 a qualcuna, e un 403 da rate limit e' indistinguibile da un 403 da
// "render non pubblicato".
const PARALLELE = 4;
const manifest = JSON.parse(readFileSync(join(root, 'mounts', 'manifest.json'), 'utf8'));
// ⚠️ Non basta il `display` del manifest, che e' quello dell'ultimo dump: qualche mount
// CAMBIA modello da se' (Ash'adar e' solare o lunare secondo l'ora, altre seguono la
// personalizzazione attiva). Tenendo conto del solo display corrente questo script
// cancellerebbe il render dell'altra forma come orfano e lo riscaricherebbe al
// ribaltamento successivo, all'infinito. mounts/display-noti.json elenca i display gia'
// visti per ogni mount: quelli valgono quanto il corrente, sia per scaricare sia per
// decidere cosa e' davvero orfano. Lo tiene aggiornato mount-sync.ps1.
const vivi = new Set(manifest.mounts.map((m) => String(m.id)));
const notiPath = join(root, 'mounts', 'display-noti.json');
const noti = existsSync(notiPath) ? JSON.parse(readFileSync(notiPath, 'utf8')) : {};
const displays = [...new Set([
...manifest.mounts.map((m) => m.display).filter(Boolean),
// Una mount uscita dal manifest (esclusa, o sparita dal diario) porta via anche le
// sue forme alternative: i suoi file tornano orfani veri e vanno cancellati.
...Object.entries(noti).filter(([id]) => vivi.has(String(id))).flatMap(([, d]) => d),
])];
mkdirSync(dir, { recursive: true });
async function scarica(url) {
const r = await fetch(url, { headers: { 'User-Agent': UA } });
if (!r.ok) return null;
return Buffer.from(await r.arrayBuffer());
}
// ⚠️ L'INQUADRATURA DEI RENDER NON E' UNIFORME, e senza correggerla il guadagno di
// risoluzione si perde: la camera di Blizzard non si adatta al modello, quindi un
// cavallo riempie il quadrato mentre l'Abyss Worm ci sta dentro alto 200 pixel su 600
// -- in pagina diventava un francobollo in mezzo a una fascia scura vuota, peggio
// della vecchia miniatura di Wowhead.
// Si normalizza: si ritaglia il fondo piatto (#181818, misurato: e' identico su tutta
// l'immagine), si porta il lato lungo del modello a 470 di 600 e lo si ricentra. Il
// tetto di 2.2x evita di gonfiare un modello davvero minuscolo fino a spappolarlo.
const BG = { r: 0x18, g: 0x18, b: 0x18 };
const LATO = 600; // il quadrato dei render, e quello che si riscrive
const CONTENUTO = 470; // quanto ne deve occupare il modello: il resto e' margine
async function normalizza(jpg) {
const t = await sharp(jpg).trim({ background: BG, threshold: 14 }).toBuffer({ resolveWithObject: true });
const scala = Math.min(CONTENUTO / Math.max(t.info.width, t.info.height), 2.2);
const w = Math.max(1, Math.round(t.info.width * scala));
const h = Math.max(1, Math.round(t.info.height * scala));
const sx = Math.floor((LATO - w) / 2);
const sy = Math.floor((LATO - h) / 2);
return sharp(t.data)
.resize(w, h, { kernel: 'lanczos3' })
.extend({ left: sx, right: LATO - w - sx, top: sy, bottom: LATO - h - sy, background: BG })
.webp({ quality: 80, effort: 5 })
.toBuffer();
}
// Il render e' un JPEG: si ricomprime in webp (q80) perche' a parita' di resa pesa un
// terzo -- 1509 render sono 40 MB in jpg e 13 in webp, cioe' meno dei 16 MB che
// occupavano le vecchie miniature a meta' risoluzione.
async function prendi(id) {
const jpg = await scarica(`https://render.worldofwarcraft.com/us/npcs/zoom/creature-display-${id}.jpg`);
if (jpg) return { buf: await normalizza(jpg), fonte: 'render' };
const thumb = await scarica(`https://wow.zamimg.com/modelviewer/live/webthumbs/npc/${id % 256}/${id}.webp`);
return thumb ? { buf: thumb, fonte: 'thumb' } : null;
}
// ⚠️ Su Windows la scrittura fallisce ogni tanto con un errno opaco (-4094 UNKNOWN):
// il file e' aperto da qualcun altro in quell'istante -- l'antivirus che lo scandisce
// appena creato, o il server di anteprima che lo sta servendo. Con 1500 file capita, e
// una volta ha buttato giu' l'intero giro a due terzi. Si riprova invece di morire.
async function scrivi(file, buf, tentativi = 4) {
for (let i = 1; ; i++) {
try {
writeFileSync(file, buf);
return;
} catch (e) {
if (i >= tentativi) throw e;
await new Promise((r) => setTimeout(r, 150 * i));
}
}
}
const daFare = displays.filter((id) => rifai || !existsSync(join(dir, `${id}.webp`)));
if (daFare.length) console.log(`immagini da scaricare: ${daFare.length}`);
const conta = { render: 0, thumb: 0, niente: 0 };
let fatte = 0;
const coda = [...daFare];
await Promise.all(
Array.from({ length: PARALLELE }, async () => {
for (let id = coda.shift(); id !== undefined; id = coda.shift()) {
// Un singolo modello che va storto -- rete, immagine corrotta, file occupato --
// non deve buttare giu' il giro: si conta fra i «non disponibili» e si tira
// avanti. (Con `--rifai` sono 1500 file: e' successo davvero, a due terzi.)
let esito = null;
try {
esito = await prendi(id);
if (esito) await scrivi(join(dir, `${id}.webp`), esito.buf);
} catch {
esito = null;
}
if (esito) {
conta[esito.fonte]++;
} else {
conta.niente++;
}
if (++fatte % 100 === 0) console.log(` ...${fatte}/${daFare.length}`);
}
}),
);
if (daFare.length) {
console.log(`immagini: ${conta.render} render ufficiali, ${conta.thumb} miniature Wowhead, ${conta.niente} non disponibili`);
}
// Orfane: una mount esclusa (o sparita dal diario) lascerebbe il suo file nel repo.
const vive = new Set(displays.map(String));
let orfane = 0;
for (const f of readdirSync(dir)) {
if (f.endsWith('.webp') && !vive.has(f.slice(0, -5))) {
unlinkSync(join(dir, f));
orfane++;
}
}
if (orfane) console.log(`immagini orfane rimosse: ${orfane}`);-- WowManagerDump - il SOLO addon del repo che resta sempre caricato, e non calcola
-- niente: registra i comandi e carica il modulo giusto quando glielo chiedi.
--
-- Installazione: _retail_/Interface/AddOns/WowManagerDump/ con questo file
-- rinominato -> WowManagerDump.lua + scripts/WowManagerDump.toc (invariato).
-- ⚠️ E' un addon NUOVO: la prima volta serve il RIAVVIO del client, il /reload non lo
-- vede. Da li' in poi lo tengono aggiornato mount-sync.ps1 e transmog-sync.ps1 (§0).
--
-- ── Perche' esiste ────────────────────────────────────────────────────────────
-- I quattro dump (tier, mount, prof, cvar) sono attrezzi da manutenzione: servono
-- quando sincronizzo il sito, cioe' qualche volta al mese. Restando addon normali
-- pero' li pagavo a OGNI sessione, senza usarli:
-- * 95 KB di Lua compilati a ogni login;
-- * 2,6 MB di SavedVariables letti al login e tenuti in memoria per tutta la
-- sessione (misurato: tier 1561 KB, mount 596, prof 271, cvar 193);
-- * e gli stessi 2,6 MB riscritti su disco a OGNI /reload e a ogni uscita, anche
-- quando il dump non era stato ricalcolato -- WoW versa quel che sta in memoria.
--
-- Dichiarandoli `LoadOnDemand` nel .toc quel conto sparisce: un addon non caricato
-- non compila codice, non gli si legge il SavedVariables e non glielo si riscrive.
-- ⚠️ Ma un addon LoD non puo' registrare un comando che non ha ancora caricato:
-- serve qualcuno di sempre presente che lo faccia al posto suo, ed e' questo file.
-- Costa il suo caricamento (pochi KB) e un SavedVariables di poche righe.
--
-- ⚠️ I comandi restano gli stessi (/wmtier /wmmount /wmprof /wmcvar): gli script di
-- sync li chiedono per nome, e non devono accorgersi di nulla. Ogni modulo continua
-- a registrarsi il proprio comando SE questo lanciatore non c'e' (vedi in fondo ai
-- moduli), cosi' un modulo caricato a mano resta utilizzabile da solo.
local MODULI = {
tier = { addon = "WowManagerTierDump", run = "WowManagerTierDump_Run" },
mount = { addon = "WowManagerMountDump", run = "WowManagerMountDump_Run" },
prof = { addon = "WowManagerProfDump", run = "WowManagerProfDump_Run" },
cvar = { addon = "WowManagerCVarDump", run = "WowManagerCVarDump_Run" },
}
-- Le globali LoadAddOn/IsAddOnLoaded sono state spostate in C_AddOns: si prende
-- quella che c'e' invece di dare per buona una delle due.
local Carica = (C_AddOns and C_AddOns.LoadAddOn) or LoadAddOn
local Caricato = (C_AddOns and C_AddOns.IsAddOnLoaded) or IsAddOnLoaded
-- ⚠️ LoadAddOn e' SINCRONA e il SavedVariables del modulo e' gia' popolato quando
-- ritorna: le guardie dei dump ("c'e' gia' un dump buono, non lo sovrascrivo")
-- leggono la loro DB e la trovano. Percio' il runner si puo' chiamare subito dopo.
local function Esegui(chiave, msg)
local m = MODULI[chiave]
if not Caricato(m.addon) then
-- LoadAddOn non solleva errore per un addon disabilitato: risponde nil piu'
-- il motivo. Servono tutti e due i controlli.
local ok, caricato, motivo = pcall(Carica, m.addon)
if not ok or not caricato then
print(("|cffff2020WowManagerDump|r: %s non si carica (%s). E' abilitato nella lista addon?")
:format(m.addon, tostring(ok and motivo or caricato)))
return
end
end
local f = _G[m.run]
if type(f) ~= "function" then
print(("|cffff2020WowManagerDump|r: %s caricato ma %s non esiste. Copia del modulo indietro rispetto al repo?")
:format(m.addon, m.run))
return
end
return f(msg)
end
SLASH_WMTIER1 = "/wmtier"
SlashCmdList["WMTIER"] = function(msg) Esegui("tier", msg) end
SLASH_WMMOUNT1 = "/wmmount"
SlashCmdList["WMMOUNT"] = function(msg) Esegui("mount", msg) end
SLASH_WMPROF1 = "/wmprof"
SlashCmdList["WMPROF"] = function(msg) Esegui("prof", msg) end
SLASH_WMCVAR1 = "/wmcvar"
SlashCmdList["WMCVAR"] = function(msg) Esegui("cvar", msg) end
-- ── Mount: la collezione che cambia davvero ───────────────────────────────────
-- ⚠️ L'unico innesco automatico dei dump di collezione, e resta qui invece che nel
-- modulo perche' il modulo non e' caricato: se l'evento lo aspettasse lui non
-- scatterebbe mai. NEW_MOUNT_ADDED capita qualche volta al mese -- il gemello
-- transmog un innesco l'ha avuto e gli e' stato tolto, perche' li' gli eventi
-- scattano a ogni apparenza e il ricalcolo gira su 11403 pezzi.
--
-- ⚠️ Il ritardo non e' un dettaglio: imparare una cavalcatura fa scattare l'evento
-- piu' volte. Si programma UN ricalcolo dopo la raffica, non uno per evento. E se il
-- diario delle collezioni e' APERTO si rimanda: quel dump accende e spegne i filtri
-- per tipo e azzera la ricerca testuale, cioe' ti cambierebbe sotto gli occhi la
-- finestra che stai usando.
local ATTESA = 30
local programmato = false
local function ProgrammaMount()
if programmato then return end
programmato = true
C_Timer.After(ATTESA, function()
programmato = false
if CollectionsJournal and CollectionsJournal:IsShown() then
ProgrammaMount()
return
end
Esegui("mount")
end)
end
-- ── Professioni: solo quando cambiano ─────────────────────────────────────────
-- Il dump professioni girava a ogni PLAYER_LOGIN, e quell'evento scatta anche a ogni
-- /reload: significava ripercorrere gli alberi (38 skill line, centinaia di chiamate)
-- ogni volta, per riscrivere quasi sempre lo stesso dump. Il motivo per cui girava al
-- login resta valido -- e' cosi' che un alt entra nel tracker professioni senza
-- doversi ricordare /wmprof su ognuno -- quindi non si toglie: si condiziona.
--
-- La firma e' fatta di sole chiamate a costo trascurabile (le professioni note e il
-- configID di ogni skill line): se combacia con l'ultima per cui il dump e' stato
-- fatto, non c'e' niente di nuovo da scrivere e il modulo non si carica nemmeno.
-- ⚠️ Il configID entra nella firma perche' e' lui a dire "questa professione e'
-- speccata", cioe' l'unico caso in cui l'albero coi cap esce davvero: senza, il
-- primo /wmprof dopo aver speccato resterebbe l'unica strada.
local function FirmaProf()
local pezzi = {}
for i = 1, select("#", GetProfessions()) do
local idx = select(i, GetProfessions())
if idx then
local nome, _, _, _, _, _, linea = GetProfessionInfo(idx)
if linea then pezzi[#pezzi + 1] = ("p%s:%d"):format(tostring(nome), linea) end
end
end
-- ⚠️ Nessuna professione letta = firma VUOTA, e si esce subito: le skill line
-- speccate qui sotto da sole basterebbero a farla sembrare piena, e una firma
-- piena e' una firma che si registra. Se i dati del PG non sono ancora arrivati,
-- registrarla vorrebbe dire archiviare una lettura a vuoto come "gia' fatto".
if #pezzi == 0 then return "" end
if C_TradeSkillUI and C_TradeSkillUI.GetAllProfessionTradeSkillLines and C_ProfSpecs
and C_ProfSpecs.GetConfigIDForSkillLine then
local ok, tutte = pcall(C_TradeSkillUI.GetAllProfessionTradeSkillLines)
if ok and type(tutte) == "table" then
for _, linea in ipairs(tutte) do
local okc, cfg = pcall(C_ProfSpecs.GetConfigIDForSkillLine, linea)
if okc and type(cfg) == "number" and cfg ~= 0 then
pezzi[#pezzi + 1] = ("s%d"):format(linea)
end
end
end
end
table.sort(pezzi)
return table.concat(pezzi, "|")
end
-- ⚠️ Firma vuota = dati del PG non ancora arrivati (oppure un PG senza professioni):
-- in entrambi i casi non c'e' niente da dumpare e NON si registra nulla, altrimenti
-- una lettura a vuoto si fisserebbe come "gia' fatto". Un solo riprova, poi si tace:
-- /wmprof resta li'.
local function ControllaProf(riprova)
local firma = FirmaProf()
if firma == "" then
if riprova then C_Timer.After(20, function() ControllaProf(false) end) end
return
end
local pg = (UnitName("player") or "?") .. "-" .. (GetRealmName() or "?")
WowManagerDumpDB = WowManagerDumpDB or {}
WowManagerDumpDB.firmaProf = WowManagerDumpDB.firmaProf or {}
if WowManagerDumpDB.firmaProf[pg] == firma then return end
WowManagerDumpDB.firmaProf[pg] = firma
Esegui("prof")
end
-- ⚠️ RegisterEvent su un nome che il client non conosce solleva errore, e un errore
-- qui vorrebbe dire lanciatore non caricato, cioe' nessun comando affatto -- molto
-- peggio del problema che l'automatismo risolve.
local f = CreateFrame("Frame")
f:SetScript("OnEvent", function(_, evento)
if evento == "NEW_MOUNT_ADDED" then
ProgrammaMount()
elseif evento == "PLAYER_LOGIN" then
C_Timer.After(5, function() ControllaProf(true) end)
end
end)
f:RegisterEvent("PLAYER_LOGIN")
if not pcall(f.RegisterEvent, f, "NEW_MOUNT_ADDED") then
print("|cffffcc00WowManagerDump|r: NEW_MOUNT_ADDED non riconosciuto, resta /wmmount.")
end// Miniature degli screenshot UI: una per ogni file in public/screenshots/, dentro
// public/screenshots/thumb/ con lo STESSO nome.
//
// node scripts/make-thumbs.mjs rigenera solo le miniature piu' vecchie
// node scripts/make-thumbs.mjs --forza le rifa' tutte
// node scripts/make-thumbs.mjs --larghezza 800
//
// Gli originali sono 2560x1440: vanno bene per il lightbox, che li mostra a schermo
// intero, ma la griglia della pagina /ui li rende a ~300px e scaricarli interi e' spreco
// puro. La miniatura e' larga 640 = il doppio della card, cosi' resta nitida sugli
// schermi a densita' doppia. Gli originali non si toccano mai.
//
// ⚠️ Era `make-thumbs.ps1` e usava System.Drawing di .NET, per non dipendere da Node su
// questa macchina. Non e' piu' possibile: dal 2026-08-04 gli screenshot sono **webp**
// (meta' del peso a parita' di resa, misurato: 15.7 MB -> 7.9 MB su 52 file) e
// System.Drawing il webp non lo legge ne' lo scrive. E' lo stesso motivo per cui
// mount-images.mjs e addon-images.mjs sono in JS: serve `sharp`, e PowerShell non ha
// nulla per scrivere un webp. La regola «gli script di manutenzione in PowerShell» vale
// ancora — questa ne e' l'eccezione gia' prevista, non uno strappo.
//
// ⚠️ Legge SOLO la radice di screenshots/: se leggesse anche thumb/ alla seconda
// esecuzione rimpicciolirebbe le proprie miniature.
import { readdirSync, existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import sharp from 'sharp';
// ⚠️ La radice viene dal PERCORSO DELLO SCRIPT, non da `process.cwd()`: come in
// mount-images.mjs e addon-images.mjs. Con la cwd, lanciarlo da una cartella diversa dal
// repo non falliva — creava `./public/screenshots/thumb` li' dov'era, vuoto, e stampava
// «Nessuno screenshot da elaborare» come se fosse tutto a posto.
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const src = join(root, 'public', 'screenshots');
const dest = join(src, 'thumb');
const forza = process.argv.includes('--forza');
const iw = process.argv.indexOf('--larghezza');
const LARGHEZZA = iw >= 0 ? Number(process.argv[iw + 1]) : 640;
const QUALITA = 82;
mkdirSync(dest, { recursive: true });
const files = readdirSync(src).filter((f) => f.endsWith('.webp')).sort();
if (!files.length) {
console.log('Nessuno screenshot da elaborare.');
process.exit(0);
}
let prima = 0, dopo = 0, fatte = 0, saltate = 0;
for (const f of files) {
const orig = join(src, f);
const out = join(dest, f);
const st = statSync(orig);
prima += st.size;
// Si salta se la miniatura c'e' ed e' piu' recente dell'originale.
if (!forza && existsSync(out) && statSync(out).mtimeMs >= st.mtimeMs) {
dopo += statSync(out).size;
saltate++;
continue;
}
const buf = await sharp(orig)
.resize(LARGHEZZA, null, { withoutEnlargement: true, kernel: 'lanczos3' })
.webp({ quality: QUALITA, effort: 5 })
.toBuffer();
writeFileSync(out, buf);
dopo += buf.length;
fatte++;
console.log(` + ${f} (${(buf.length / 1024) | 0} KB)`);
}
const mb = (n) => (n / 1024 / 1024).toFixed(2) + ' MB';
console.log(`miniature: ${fatte} generate, ${saltate} gia' aggiornate`);
console.log(`originali ${mb(prima)} → miniature ${mb(dopo)} (-${(100 - (dopo / prima) * 100) | 0}%)`);#Requires -Version 5.1
# Report delle impostazioni del client, per decidere cosa mettere nel preset di
# sync-game-settings.ps1.
#
# .\scripts\settings-report.ps1 aspetta un dump nuovo (/reload in gioco)
# .\scripts\settings-report.ps1 -Subito usa il dump gia' sul disco
# .\scripts\settings-report.ps1 -Md <path> scrive il report altrove
#
# Incrocia tre cose:
# 1. il dump dell'addon WowManagerCVarDump (/wmcvar): ogni CVar del client col
# suo valore, il DEFAULT, la categoria e il testo d'aiuto che scrive il gioco.
# L'addon e' LoadOnDemand: il comando lo registra il lanciatore WowManagerDump,
# che lo carica al volo. Questo script non installa nulla -- gli addon li
# allineano i due sync (mount e transmog, §0);
# 2. il preset di sync-game-settings.ps1, letto dal file (non duplicato qui);
# 3. i 53 config-cache.wtf per-PG, per dire su quanti PG una chiave e' presente
# e con quanti valori diversi.
#
# Quello che serve leggere e' la sezione 2: le impostazioni che hai cambiato in
# gioco e che il preset NON propaga ancora.
param(
[switch]$Subito,
[int]$AttesaMax = 300,
[string]$Md = (Join-Path $env:TEMP 'wow-impostazioni.md'),
[string]$Wow = 'C:\Program Files (x86)\World of Warcraft'
)
$ErrorActionPreference = 'Stop'
$RepoRoot = Split-Path $PSScriptRoot -Parent
$SEP = ' ~|~ '
function Errore($msg) { Write-Host "STOP: $msg" -ForegroundColor Red; exit 1 }
function TrovaDump {
# Cercato, non scritto a mano: il percorso contiene il nome dell'account.
return Get-ChildItem -Path (Join-Path $Wow '_retail_\WTF\Account') -Recurse `
-Filter 'WowManagerCVarDump.lua' -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -like '*SavedVariables*' } |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
}
# ⚠️ La data del FILE non dice se il dump e' fresco: WoW riscrive il SavedVariables a
# ogni /reload e a ogni uscita, quindi senza /wmcvar riscrive gli stessi dati con una
# data di modifica nuova. Il campo `data` invece lo mette l'addon quando calcola.
function CalcolatoIl($file) {
try {
$t = Get-Content -Raw -Encoding UTF8 $file -ErrorAction Stop
if ($t -match '\["?data"?\]\s*=\s*"([^"]+)"') { return $Matches[1] }
} catch { } # letto mentre il gioco lo stava scrivendo: si riprova al giro dopo
return $null
}
# --- 1. il dump ------------------------------------------------------------------
$dump = TrovaDump
if (-not $dump) {
Errore ("WowManagerCVarDump.lua non trovato. L'addon e' installato in " +
"Interface\AddOns\WowManagerCVarDump? Un addon NUOVO non lo vede il /reload: " +
"la prima volta serve riavviare il client.")
}
if (-not $Subito) {
$prima = CalcolatoIl $dump.FullName
Write-Host ''
# Il comando PRIMA del /reload: /wmcvar calcola, il /reload scrive. L'addon non
# rigenera piu' da solo (scattava su PLAYER_LOGOUT, che vale anche per la chiusura
# del gioco), quindi un /reload da solo riscriverebbe gli stessi dati.
Write-Host ' In gioco: /wmcvar poi /reload' -ForegroundColor Cyan
Write-Host (" (aspetto un dump nuovo, max {0}s - Ctrl+C per annullare)" -f $AttesaMax)
$scaduto = (Get-Date).AddSeconds($AttesaMax)
$ora = $prima
while ((Get-Date) -lt $scaduto) {
Start-Sleep -Seconds 2
$dump = TrovaDump
$ora = CalcolatoIl $dump.FullName
if ($ora -and $ora -ne $prima) { break }
}
if (-not $ora -or $ora -eq $prima) {
Errore ("nessun dump RICALCOLATO entro $AttesaMax secondi (data ferma a '{0}'). " -f $prima) +
"Il /reload da solo riscrive gli stessi dati: serve /wmcvar prima. Oppure -Subito per usare quello che c'e'."
}
Write-Host ' dump ricevuto.' -ForegroundColor Green
}
$testo = Get-Content -LiteralPath $dump.FullName -Raw -Encoding UTF8
$meta = @{}
foreach ($k in @('data', 'pg', 'build')) {
if ($testo -match ('\["' + $k + '"\]\s*=\s*"([^"]*)"')) { $meta[$k] = $matches[1] }
}
$totali = 0; $diversi = 0
if ($testo -match '\["totali"\]\s*=\s*(\d+)') { $totali = [int]$matches[1] }
if ($testo -match '\["diversi"\]\s*=\s*(\d+)') { $diversi = [int]$matches[1] }
if ($totali -le 0) { Errore "il dump dice $totali CVar: lettura a vuoto, rifai /reload" }
# Righe di testo, separatore " ~|~ ". Il dump ha gia' tolto virgolette e
# backslash dai campi, quindi non c'e' nessun escape da sciogliere.
$cvar = [ordered]@{}
foreach ($riga in ($testo -split "`r?`n")) {
if ($riga -notmatch '"(.+)"\s*,?\s*$') { continue }
$c = $matches[1]
if ($c -notlike "*$SEP*") { continue }
$f = $c -split ([regex]::Escape($SEP))
if ($f.Count -lt 9) { continue }
if ($f[0] -eq 'nome' -and $f[1] -eq 'valore') { continue } # riga d'intestazione
$cvar[$f[0]] = [pscustomobject]@{
nome = $f[0]; valore = $f[1]; default = $f[2]; diverso = ($f[3] -eq '1')
srvAccount = ($f[4] -eq '1'); srvChar = ($f[5] -eq '1'); soloLettura = ($f[6] -eq '1')
categoria = $f[7]; aiuto = $f[8]
}
}
if ($cvar.Count -eq 0) { Errore 'nessuna riga di CVar letta dal dump: formato inatteso' }
# --- 2. il preset, letto da sync-game-settings.ps1 --------------------------------
function LeggiBlocco($file, $nomeVar) {
$val = [ordered]@{}
$dentro = $false
foreach ($l in (Get-Content -LiteralPath $file)) {
if (-not $dentro) {
if ($l -match ('^\s*\$' + $nomeVar + '\s*=\s*\[ordered\]@\{')) { $dentro = $true }
continue
}
if ($l -match '^\s*\}\s*$') { break }
if ($l -match "^\s*([A-Za-z0-9_]+)\s*=\s*'([^']*)'") { $val[$matches[1]] = $matches[2] }
}
return $val
}
$syncPs1 = Join-Path $PSScriptRoot 'sync-game-settings.ps1'
$preset = LeggiBlocco $syncPs1 'CharValues'
$presetAcc = LeggiBlocco $syncPs1 'AccountValues'
if ($preset.Count -eq 0) { Errore "preset non letto da $syncPs1 (il blocco CharValues e' cambiato di forma?)" }
# --- 3. i file per-PG -------------------------------------------------------------
$AccRoot = Join-Path $Wow '_retail_\WTF\Account'
$Acc = (Get-ChildItem -LiteralPath $AccRoot -Directory |
Where-Object { $_.Name -ne 'SavedVariables' } | Select-Object -First 1).FullName
$accCache = Join-Path $Acc 'config-cache.wtf'
$files = Get-ChildItem -LiteralPath $Acc -Recurse -File -Filter 'config-cache.wtf' |
Where-Object { $_.FullName -ne $accCache -and $_.FullName -notmatch '\\_backup-' }
$suiPg = @{}
foreach ($f in $files) {
foreach ($l in (Get-Content -LiteralPath $f.FullName)) {
if ($l -match '^SET (\S+) "(.*)"\s*$') {
$k = $matches[1]
if (-not $suiPg.ContainsKey($k)) { $suiPg[$k] = @{} }
$suiPg[$k][$f.Directory.Name] = $matches[2]
}
}
}
function Copertura($nome) {
if (-not $suiPg.ContainsKey($nome)) { return "-" }
$v = $suiPg[$nome].Values
$n = @($v).Count
$d = @($v | Sort-Object -Unique).Count
if ($d -gt 1) { return "$n PG / $d valori" }
return "$n PG"
}
# --- 4. il report -----------------------------------------------------------------
$rep = New-Object System.Collections.Generic.List[string]
$rep.Add("# Impostazioni del client")
$rep.Add('')
$rep.Add("PG del dump: **$($meta['pg'])** - build $($meta['build']) - dump del $($meta['data'])")
$rep.Add('')
$rep.Add("CVar totali: **$totali**, fuori dal default: **$diversi**. Preset: **$($preset.Count)** chiavi per-PG + $($presetAcc.Count) account-wide.")
$rep.Add('')
$rep.Add('> Il valore mostrato e'' quello del PG del dump. La colonna *nei file* dice su quanti')
$rep.Add('> dei ' + $files.Count + ' config-cache.wtf la chiave compare e con quanti valori distinti:')
$rep.Add('> piu'' di un valore = i PG oggi divergono.')
$rep.Add('')
# Sezione 1: il preset
$rep.Add('## 1. Chiavi del preset')
$rep.Add('')
$rep.Add('| chiave | preset | sul PG | default | stato |')
$rep.Add('|---|---|---|---|---|')
foreach ($k in $preset.Keys) {
$c = $cvar[$k]
if (-not $c) { $rep.Add("| ``$k`` | $($preset[$k]) | ? | ? | il client non conosce questa chiave |"); continue }
$stato = if ($preset[$k] -ne $c.valore) { 'diverso dal PG' }
elseif ($preset[$k] -eq $c.default) { 'uguale al default: il preset non serve' }
else { 'ok' }
$rep.Add("| ``$k`` | $($preset[$k]) | $($c.valore) | $($c.default) | $stato |")
}
$rep.Add('')
# Sezione 2: i candidati -- la parte da leggere
$cand = @($cvar.Values | Where-Object { $_.diverso -and -not $preset.Contains($_.nome) -and
-not $presetAcc.Contains($_.nome) -and -not $_.soloLettura })
$rep.Add("## 2. Fuori dal default e non nel preset ($($cand.Count))")
$rep.Add('')
$rep.Add('Le impostazioni che hai cambiato e che il preset non propaga. Dimmi quali vuoi ovunque.')
$rep.Add('')
$rep.Add('| chiave | valore | default | nei file | scope | categoria | aiuto |')
$rep.Add('|---|---|---|---|---|---|---|')
foreach ($c in ($cand | Sort-Object categoria, nome)) {
$scope = if ($c.srvAccount) { 'server/account' } elseif ($c.srvChar) { 'server/PG' } else { 'file' }
$rep.Add("| ``$($c.nome)`` | $($c.valore) | $($c.default) | $(Copertura $c.nome) | $scope | $($c.categoria) | $($c.aiuto) |")
}
$rep.Add('')
# Sezione 3: quelle che i file non possono propagare
$srv = @($cvar.Values | Where-Object { $_.diverso -and ($_.srvAccount -or $_.srvChar) })
$rep.Add("## 3. Salvate sul server: i file WTF non le propagano ($($srv.Count))")
$rep.Add('')
$rep.Add('Stessa natura del flag che blocca gli inviti in gilda: scriverle nei config-cache non fa niente.')
$rep.Add('')
$rep.Add('| chiave | valore | default | dove | categoria |')
$rep.Add('|---|---|---|---|---|')
foreach ($c in ($srv | Sort-Object nome)) {
$dove = if ($c.srvAccount) { 'account' } else { 'personaggio' }
$rep.Add("| ``$($c.nome)`` | $($c.valore) | $($c.default) | $dove | $($c.categoria) |")
}
$rep.Add('')
# Sezione 4: dove i PG divergono nei file, fuori preset
$div = @()
foreach ($k in $suiPg.Keys) {
if ($preset.Contains($k)) { continue }
$vals = @($suiPg[$k].Values | Sort-Object -Unique)
$mancanti = $files.Count - @($suiPg[$k].Keys).Count
if ($vals.Count -gt 1 -or $mancanti -gt 0) {
$g = $suiPg[$k].Values | Group-Object | Sort-Object Count -Descending
$div += [pscustomobject]@{
nome = $k; valori = $vals.Count; mancanti = $mancanti
dominante = ("{0} ({1} PG)" -f $g[0].Name, $g[0].Count)
noto = $cvar.Contains($k)
}
}
}
$rep.Add("## 4. Divergenze nei file, fuori preset ($($div.Count))")
$rep.Add('')
$rep.Add('Quasi tutto qui e'' STATO, non impostazioni (tutorial visti, header chiusi, filtri del')
$rep.Add('Dungeon Journal): serve per capire cosa NON vale la pena di mettere nel preset.')
$rep.Add('')
$rep.Add('| chiave | valori distinti | assente su | valore piu'' comune |')
$rep.Add('|---|---|---|---|')
foreach ($d in ($div | Sort-Object -Property @{E = 'valori'; Descending = $true }, nome)) {
$rep.Add("| ``$($d.nome)`` | $($d.valori) | $($d.mancanti) PG | $($d.dominante) |")
}
$rep.Add('')
[System.IO.File]::WriteAllLines($Md, $rep, [System.Text.UTF8Encoding]::new($false))
# --- 5. a schermo: il riassunto e le due verifiche che contano ---------------------
Write-Host ''
Write-Host ("PG del dump : {0} (build {1}, {2})" -f $meta['pg'], $meta['build'], $meta['data'])
Write-Host ("CVar : {0} totali, {1} fuori dal default" -f $totali, $diversi)
Write-Host ("Preset : {0} chiavi per-PG, {1} account-wide" -f $preset.Count, $presetAcc.Count)
Write-Host ''
$fuori = @($preset.Keys | Where-Object { $cvar[$_] -and $preset[$_] -ne $cvar[$_].valore })
$inutili = @($preset.Keys | Where-Object { $cvar[$_] -and $preset[$_] -eq $cvar[$_].default })
$ignote = @($preset.Keys | Where-Object { -not $cvar.Contains($_) })
Write-Host ("Preset diverso dal PG del dump : {0}{1}" -f $fuori.Count, $(if ($fuori) { ' -> ' + ($fuori -join ', ') } else { '' }))
Write-Host ("Preset uguale al default : {0}{1}" -f $inutili.Count, $(if ($inutili) { ' -> ' + ($inutili -join ', ') } else { '' }))
Write-Host ("Preset che il client non ha : {0}{1}" -f $ignote.Count, $(if ($ignote) { ' -> ' + ($ignote -join ', ') } else { '' }))
Write-Host ("Candidati (fuori default) : {0}" -f $cand.Count)
Write-Host ("Salvate sul server : {0}" -f $srv.Count)
Write-Host ''
Write-Host "Report: $Md" -ForegroundColor Green# A che punto sono, su un raid, su TUTTE le classi.
#
# .\scripts\transmog-raid.ps1 t20 # per chiave tier
# .\scripts\transmog-raid.ps1 sargeras # per pezzo di nome del raid
# .\scripts\transmog-raid.ps1 # elenca i raid disponibili
#
# La metrica principale e' il set MYTHIC: completarlo sblocca da solo anche
# LFR/Normal/Heroic dello stesso tier (verificato sul dump, 30/30). Quindi il
# bersaglio vero di un raid e' il Mythic; le inferiori seguono e sono solo dettaglio.
# Sui tier vecchi senza Mythic la metrica ripiega sul totale di tutte le versioni.
#
# Sola lettura: non tocca il manifest.
param(
[Parameter(Position = 0)]
[string]$Raid,
[string]$Manifest = (Join-Path (Split-Path $PSScriptRoot -Parent) "transmog\manifest.json")
)
$ErrorActionPreference = "Stop"
if (-not (Test-Path $Manifest)) { Write-Host "manifest non trovato: $Manifest" -ForegroundColor Red; exit 1 }
$m = Get-Content -Raw -Encoding UTF8 $Manifest | ConvertFrom-Json
function ElencaRaid {
Write-Host ""
Write-Host "Raid disponibili (chiave -> nome):" -ForegroundColor Cyan
$m.tiers |
Select-Object @{n='Chiave';e={$_.key}}, @{n='Tier';e={$_.tier}}, @{n='Nome';e={$_.name}}, @{n='Exp';e={$_.exp}} |
Format-Table -AutoSize | Out-String -Width 200 | Write-Host
}
if (-not $Raid) { ElencaRaid; exit 0 }
# --- risolvi il tier: per chiave, per sigla Txx, o per pezzo di nome ------------
$q = $Raid.Trim()
# La chiave e' unica: se combacia esatta vince, cosi' 't6' non e' ambiguo con 't6-swp'.
$hit = @($m.tiers | Where-Object { $_.key -ieq $q })
if ($hit.Count -eq 0) {
$hit = @($m.tiers | Where-Object { $_.tier -ieq $q -or $_.name -imatch [regex]::Escape($q) })
}
if ($hit.Count -eq 0) {
Write-Host "nessun raid corrisponde a '$q'." -ForegroundColor Yellow; ElencaRaid; exit 1
}
if ($hit.Count -gt 1) {
Write-Host "'$q' e' ambiguo, corrisponde a piu' raid:" -ForegroundColor Yellow
$hit | ForEach-Object { Write-Host (" {0} {1}" -f $_.key, $_.name) }
Write-Host "usa la chiave esatta."; exit 1
}
$tier = $hit[0]
$key = $tier.key
# --- versioni e ordine di difficolta' ------------------------------------------
$rank = @{ lfr = 1; normal = 2; heroic = 3; mythic = 4 }
$hasMythic = [bool]($tier.versions.PSObject.Properties.Name -contains "mythic")
function OrdinaVersioni($props) {
$props | Sort-Object @{ Expression = { if ($rank.ContainsKey($_.Name)) { $rank[$_.Name] } else { 99 } } }, Name
}
# --- raccogli per classe -------------------------------------------------------
$rows = @()
$fatti = 0; $nClass = 0
$mHave = 0; $mTot = 0 # totale sulla metrica principale
foreach ($cls in $m.collected.PSObject.Properties) {
$t = $cls.Value.PSObject.Properties[$key]
if (-not $t) { continue } # classe che non esisteva a questo raid
$nClass++
$det = @()
$ovH = 0; $ovT = 0
foreach ($v in (OrdinaVersioni $t.Value.PSObject.Properties)) {
$ovH += $v.Value[0]; $ovT += $v.Value[1]
$det += ("{0} {1}/{2}" -f $v.Name, $v.Value[0], $v.Value[1])
}
if ($hasMythic -and $t.Value.PSObject.Properties["mythic"]) {
$pH = $t.Value.mythic[0]; $pT = $t.Value.mythic[1]
} else {
$pH = $ovH; $pT = $ovT
}
$mHave += $pH; $mTot += $pT
$done = ($pT -gt 0 -and $pH -eq $pT)
if ($done) { $fatti++ }
$rows += [pscustomobject]@{
Classe = $cls.Name
_done = [int]$done
_ratio = $(if ($pT -gt 0) { $pH / $pT } else { 1 })
Target = ("{0}/{1}" -f $pH, $pT)
Fatto = $(if ($done) { "OK" } else { "" })
Dettaglio = ($det -join " ")
}
}
# --- stampa --------------------------------------------------------------------
$etichetta = $(if ($hasMythic) { "Mythic" } else { "Totale" })
Write-Host ""
Write-Host ("{0} [{1}] ({2})" -f $tier.name, $key, $tier.exp) -ForegroundColor Cyan
Write-Host ("Metrica principale: {0} (le difficolta' inferiori seguono il Mythic)" -f $etichetta) -ForegroundColor DarkGray
$rows |
Sort-Object @{ Expression = '_done'; Descending = $true }, @{ Expression = '_ratio'; Descending = $true } |
Select-Object Classe, @{ n = $etichetta; e = { $_.Target } }, Fatto, Dettaglio |
Format-Table -AutoSize | Out-String -Width 200 | Write-Host
Write-Host ("--- {0}: {1}/{2} classi col {3} completo; pezzi {3} {4}/{5} ---" -f `
$tier.name, $fatti, $nClass, $etichetta, $mHave, $mTot) -ForegroundColor Cyan
Write-Host "(numeri account-wide / Warband)" -ForegroundColor DarkGray# Sincronizza la collezione cavalcature: aspetta il dump, rigenera mounts/manifest.json,
# scarica le icone mancanti, committa.
#
# .\scripts\mount-sync.ps1
# -> "fai /reload in gioco", aspetta, e da li' in poi fa tutto da solo
#
# -Subito usa il dump gia' sul disco invece di aspettarne uno nuovo
# -NoGit aggiorna il manifest e si ferma li'
# -NoIcone salta la risoluzione/scaricamento delle icone (solo dati)
# -MaxIcone N ne risolve al massimo N e rimanda il resto al giro dopo
#
# In gioco: /wmmount (calcola) poi /reload (scrive). L'ordine conta, e il comando non
# e' facoltativo: il dump non si ricalcola piu' da se' ne' al login ne' in chiusura.
# Se gli addon installati sono indietro rispetto al repo lo script li aggiorna da se'
# (vedi §0) e allora i reload diventano due: te lo chiede lui, uno alla volta.
#
# A differenza del transmog qui il manifest e' INTERAMENTE generato: non contiene
# nulla di redazionale, quindi si riscrive per intero a ogni giro. L'unico dato che
# non viene dal gioco e' il nome dell'icona di ogni mount (il client espone solo un
# fileID numerico, che sul web non si puo' usare): si risolve una volta su Wowhead e
# si conserva nel manifest, cosi' i giri successivi non ripetono la ricerca.
param(
[switch]$Subito,
[switch]$NoGit,
[switch]$NoIcone,
# Tetto di icone da risolvere in un giro. Serve al PRIMO popolamento, dove ce ne
# sono un migliaio: la ricerca su Wowhead e' una richiesta per mount, e un'unica
# sessione lunghissima che si interrompe a meta' butterebbe via tutto il lavoro,
# perche' i nomi si salvano solo scrivendo il manifest. Con un tetto lo script si
# rilancia finche' non ne restano: ogni giro scrive quello che ha risolto.
[int]$MaxIcone = 0,
# ⚠️ UNICA manopola redazionale di questo script, da aggiornare a ogni espansione:
# serve a capire quale stagione gladiator e' ancora in corso (vedi il taglio delle
# non piu' ottenibili). Il numero della stagione NON si scrive qui, si ricava dai
# dati: basta il nome dell'espansione.
[string]$EspansioneCorrente = "Midnight",
[int]$AttesaMax = 300,
# Ancorati alla posizione dello script, non alla cwd: lanciato da scripts\ il
# path relativo non risolverebbe e git finirebbe sul repo della cwd.
[string]$Manifest = (Join-Path (Split-Path $PSScriptRoot -Parent) "mounts\manifest.json"),
[string]$Wow = "C:\Program Files (x86)\World of Warcraft"
)
$ErrorActionPreference = "Stop"
$RepoRoot = Split-Path $PSScriptRoot -Parent
$IconDir = Join-Path $RepoRoot "public\icons\mount"
# Le immagini del modello le SCRIVE scripts/mount-images.mjs (vedi §5a); qui la
# cartella serve solo a contare quelle che mancano, quando Node non c'e'.
$ImgDir = Join-Path $RepoRoot "public\mounts"
# PS 5.1: senza TLS 1.2 esplicito le chiamate https falliscono, e la barra di
# avanzamento di Invoke-WebRequest rallenta ogni download di ordini di grandezza.
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$ProgressPreference = "SilentlyContinue"
$UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) WowManagerMountSync/1.0"
function Errore($msg) {
Write-Host "STOP: $msg" -ForegroundColor Red
exit 1
}
function TrovaDump {
# Cercato, non scritto a mano: il percorso contiene il nome dell'account.
return Get-ChildItem -Path (Join-Path $Wow "_retail_\WTF\Account") -Recurse `
-Filter "WowManagerMountDump.lua" -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -like "*SavedVariables*" } |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
}
# ⚠️ La data del FILE non dice se il dump e' fresco. L'addon calcola a client vivo
# (col comando o quando la collezione cambia), mentre WoW scrive il SavedVariables a
# ogni /reload e a ogni uscita: se dall'ultimo calcolo non e' cambiato nulla, riscrive
# gli stessi dati con una data di modifica nuova. Aspettare quella vorrebbe dire
# accettare un dump vecchio credendolo appena fatto. Il campo `generated` invece
# cambia solo quando il dump viene davvero ricalcolato.
function GeneratoIl($file) {
try {
$t = Get-Content -Raw -Encoding UTF8 $file -ErrorAction Stop
if ($t -match '\["generated"\]\s*=\s*"([^"]+)"') { return $Matches[1] }
} catch { } # letto mentre il gioco lo stava scrivendo: si riprova al giro dopo
return $null
}
# --- 0. gli addon nella cartella di gioco ---------------------------------------
# Il gioco legge la copia in Interface/AddOns, NON il .lua del repo: finche' non la
# si sovrascrive il dump esce col codice vecchio e il sync reincolla i dati vecchi
# senza che nulla lo segnali -- i dati restano coerenti, quindi nessuna guardia a
# valle se ne accorge. E' successo il 2026-08-04: l'addon installato era fermo alla
# versione che filtrava il solo "(PH)" e cinque cavalcature finte sono tornate in
# pagina. Percio' la copia la fa lo script, non la memoria di chi lo lancia.
#
# Gli addon da tenere allineati sono DUE: il modulo del dump e il lanciatore
# WowManagerDump, che dal momento in cui i moduli sono LoadOnDemand e' quello che
# registra /wmmount e li carica. Un lanciatore vecchio accanto a un modulo nuovo e'
# esattamente il tipo di disallineamento che questo blocco esiste per evitare.
$AddOnsDir = Join-Path $Wow "_retail_\Interface\AddOns"
$Addon = @(
@{ dir = "WowManagerDump"; files = @(
@{ src = "dump-launcher.lua"; dst = "WowManagerDump.lua" },
@{ src = "WowManagerDump.toc"; dst = "WowManagerDump.toc" }) },
@{ dir = "WowManagerMountDump"; files = @(
@{ src = "mount-dump.lua"; dst = "WowManagerMountDump.lua" },
@{ src = "WowManagerMountDump.toc"; dst = "WowManagerMountDump.toc" }) }
)
$Reload = 1
$Riavvio = @()
foreach ($a in $Addon) {
$dir = Join-Path $AddOnsDir $a.dir
$nuovo = -not (Test-Path $dir)
if ($nuovo) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
$copiati = @()
$toc = $false
foreach ($f in $a.files) {
$src = Join-Path $PSScriptRoot $f.src
$dst = Join-Path $dir $f.dst
if (-not (Test-Path $src)) { continue }
if ((Test-Path $dst) -and (Get-FileHash $src).Hash -eq (Get-FileHash $dst).Hash) { continue }
Copy-Item $src $dst -Force
$copiati += $f.dst
if ($f.dst -like "*.toc") { $toc = $true }
}
if ($copiati.Count -gt 0) {
Write-Host ("addon aggiornato nella cartella di gioco: {0}/{1}" -f $a.dir, ($copiati -join ", ")) -ForegroundColor Yellow
# Si puo' sovrascrivere a gioco aperto, ma il modulo gia' caricato in questa
# sessione resta quello vecchio: il dump buono e' quello del secondo giro.
$Reload = 2
}
# Il .toc lo legge il client all'AVVIO: un addon nuovo, o un .toc cambiato (per
# esempio LoadOnDemand aggiunto), il /reload non li vede. Non e' un errore -- fino
# al riavvio ogni modulo si registra il comando da se' e il sync funziona
# ugualmente -- ma va detto, o il risparmio non entra mai in vigore.
if ($nuovo -or $toc) { $Riavvio += $a.dir }
}
if ($Riavvio.Count -gt 0) {
Write-Host ("addon da attivare col RIAVVIO del client (non basta /reload): {0}" -f ($Riavvio -join ", ")) -ForegroundColor Yellow
}
# --- 1. il dump ----------------------------------------------------------------
$dump = TrovaDump
if (-not $dump) { Errore "WowManagerMountDump.lua non trovato sotto $Wow. L'addon e' installato e il client riavviato?" }
# -Subito prende il dump gia' sul disco, che pero' e' stato scritto PRIMA della copia
# qui sopra: sarebbe esattamente il caso che questo blocco esiste per evitare.
if ($Subito -and $Reload -gt 1) {
Errore "l'addon era indietro e l'ho appena aggiornato: il dump sul disco e' ancora del codice vecchio. Rilancia senza -Subito."
}
if (-not $Subito) {
for ($giro = 1; $giro -le $Reload; $giro++) {
$prima = GeneratoIl $dump.FullName
Write-Host ""
# Si chiede sempre il comando PRIMA del /reload, anche sui giri di scarto: il
# dump non si ricalcola piu' a ogni reload, quindi un /reload da solo non
# cambierebbe `generated` e lo script resterebbe li' ad aspettare. Il comando
# calcola, il /reload scrive.
$cmd = "/wmmount poi /reload"
if ($Reload -gt 1) {
Write-Host (" In gioco: {0} ({1} di {2})" -f $cmd, $giro, $Reload) -ForegroundColor Cyan
} else {
Write-Host (" In gioco: {0}" -f $cmd) -ForegroundColor Cyan
}
Write-Host (" (aspetto un dump nuovo, max {0}s - Ctrl+C per annullare)" -f $AttesaMax)
$scaduto = (Get-Date).AddSeconds($AttesaMax)
$ora = $prima
while ((Get-Date) -lt $scaduto) {
Start-Sleep -Seconds 2
$dump = TrovaDump
$ora = GeneratoIl $dump.FullName
if ($ora -and $ora -ne $prima) { break }
}
if (-not $ora -or $ora -eq $prima) {
Errore ("nessun dump RICALCOLATO entro $AttesaMax secondi (generated fermo a '{0}'). " -f $prima) `
+ "Il /reload da solo riscrive gli stessi dati: serve /wmmount prima. Oppure -Subito per prendere quello che c'e'."
}
if ($giro -lt $Reload) {
Write-Host " dump di scarto (codice vecchio), ne aspetto un altro." -ForegroundColor DarkGray
} else {
Write-Host " dump ricevuto." -ForegroundColor Green
}
}
}
$lua = Get-Content -Raw -Encoding UTF8 $dump.FullName
Write-Host ("dump: {0}" -f $dump.LastWriteTime)
# --- 2. guardie ----------------------------------------------------------------
# Un dump preso prima che il client abbia caricato la collezione elenca tutte le
# mount ma con nessuna presa: e' internamente coerente, quindi solo il campo
# `sospetto` (che scrive l'addon) lo distingue da uno buono.
if ($lua -match '\["sospetto"\]\s*=\s*"([^"]+)"') { Errore "il dump si e' auto-segnalato: $($matches[1])" }
$presi = 0
$totale = 0
if ($lua -match '\["presi"\]\s*=\s*(\d+)') { $presi = [int]$matches[1] }
if ($lua -match '\["totale"\]\s*=\s*(\d+)') { $totale = [int]$matches[1] }
if ($presi -le 0) { Errore "il dump dice $presi mount collezionate: collezione non caricata" }
if ($totale -le 0) { Errore "il dump non elenca nessuna mount" }
# --- 3. estrarre i campi --------------------------------------------------------
function Blocco($nome) {
$re = '\["' + $nome + '"\]\s*=\s*"((?:[^"\\]|\\.)*)"'
if ($lua -notmatch $re) { Errore "campo $nome assente dal dump" }
# Un passaggio solo, non tre Replace in cascata: con '\\' trattato per ultimo un
# backslash escapato seguito da n diventerebbe un a capo invece di \ + n.
#
# ⚠️ Qui convivono DUE livelli di \n e vanno distinti: quelli STRUTTURALI del JSON
# (uno per mount, messi dal dump) devono tornare a capo veri, mentre quelli DENTRO
# srcText -- il testo di provenienza e' multiriga -- nel file sono \\n e devono
# restare l'escape \n del JSON. Se ne occupa l'ordine: '\\' viene consumato per
# primo come coppia, quindi la n che segue resta lettera.
return [regex]::Replace($matches[1], '\\(.)', {
param($m)
switch ($m.Groups[1].Value) {
'n' { "`n" }
't' { "`t" }
'r' { "`r" }
default { $m.Groups[1].Value }
}
})
}
$mounts = Blocco "mountsJson"
$sorgenti = Blocco "sorgentiJson"
$build = ""
if ($lua -match '\["build"\]\s*=\s*"([^"]*)"') { $build = $matches[1] }
# Terza guardia, e non e' teorica: il 2026-08-04 un dump e' uscito con `spell` a ZERO
# su tutte e 1617 le voci -- nomi, provenienze e collezionate giuste, `presi` a 699,
# `sospetto` vuoto. Cioe' ha passato tutte le guardie di sopra. Ma la spell e' la
# chiave con cui si ritrova l'icona nel manifest precedente: a zero, la cache non
# aggancia niente, tutte le mount risultano "senza icona", e al §5c quelle 1184 icone
# diventano orfane e vengono CANCELLATE. Un dump buono ne ha zero, quindi la soglia
# puo' essere stretta.
$conSpell = ([regex]::Matches($mounts, '"spell":')).Count
$spellZero = ([regex]::Matches($mounts, '"spell":0[,}]')).Count
if ($conSpell -gt 0 -and $spellZero -gt [Math]::Max(20, $conSpell * 0.05)) {
Errore "$spellZero mount su $conSpell hanno spell 0: il client non ha dato gli spellID. Rifai /reload e rilancia."
}
# --- 4bis. nomi degli oggetti usati come prezzo ----------------------------------
# Il dump lascia un segnaposto {item:ID} dove il vendor vuole un OGGETTO invece di
# una valuta (Mark of Honor, Drowned Mana, Essence of the Storm...). Il nome non si
# chiede al client -- GetItemInfo tace finche' l'oggetto non e' in cache, e per roba
# mai vista non ci arriva mai: misurato, 31 nomi su 32 mancanti anche dopo aver
# chiesto il caricamento. Qui si risolve su Wowhead una volta sola e si conserva nel
# manifest (blocco `items`), che e' anche la cache dei giri successivi.
# Il manifest precedente si legge QUI e non piu' avanti: e' la cache sia dei nomi
# degli oggetti sia delle icone, e questo blocco viene prima di quello delle icone.
$vecchio = $null
if (Test-Path $Manifest) { $vecchio = Get-Content -Raw -Encoding UTF8 $Manifest | ConvertFrom-Json }
$itemNomi = @{}
if ($vecchio -and $vecchio.items) {
foreach ($p in $vecchio.items.PSObject.Properties) { $itemNomi[$p.Name] = [string]$p.Value }
}
# La risoluzione vera sta piu' sotto (4c), dopo che $righe esiste ed e' stata
# filtrata: qui si carica solo la cache.
# --- 4. icone -------------------------------------------------------------------
# Nome icona gia' noto dal manifest precedente: si riusa, cosi' ogni mount si cerca
# una volta sola nella vita del repo e i giri successivi toccano solo le novita'.
$noti = @{}
# Vincoli di classe e razza gia' cercati: la stringa vuota vale "cercato, nessun
# requisito" ed e' diversa da "mai cercato" (assente), altrimenti le mount senza vincoli
# si ricercherebbero a ogni giro -- che sono il 96%.
# ⚠️ Qui non si RISOLVONO (lo fa mount-classes.mjs al §5b): si riportano avanti, perche'
# il dump non li conosce e il manifest precedente e' la loro unica cache.
$notiClasse = @{}
$notiRazza = @{}
if ($vecchio) {
foreach ($m in $vecchio.mounts) {
if ($m.icon) { $noti[[string]$m.spell] = [string]$m.icon }
if ($null -ne $m.class) { $notiClasse[[string]$m.spell] = [string]$m.class }
if ($null -ne $m.race) { $notiRazza[[string]$m.spell] = [string]$m.race }
}
}
# Il nome dell'icona, che il client non da' (espone solo un fileID numerico).
#
# ⚠️ IL VINCOLO DI CLASSE NON SI RISOLVE PIU' QUI. Lo faceva, con la sola riga
# "Requires <classe>" del blocco `wowhead-tooltip-requirements`, e prendeva 19 mount su
# 52: restavano fuori TUTTE le cavalcature di classe di Legion, il cui vincolo non sta
# sulla spell ma sulla quest che le consegna. Ci vogliono tre segnali diversi, uno dei
# quali e' una seconda pagina da spogliare: sta tutto in scripts/mount-classes.mjs,
# chiamato al §5b. Qui la classe si porta avanti e basta (la cache e' il manifest).
#
# Ripiego per l'icona: la pagina della spell, dove il nome sta nell'og:image -- lo
# stesso trucco usato per gli avatar degli addon.
function DatiDiSpell($spellID) {
$icona = $null
try {
$r = Invoke-RestMethod -Uri "https://nether.wowhead.com/tooltip/spell/$spellID" `
-Headers @{ "User-Agent" = $UA } -TimeoutSec 20
if ($r.icon) { $icona = [string]$r.icon }
} catch { }
if (-not $icona) {
try {
$h = Invoke-WebRequest -Uri "https://www.wowhead.com/spell=$spellID" `
-Headers @{ "User-Agent" = $UA } -TimeoutSec 20 -UseBasicParsing
if ($h.Content -match 'icons/large/([a-z0-9_]+)\.jpg') { $icona = $matches[1] }
} catch { }
}
return $icona
}
function ScaricaIcona($nome) {
$file = Join-Path $IconDir "$nome.jpg"
if (Test-Path $file) { return $true }
try {
Invoke-WebRequest -Uri "https://wow.zamimg.com/images/wow/icons/large/$nome.jpg" `
-Headers @{ "User-Agent" = $UA } -OutFile $file -TimeoutSec 30 -UseBasicParsing
return $true
} catch {
if (Test-Path $file) { Remove-Item $file -Force }
return $false
}
}
if (-not (Test-Path $IconDir)) { New-Item -ItemType Directory -Force -Path $IconDir | Out-Null }
$righe = $mounts -split "`n"
# --- 4b. via le cavalcature non piu' ottenibili ---------------------------------
# Tre regole, in ordine di autorevolezza. Solo la prima viene dal gioco.
#
# 1. "Legacy": il client stesso, al posto della provenienza, scrive quella riga
# (Amani War Bear, i Qiraji Battle Tank dell'evento, i nether drake dei gladiator
# piu' vecchi). E' l'unica fonte macchina che esista: Wowhead segnala gli stessi
# casi e nulla di piu'.
# 2. Gladiator di stagione passata: il client NON li marca, quindi il criterio e'
# nostro. Non si tolgono per nome -- cancellerebbe anche la stagione in corso,
# che si prende ancora -- ma si tiene la sola stagione PIU' RECENTE
# dell'espansione corrente, ricavata dai dati stessi: quando esce la stagione
# nuova la vecchia decade da se', senza toccare lo script.
# 3. Challenge mode: una sola mount (Challenger's War Yeti).
# ⚠️ La regola e' ancorata a "Achievement: Challenge ", NON alla parola
# "Challenge": "Challenger's Cache" compare nella provenienza di 7 mount da
# mitica+ che si prendono benissimo, e una regola larga le cancellerebbe.
#
# Il Trading Card Game resta DENTRO per scelta: le carte si comprano ancora
# sull'usato, quindi non sono "non piu' ottenibili" ma solo scomode.
#
# Si filtra QUI e non nel dump di proposito: il dump resta lo specchio fedele del
# diario, e siccome il taglio viene prima della risoluzione delle icone, le escluse
# non vengono nemmeno cercate su Wowhead.
# Stagione piu' recente dell'espansione corrente, letta dai dati.
$stagioneMax = 0
foreach ($l in $righe) {
if ($l -match ('Gladiator: ' + [regex]::Escape($EspansioneCorrente) + ' Season (\d+)')) {
if ([int]$matches[1] -gt $stagioneMax) { $stagioneMax = [int]$matches[1] }
}
}
function MotivoEsclusione($parti) {
if ($parti -contains 'Legacy') { return 'Legacy (lo dice il client)' }
foreach ($p in $parti) {
if ($p -match '^Achievement: Challenge ') { return 'challenge mode' }
if ($p -match 'Gladiator: (.+?) Season (\d+)$') {
$exp = $matches[1]
$st = [int]$matches[2]
if (-not ($exp -eq $EspansioneCorrente -and $st -eq $stagioneMax)) {
return 'gladiator, stagione passata'
}
}
}
return $null
}
$tolte = @()
$motivi = @{}
$tenute = New-Object System.Collections.ArrayList
foreach ($l in $righe) {
$motivo = $null
if ($l -match '"srcText":"((?:[^"\\]|\\.)*)"') {
# srcText porta gli a capo come \n letterali: si spezza su quelli e si
# ragiona per RIGA, non per sottostringa.
$motivo = MotivoEsclusione ($matches[1] -split '\\n')
}
if ($motivo) {
if ($l -match '"name":"((?:[^"\\]|\\.)*)"') { $tolte += $matches[1] }
$motivi[$motivo] = ($motivi[$motivo] + 1)
} else {
[void]$tenute.Add($l)
}
}
$righe = $tenute.ToArray()
# L'ultima riga rimasta non deve portare la virgola: se le tolte erano in fondo,
# il JSON finirebbe con una virgola pendente.
for ($i = $righe.Count - 1; $i -ge 0; $i--) {
if ($righe[$i].TrimEnd().EndsWith("},")) {
$righe[$i] = $righe[$i].TrimEnd().TrimEnd(",")
break
}
if ($righe[$i].TrimEnd().EndsWith("}")) { break }
}
if ($tolte.Count -gt 0) {
Write-Host ("non piu' ottenibili, escluse: {0}" -f $tolte.Count)
foreach ($k in ($motivi.Keys | Sort-Object)) { Write-Host (" {0,-28} {1}" -f $k, $motivi[$k]) }
}
# --- 4c. i nomi degli oggetti-prezzo --------------------------------------------
# ⚠️ Deve stare QUI, non prima: $righe nasce poche righe sopra. Al primo tentativo
# questo blocco era sopra la sua creazione, girava a vuoto senza dire niente e 66
# mount finivano nel manifest col segnaposto grezzo dentro.
$daRisolvere = New-Object System.Collections.ArrayList
foreach ($l in $righe) {
foreach ($mm in [regex]::Matches($l, '\{item:(\d+)\}')) {
$id = $mm.Groups[1].Value
if (-not $itemNomi.ContainsKey($id) -and -not $daRisolvere.Contains($id)) { [void]$daRisolvere.Add($id) }
}
}
if ($daRisolvere.Count -gt 0 -and -not $NoIcone) {
Write-Host ("oggetti-prezzo da risolvere: {0}" -f $daRisolvere.Count)
foreach ($id in $daRisolvere) {
$nome = ""
try {
$r = Invoke-RestMethod -Uri "https://nether.wowhead.com/tooltip/item/$id" `
-Headers @{ "User-Agent" = $UA } -TimeoutSec 20
if ($r.name) { $nome = [string]$r.name }
} catch { }
$itemNomi[$id] = $nome
Start-Sleep -Milliseconds 60
}
}
# Sostituzione: senza nome resta il solo numero, come prima.
for ($i = 0; $i -lt $righe.Count; $i++) {
if ($righe[$i] -notmatch '\{item:') { continue }
$righe[$i] = [regex]::Replace($righe[$i], '\{item:(\d+)\}', {
param($m)
$n = $itemNomi[$m.Groups[1].Value]
if ($n) { ($n -replace '\\', '\\\\') -replace '"', '\"' } else { "" }
})
}
$nuoveIcone = 0
$senzaIcona = 0
$conClasse = 0
$daCercare = 0
if (-not $NoIcone) {
foreach ($l in $righe) {
if ($l -match '"spell":(\d+)' -and -not $noti[$matches[1]]) { $daCercare++ }
}
if ($daCercare -gt 0) { Write-Host ("icone da cercare su Wowhead: {0}" -f $daCercare) }
}
$fatte = 0
$rimandate = 0
for ($i = 0; $i -lt $righe.Count; $i++) {
$l = $righe[$i].TrimEnd()
if ($l -notmatch '"spell":(\d+)') { continue }
$spell = $matches[1]
$icona = $noti[$spell]
# Classe e razza: solo cache, a risolverle e' mount-classes.mjs (§5b).
$classe = $notiClasse[$spell]
$razza = $notiRazza[$spell]
if (-not $icona -and -not $NoIcone -and $spell -ne "0") {
if ($MaxIcone -gt 0 -and $fatte -ge $MaxIcone) {
# ⚠️ Oltre il tetto si SALTA LA RICERCA, non la riga: con un `continue`
# qui la mount perdeva anche l'icona che gia' si conosceva, e siccome la
# cache delle icone e' il manifest stesso, il giro dopo se le ritrovava
# tutte da ricercare. Si scrive quel che si sa e si rimanda il resto.
$rimandate++
} else {
$icona = DatiDiSpell $spell
$fatte++
if ($fatte % 25 -eq 0) { Write-Host (" ...{0}/{1}" -f $fatte, $daCercare) }
Start-Sleep -Milliseconds 60 # gentile con Wowhead
}
}
if ($classe) { $conClasse++ }
if ($icona) {
if (-not $NoIcone) {
if (ScaricaIcona $icona) {
if (-not $noti[$spell]) { $nuoveIcone++ }
} else {
$icona = $null
}
}
}
if (-not $icona) { $senzaIcona++ }
# Classe e razza note si riportano sulla riga nuova: il dump non le sa (emette
# `class` a null e `race` per niente) e il manifest precedente e' la loro unica
# cache -- senza questo passaggio ogni sync le butterebbe via e mount-classes.mjs
# dovrebbe rifare 1527 richieste.
$coda = ""
if ($l.EndsWith(",")) { $coda = ","; $l = $l.Substring(0, $l.Length - 1) }
$l = $l -replace ',"class":(?:null|"[^"]*")', ''
$l = $l -replace ',"race":(?:null|"[^"]*")', ''
$agg = ""
if ($icona) { $agg += ',"icon":"{0}"' -f $icona }
if ($null -ne $classe) { $agg += ',"class":{0}' -f $(if ($classe) { '"' + $classe + '"' } else { '""' }) }
if ($null -ne $razza) { $agg += ',"race":{0}' -f $(if ($razza) { '"' + $razza + '"' } else { '""' }) }
$righe[$i] = $l.Substring(0, $l.Length - 1) + $agg + "}" + $coda
}
# --- 5. scrivere il manifest ----------------------------------------------------
# I conteggi si RICALCOLANO sulle righe rimaste, non si prendono dal dump: quelli del
# dump contano anche le non piu' ottenibili, e il totale in pagina non tornerebbe.
$presi, $totale = 0, 0
foreach ($l in $righe) {
if ($l -match '"got":(\d)') { $totale++; if ($matches[1] -eq "1") { $presi++ } }
}
$oggi = Get-Date -Format "yyyy-MM-dd"
$testo = @(
"{",
' "_meta": {',
(' "generated": "{0}",' -f $oggi),
(' "build": "{0}",' -f $build),
(' "collected": {0},' -f $presi),
(' "total": {0},' -f $totale),
(' "excluded": {0}' -f $tolte.Count),
" },",
(' "sources": {0},' -f $sorgenti),
# Nomi degli oggetti usati come prezzo: gia' sostituiti dentro `srcText`, ma
# tenuti anche qui perche' sono la CACHE -- senza, ogni sync li ricercherebbe.
(' "items": {{ {0} }},' -f (($itemNomi.Keys | Sort-Object { [int]$_ } | ForEach-Object {
'"{0}": "{1}"' -f $_, ((($itemNomi[$_] -replace '\\', '\\') -replace '"', '\"'))
}) -join ", ")),
(' "mounts": {0}' -f ($righe -join "`n")),
"}"
) -join "`n"
try { $null = $testo | ConvertFrom-Json } catch { Errore "il risultato non e' JSON valido: $_" }
# Guardia generale, complementare a quella sugli spell: il manifest E' la cache delle
# icone (e di classe/razza), quindi un giro non deve MAI perderne in massa. Vale
# qualunque sia la causa, anche una che oggi non sappiamo prevedere -- la si prende
# guardando il risultato invece del sintomo. Sotto la soglia si ferma PRIMA di
# scrivere, cosi' non c'e' niente da ripristinare: senza questo controllo il §5c
# avrebbe poi cancellato dal disco anche i file delle icone rimaste orfane.
$iconeVecchie = 0
if ($vecchio) { $iconeVecchie = @($vecchio.mounts | Where-Object { $_.icon }).Count }
$iconeNuove = ([regex]::Matches($testo, '"icon":"')).Count
if ($iconeVecchie -gt 50 -and $iconeNuove -lt ($iconeVecchie - 10)) {
Errore "il manifest passerebbe da $iconeVecchie icone a $iconeNuove : non scrivo. Dump sospetto, rifai /reload e rilancia."
}
$primaPrese = 0
if (Test-Path $Manifest) {
$v = Get-Content -Raw -Encoding UTF8 $Manifest | ConvertFrom-Json
$primaPrese = [int]$v._meta.collected
} else {
New-Item -ItemType Directory -Force -Path (Split-Path $Manifest -Parent) | Out-Null
}
[System.IO.File]::WriteAllText($Manifest, $testo, (New-Object System.Text.UTF8Encoding($false)))
$delta = $presi - $primaPrese
Write-Host ("manifest: {0}/{1} mount collezionate ({2:+#;-#;0} rispetto a prima)" -f $presi, $totale, $delta) -ForegroundColor Green
if ($nuoveIcone -gt 0) { Write-Host ("icone scaricate: {0}" -f $nuoveIcone) }
if ($conClasse -gt 0) { Write-Host ("mount con vincolo di classe (dal manifest precedente): {0}" -f $conClasse) }
if ($rimandate -gt 0) {
Write-Host ("icone rimandate al prossimo giro: {0} (tetto -MaxIcone {1})" -f $rimandate, $MaxIcone) -ForegroundColor Cyan
Write-Host " rilancia con -Subito per continuare da dove e' rimasto."
} elseif ($senzaIcona -gt 0) {
Write-Host ("senza icona: {0} (la card mostra l'iniziale)" -f $senzaIcona) -ForegroundColor Yellow
}
# --- 5bis. i display gia' visti -------------------------------------------------
# ⚠️ Il `display` che finisce nel manifest e' quello dell'ULTIMO dump, e per qualche
# mount CAMBIA da se': Ash'adar ha una forma solare e una lunare (decide l'ora in cui
# gira il dump), altre seguono la personalizzazione attiva. Il manifest e' macchina-
# generato e si riscrive per intero, quindi la forma di prima ci sparirebbe -- e il suo
# render diventerebbe un file senza padrone, che "Verifica dati" segnala e
# mount-images.mjs cancella, salvo riscaricarlo al ribaltamento dopo. Misurato: e'
# gia' capitato a 5 mount, due volte alla stessa (Ash'adar), sempre a mano.
#
# Qui si tiene l'elenco DUREVOLE dei display visti per ogni mount, in un file suo
# accanto al manifest -- non dentro, perche' li' al primo sync sparirebbe, che e' la
# regola gia' scritta per ogni dato non macchina-generato di questo file.
# Ci finiscono solo le mount con piu' di una forma: oggi 5 righe, non 1565.
$NotiFile = Join-Path (Split-Path $Manifest -Parent) "display-noti.json"
$storia = @{}
if (Test-Path $NotiFile) {
$j = Get-Content -Raw -Encoding UTF8 $NotiFile | ConvertFrom-Json
foreach ($p in $j.PSObject.Properties) { $storia[$p.Name] = @($p.Value | ForEach-Object { [int]$_ }) }
}
$primaForme = $storia.Count
# Il display di ieri: $vecchio e' il manifest PRIMA di questa riscrittura, letto in
# memoria al §4 -- e' l'unico posto in cui la forma precedente esiste ancora.
$displayPrima = @{}
if ($vecchio) {
foreach ($m in $vecchio.mounts) { if ($m.display) { $displayPrima[[string]$m.id] = [int]$m.display } }
}
$vivi = @{}
$displayOra = @{}
foreach ($l in $righe) {
if ($l -notmatch '"id":(\d+),"spell":\d+,"display":(\d+)') { continue }
$id = $matches[1]
$ora = [int]$matches[2]
if ($ora -eq 0) { continue }
$vivi[$id] = $true
$displayOra[$id] = $ora
$viste = @()
if ($storia.ContainsKey($id)) { $viste = $storia[$id] }
if ($displayPrima.ContainsKey($id)) { $viste = @($viste) + $displayPrima[$id] }
$viste = @($viste) + $ora
$viste = @($viste | Where-Object { $_ -gt 0 } | Sort-Object -Unique)
# Una forma sola non e' una storia: la riga si scrive solo quando la mount ribalta.
if ($viste.Count -gt 1) { $storia[$id] = $viste } elseif ($storia.ContainsKey($id)) { $storia.Remove($id) }
}
# Una mount uscita dal manifest (esclusa, o sparita dal diario) porta via la sua storia:
# da li' in poi i suoi render sono orfani veri e vanno cancellati davvero.
foreach ($k in @($storia.Keys)) { if (-not $vivi.ContainsKey($k)) { $storia.Remove($k) } }
$chiaviNoti = @($storia.Keys | Sort-Object { [int]$_ })
if ($chiaviNoti.Count -gt 0) {
$testoNoti = "{`n" + (($chiaviNoti | ForEach-Object {
' "{0}": [{1}]' -f $_, (($storia[$_] | Sort-Object) -join ", ")
}) -join ",`n") + "`n}`n"
} else {
$testoNoti = "{}`n"
}
[System.IO.File]::WriteAllText($NotiFile, $testoNoti, (New-Object System.Text.UTF8Encoding($false)))
if ($storia.Count -gt 0) {
$nuoveForme = $storia.Count - $primaForme
if ($nuoveForme -gt 0) {
Write-Host ("mount che cambiano modello: {0} ({1} nuove in questo giro)" -f $storia.Count, $nuoveForme) -ForegroundColor Cyan
} else {
Write-Host ("mount che cambiano modello: {0}" -f $storia.Count)
}
}
# --- 5ab. i due passi che vogliono Node -----------------------------------------
# ATTENZIONE: su QUESTA postazione Node non c'e', ed e' la norma, non un intoppo. Il
# repo si lavora da due macchine con capacita' disgiunte: qui c'e' WoW (quindi i dump
# e i sync dei dati) e non c'e' Node; sull'altra c'e' Node per il dev server ma non
# c'e' WoW, quindi un sync non potrebbe nemmeno partire.
#
# Senza questa guardia lo script MORIVA qui: `& node` su un comando inesistente alza
# una CommandNotFoundException che, con $ErrorActionPreference = "Stop", porta via
# anche il §6 -- cioe' il manifest restava aggiornato ma non committato, in silenzio.
#
# I due .mjs non hanno bisogno del gioco: leggono solo mounts/manifest.json e il web.
# Quindi il pendente si smaltisce dall'altra postazione, dopo un git pull.
$HaNode = [bool](Get-Command node -ErrorAction SilentlyContinue)
# --- 5b. vincolo di classe ------------------------------------------------------
# Lo risolve scripts/mount-classes.mjs sul manifest appena scritto, e tocca solo le
# mount che il campo `class` non ce l'hanno ancora (le altre sono cache). Tre segnali
# diversi su Wowhead piu' le sedi di classe di Legion: vedi il commento in testa allo
# script. `--rifai` le ricontrolla tutte, ed e' una richiesta per mount.
if (-not $NoIcone -and $HaNode) {
& node (Join-Path $PSScriptRoot "mount-classes.mjs")
if ($LASTEXITCODE -ne 0) { Write-Host " (lo script delle classi ha segnalato un errore: i vincoli restano quelli di prima)" -ForegroundColor Yellow }
}
# --- 5a. immagini del modello ---------------------------------------------------
# Le fa scripts/mount-images.mjs, che legge il manifest appena scritto: render
# ufficiale Blizzard (600x600) con ripiego sulla miniatura di Wowhead, conversione in
# webp e pulizia delle orfane. E' in JS perche' il render arriva in JPEG e serve un
# convertitore: `sharp`, che il repo ha gia'. Il file su disco e' la cache, quindi
# scarica solo le novita' e un'interruzione non fa perdere lavoro.
if (-not $NoIcone -and $HaNode) {
Write-Host "immagini del modello..."
& node (Join-Path $PSScriptRoot "mount-images.mjs")
if ($LASTEXITCODE -ne 0) { Write-Host " (lo script immagini ha segnalato un errore: le immagini restano quelle di prima)" -ForegroundColor Yellow }
}
# Senza Node si dice COSA resta indietro, non un generico "saltato": quasi sempre e'
# zero (le due cose cambiano solo quando una patch aggiunge cavalcature), e allora la
# trasferta sull'altra macchina non serve affatto.
if (-not $NoIcone -and -not $HaNode) {
$classeDaFare = 0
$imgDaFare = 0
foreach ($l in $righe) {
if ($l -match '"spell":(\d+)' -and $l -notmatch '"class":') { $classeDaFare++ }
if ($l -match '"display":(\d+)' -and -not (Test-Path (Join-Path $ImgDir ($matches[1] + ".webp")))) { $imgDaFare++ }
}
# Anche le forme alternative sono immagini da avere: mount-images.mjs le scarica e
# non le considera orfane, quindi il pendente le deve contare o direbbe "niente da
# fare" mentre un render manca.
foreach ($k in $storia.Keys) {
foreach ($d in $storia[$k]) {
# Il display corrente l'ha gia' contato il giro qui sopra.
if ($d -eq $displayOra[$k]) { continue }
if (-not (Test-Path (Join-Path $ImgDir ("$d.webp")))) { $imgDaFare++ }
}
}
Write-Host "node non installato qui: vincoli di classe e immagini del modello non toccati." -ForegroundColor Yellow
if ($classeDaFare -gt 0 -or $imgDaFare -gt 0) {
Write-Host (" in sospeso: {0} mount da controllare, {1} immagini da scaricare." -f $classeDaFare, $imgDaFare) -ForegroundColor Yellow
Write-Host " dalla postazione col dev server, un comando solo:"
Write-Host " .\scripts\node-pending.ps1"
Write-Host " (fa git pull, i passi in JS che servono, commit e push)"
} else {
Write-Host " niente in sospeso: nessuna mount nuova da controllare." -ForegroundColor Green
}
}
# --- 5c. icone rimaste senza padrone ----------------------------------
# Una mount esclusa (o sparita dal diario) lascia il suo file icona nel repo. Si
# cancella solo dopo un giro COMPLETO di icone: con -NoIcone o col tetto -MaxIcone
# ci sono mount che non hanno ancora il campo icon, e il loro file sembrerebbe
# orfano pur non essendolo. Chi condivide l'icona con una mount rimasta si salva da
# se': il confronto e' sui nomi ancora referenziati.
# ATTENZIONE: le icone stanno in public/icons/mount, che appartiene al REPO e non al
# manifest -- quindi con -Manifest puntato altrove (una copia di prova) questa pulizia
# cancellerebbe lo stesso i file veri, confrontandoli con un manifest che non e' il
# loro. E' successo davvero durante un test: 18 icone tolte dal repo. Se il manifest
# non e' quello del repo, la pulizia non ha senso e si salta.
$ManifestRepo = [IO.Path]::GetFullPath((Join-Path $RepoRoot "mounts\manifest.json"))
$suoManifest = ([IO.Path]::GetFullPath($Manifest) -eq $ManifestRepo)
$orfane = 0
if (-not $NoIcone -and $rimandate -eq 0 -and $suoManifest) {
$usate = @{}
foreach ($l in $righe) { if ($l -match '"icon":"([^"]+)"') { $usate[$matches[1]] = $true } }
foreach ($f in Get-ChildItem $IconDir -Filter *.jpg -ErrorAction SilentlyContinue) {
if (-not $usate[$f.BaseName]) { Remove-Item $f.FullName -Force; $orfane++ }
}
if ($orfane -gt 0) { Write-Host ("icone orfane rimosse: {0}" -f $orfane) }
# Le immagini orfane le toglie mount-images.mjs, che gia' conosce l'elenco dei
# displayID vivi: qui resterebbe la stessa logica scritta due volte.
}
# --- 6. git ---------------------------------------------------------------------
if ($NoGit) {
Write-Host "-NoGit: mi fermo qui. Committa a mano quando vuoi."
exit 0
}
Push-Location $RepoRoot
try {
git add -- "mounts/manifest.json" "public/icons/mount" "public/mounts"
git diff --cached --quiet
if ($LASTEXITCODE -eq 0) {
Write-Host "niente di nuovo da committare." -ForegroundColor Yellow
exit 0
}
# Non si committa il lavoro altrui: se ci sono altri file modificati ci si ferma.
$sporchi = @(git status --porcelain | ForEach-Object { $_.Substring(3) } |
Where-Object { $_ -ne "mounts/manifest.json" -and $_ -notlike "public/icons/mount/*" -and $_ -notlike "public/mounts/*" })
if ($sporchi.Count -gt 0) {
Write-Host "altri file modificati, non committo da solo:" -ForegroundColor Yellow
$sporchi | ForEach-Object { Write-Host " $_" }
Write-Host "manifest aggiornato lo stesso: committa tu."
exit 0
}
if ($delta -eq 0) {
git commit -q -m "Mount: dati della collezione aggiornati"
} else {
git commit -q -m ("Mount: collezione aggiornata, {0:+#;-#;0} mount" -f $delta)
}
# ⚠️ L'esito del push si GUARDA. Prima si stampava «committato e pushato» comunque, quindi
# una rete giu' o un token scaduto passavano inosservati: il commit restava locale, e
# siccome e' il push a far ripartire GitHub Actions, il sito continuava a mostrare i dati
# vecchi con la data «sync» ferma. Il commit e' fatto e resta buono: si dice solo la verita'
# su cosa manca.
git push -q
if ($LASTEXITCODE -ne 0) {
Write-Host "committato, ma il PUSH E' FALLITO (git ha risposto $LASTEXITCODE)." -ForegroundColor Red
Write-Host " il commit e' in locale: rilancia 'git push' quando la rete torna." -ForegroundColor Yellow
exit 1
}
Write-Host "committato e pushato." -ForegroundColor Green
} finally { Pop-Location }# Sincronizza la collezione transmog: aspetta il dump, aggiorna il manifest, committa.
#
# .\scripts\transmog-sync.ps1
# -> "fai /reload in gioco", aspetta, e da li' in poi fa tutto da solo
#
# -Subito usa il dump gia' sul disco invece di aspettarne uno nuovo
# -NoGit aggiorna il manifest e si ferma li'
#
# In gioco: /wmtier (calcola) poi /reload (scrive). L'ordine conta, e il comando non
# e' facoltativo: il dump non si ricalcola piu' da se' ne' al login ne' in chiusura.
# Se gli addon installati sono indietro rispetto al repo lo script li aggiorna da se'
# (vedi §0) e allora i reload diventano due: te lo chiede lui, uno alla volta.
#
# Tocca SOLO i blocchi `collected` e `pieceList`. Il resto del manifest (tiers,
# names, versions, fonte, colonna, spans) e' redazionale e non si tocca.
param(
[switch]$Subito,
[switch]$NoGit,
[int]$AttesaMax = 300,
# Ancorati alla posizione dello script, non alla cwd: lanciato da scripts\ il
# path relativo non risolveva e le chiamate a git finivano sul repo della cwd.
[string]$Manifest = (Join-Path (Split-Path $PSScriptRoot -Parent) "transmog\manifest.json"),
[string]$Wow = "C:\Program Files (x86)\World of Warcraft"
)
$ErrorActionPreference = "Stop"
$RepoRoot = Split-Path $PSScriptRoot -Parent
function Errore($msg) {
Write-Host "STOP: $msg" -ForegroundColor Red
exit 1
}
function TrovaDump {
# Cercato, non scritto a mano: il percorso contiene il nome dell'account.
return Get-ChildItem -Path (Join-Path $Wow "_retail_\WTF\Account") -Recurse `
-Filter "WowManagerTierDump.lua" -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -like "*SavedVariables*" } |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
}
# ⚠️ La data del FILE non dice se il dump e' fresco. L'addon calcola a client vivo
# (col comando o quando la collezione cambia), mentre WoW scrive il SavedVariables a
# ogni /reload e a ogni uscita: se dall'ultimo calcolo non e' cambiato nulla, riscrive
# gli stessi dati con una data di modifica nuova. Il campo `generated` invece cambia
# solo quando il dump viene davvero ricalcolato.
function GeneratoIl($file) {
try {
$t = Get-Content -Raw -Encoding UTF8 $file -ErrorAction Stop
if ($t -match '\["generated"\]\s*=\s*"([^"]+)"') { return $Matches[1] }
} catch { } # letto mentre il gioco lo stava scrivendo: si riprova al giro dopo
return $null
}
function PezziCollezionati($manifestPath) {
$m = Get-Content -Raw -Encoding UTF8 $manifestPath | ConvertFrom-Json
$n = 0
foreach ($cls in $m.collected.PSObject.Properties) {
foreach ($tier in $cls.Value.PSObject.Properties) {
foreach ($ver in $tier.Value.PSObject.Properties) { $n += $ver.Value[0] }
}
}
return $n
}
# --- 0. gli addon nella cartella di gioco ---------------------------------------
# Il gioco legge la copia in Interface/AddOns, NON il .lua del repo: finche' non la
# si sovrascrive il dump esce col codice vecchio e il sync reincolla i dati vecchi
# senza che nulla lo segnali. E' successo davvero due volte -- col set Challenge Mode
# tolto dal repo e reincollato dal sync, e col dump mount il 2026-08-04. Percio' la
# copia la fa lo script, non la memoria di chi lo lancia.
#
# Gli addon da tenere allineati sono DUE: il modulo del dump e il lanciatore
# WowManagerDump, che dal momento in cui i moduli sono LoadOnDemand e' quello che
# registra /wmtier e li carica. Un lanciatore vecchio accanto a un modulo nuovo e'
# esattamente il tipo di disallineamento che questo blocco esiste per evitare.
$AddOnsDir = Join-Path $Wow "_retail_\Interface\AddOns"
$Addon = @(
@{ dir = "WowManagerDump"; files = @(
@{ src = "dump-launcher.lua"; dst = "WowManagerDump.lua" },
@{ src = "WowManagerDump.toc"; dst = "WowManagerDump.toc" }) },
@{ dir = "WowManagerTierDump"; files = @(
@{ src = "transmog-tier-dump.lua"; dst = "WowManagerTierDump.lua" },
@{ src = "WowManagerTierDump.toc"; dst = "WowManagerTierDump.toc" }) }
)
$Reload = 1
$Riavvio = @()
foreach ($a in $Addon) {
$dir = Join-Path $AddOnsDir $a.dir
$nuovo = -not (Test-Path $dir)
if ($nuovo) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
$copiati = @()
$toc = $false
foreach ($f in $a.files) {
$src = Join-Path $PSScriptRoot $f.src
$dst = Join-Path $dir $f.dst
if (-not (Test-Path $src)) { continue }
if ((Test-Path $dst) -and (Get-FileHash $src).Hash -eq (Get-FileHash $dst).Hash) { continue }
Copy-Item $src $dst -Force
$copiati += $f.dst
if ($f.dst -like "*.toc") { $toc = $true }
}
if ($copiati.Count -gt 0) {
Write-Host ("addon aggiornato nella cartella di gioco: {0}/{1}" -f $a.dir, ($copiati -join ", ")) -ForegroundColor Yellow
# Si puo' sovrascrivere a gioco aperto, ma il modulo gia' caricato in questa
# sessione resta quello vecchio: il dump buono e' quello del secondo giro.
$Reload = 2
}
# Il .toc lo legge il client all'AVVIO: un addon nuovo, o un .toc cambiato (per
# esempio LoadOnDemand aggiunto), il /reload non li vede. Non e' un errore -- fino
# al riavvio ogni modulo si registra il comando da se' e il sync funziona
# ugualmente -- ma va detto, o il risparmio non entra mai in vigore.
if ($nuovo -or $toc) { $Riavvio += $a.dir }
}
if ($Riavvio.Count -gt 0) {
Write-Host ("addon da attivare col RIAVVIO del client (non basta /reload): {0}" -f ($Riavvio -join ", ")) -ForegroundColor Yellow
}
# --- 1. il dump ----------------------------------------------------------------
$dump = TrovaDump
if (-not $dump) { Errore "WowManagerTierDump.lua non trovato sotto $Wow. L'addon e' installato?" }
# -Subito prende il dump gia' sul disco, che pero' e' stato scritto PRIMA della copia
# qui sopra: sarebbe esattamente il caso che questo blocco esiste per evitare.
if ($Subito -and $Reload -gt 1) {
Errore "l'addon era indietro e l'ho appena aggiornato: il dump sul disco e' ancora del codice vecchio. Rilancia senza -Subito."
}
if (-not $Subito) {
for ($giro = 1; $giro -le $Reload; $giro++) {
$prima = GeneratoIl $dump.FullName
Write-Host ""
# Si chiede sempre il comando PRIMA del /reload, anche sui giri di scarto: il
# dump non si ricalcola piu' a ogni reload, quindi un /reload da solo non
# cambierebbe `generated` e lo script resterebbe li' ad aspettare. Il comando
# calcola, il /reload scrive.
$cmd = "/wmtier poi /reload"
if ($Reload -gt 1) {
Write-Host (" In gioco: {0} ({1} di {2})" -f $cmd, $giro, $Reload) -ForegroundColor Cyan
} else {
Write-Host (" In gioco: {0}" -f $cmd) -ForegroundColor Cyan
}
Write-Host (" (aspetto un dump nuovo, max {0}s - Ctrl+C per annullare)" -f $AttesaMax)
$scaduto = (Get-Date).AddSeconds($AttesaMax)
$ora = $prima
while ((Get-Date) -lt $scaduto) {
Start-Sleep -Seconds 2
$dump = TrovaDump
$ora = GeneratoIl $dump.FullName
if ($ora -and $ora -ne $prima) { break }
}
if (-not $ora -or $ora -eq $prima) {
Errore ("nessun dump RICALCOLATO entro $AttesaMax secondi (generated fermo a '{0}'). " -f $prima) `
+ "Il /reload da solo riscrive gli stessi dati: serve /wmtier prima. Oppure -Subito per prendere quello che c'e'."
}
if ($giro -lt $Reload) {
Write-Host " dump di scarto (codice vecchio), ne aspetto un altro." -ForegroundColor DarkGray
} else {
Write-Host " dump ricevuto." -ForegroundColor Green
}
}
}
$lua = Get-Content -Raw -Encoding UTF8 $dump.FullName
Write-Host ("dump: {0}" -f $dump.LastWriteTime)
# --- 2. guardie ----------------------------------------------------------------
# Un dump preso mentre il client non ha caricato la collezione e' internamente
# COERENTE: ogni set a zero pezzi ma con il totale giusto, quindi mismatches resta
# vuoto e nulla lo distingue da uno buono. Incollarlo azzererebbe la collezione.
if ($lua -match '\["sospetto"\]\s*=\s*"([^"]+)"') { Errore "il dump si e' auto-segnalato: $($matches[1])" }
foreach ($campo in @("mismatches", "errors")) {
if ($lua -match ('\["' + $campo + '"\]\s*=\s*\{\s*"')) {
Errore "il campo $campo del dump non e' vuoto: elenco pezzi non fidato"
}
}
$presi = 0
if ($lua -match '\["presi"\]\s*=\s*(\d+)') { $presi = [int]$matches[1] }
if ($presi -le 0) { Errore "il dump dice $presi pezzi collezionati: collezione non caricata" }
# --- 3. estrarre e sostituire ---------------------------------------------------
function Blocco($nome) {
$re = '\["' + $nome + '"\]\s*=\s*"((?:[^"\\]|\\.)*)"'
if ($lua -notmatch $re) { Errore "campo $nome assente dal dump" }
# Un passaggio solo, non tre Replace in cascata: con '\\' trattato per ultimo un
# backslash escapato seguito da n diventava un a capo invece di \ + n.
return [regex]::Replace($matches[1], '\\(.)', {
param($m)
switch ($m.Groups[1].Value) {
'n' { "`n" }
't' { "`t" }
'r' { "`r" }
default { $m.Groups[1].Value }
}
})
}
$blocchi = @{ "collected" = (Blocco "collectedJson"); "pieceList" = (Blocco "piecesJson") }
if (-not (Test-Path $Manifest)) { Errore "manifest non trovato: $Manifest" }
$primaPezzi = PezziCollezionati $Manifest
$righe = [System.Collections.ArrayList]@((Get-Content -Encoding UTF8 $Manifest))
foreach ($chiave in @("collected", "pieceList")) {
$inizio = -1
for ($i = 0; $i -lt $righe.Count; $i++) {
if ($righe[$i] -eq (' "' + $chiave + '": {')) { $inizio = $i; break }
}
if ($inizio -lt 0) { Errore "blocco $chiave non trovato nel manifest" }
$fine = -1
for ($i = $inizio + 1; $i -lt $righe.Count; $i++) {
if ($righe[$i] -eq " }" -or $righe[$i] -eq " },") { $fine = $i; break }
}
if ($fine -lt 0) { Errore "fine del blocco $chiave non trovata" }
$coda = ""
if ($righe[$fine].EndsWith(",")) { $coda = "," }
$nuove = $blocchi[$chiave] -split "`n"
$nuove[$nuove.Count - 1] = $nuove[$nuove.Count - 1] + $coda
$righe.RemoveRange($inizio, $fine - $inizio + 1)
$righe.InsertRange($inizio, [string[]]$nuove)
}
# --- 4. validare PRIMA di scrivere ----------------------------------------------
$testo = ($righe -join "`n")
try { $null = $testo | ConvertFrom-Json } catch { Errore "il risultato non e' JSON valido: $_" }
[System.IO.File]::WriteAllText(
(Resolve-Path $Manifest), $testo, (New-Object System.Text.UTF8Encoding($false)))
$dopoPezzi = PezziCollezionati $Manifest
$delta = $dopoPezzi - $primaPezzi
Write-Host ("manifest: {0} pezzi collezionati ({1:+#;-#;0} rispetto a prima)" -f $dopoPezzi, $delta) -ForegroundColor Green
# --- 5. git ---------------------------------------------------------------------
if ($NoGit) {
Write-Host "-NoGit: mi fermo qui. Committa a mano quando vuoi."
exit 0
}
Push-Location $RepoRoot
try {
# Il conteggio dei pezzi NON basta a dire se c'e' qualcosa da salvare: pieceList
# cambia anche a parita' di pezzi (boss appena attribuiti, copertura fonte migliore
# dopo una modifica al dump). Prima si guardava solo $delta e un `git checkout`
# buttava via quelle modifiche in silenzio. Ora decide git: se il file non e'
# cambiato davvero, non c'e' niente da fare e non serve nemmeno ripristinarlo.
git diff --quiet -- $Manifest
$immutato = ($LASTEXITCODE -eq 0)
if ($immutato) {
Write-Host "manifest identico a quello committato: niente da fare." -ForegroundColor Yellow
exit 0
}
# Non si committa il lavoro altrui: se ci sono altri file modificati ci si ferma.
# A mano, non con GetRelativePath: quello e' .NET Core, PS 5.1 gira su Framework.
$pieno = (Resolve-Path $Manifest).Path
$radice = (Resolve-Path $RepoRoot).Path.TrimEnd('\') + '\'
$relManifest = $pieno.Substring($radice.Length) -replace '\\', '/'
$sporchi = @(git status --porcelain | ForEach-Object { $_.Substring(3) } |
Where-Object { $_ -ne $relManifest })
if ($sporchi.Count -gt 0) {
Write-Host "altri file modificati, non committo da solo:" -ForegroundColor Yellow
$sporchi | ForEach-Object { Write-Host " $_" }
Write-Host "manifest aggiornato lo stesso: committa tu."
exit 0
}
git add -- $Manifest
if ($delta -eq 0) {
# Il file e' cambiato ma i pezzi no: e' pieceList, cioe' provenienze migliorate.
git commit -q -m "Transmog: provenienze dei pezzi aggiornate"
} else {
git commit -q -m ("Transmog: collezione aggiornata, +{0} pezzi" -f $delta)
}
# ⚠️ L'esito del push si GUARDA. Prima si stampava «committato e pushato» comunque, quindi
# una rete giu' o un token scaduto passavano inosservati: il commit restava locale, e
# siccome e' il push a far ripartire GitHub Actions, il sito continuava a mostrare i dati
# vecchi con la data «sync» ferma. Il commit e' fatto e resta buono: si dice solo la verita'
# su cosa manca.
git push -q
if ($LASTEXITCODE -ne 0) {
Write-Host "committato, ma il PUSH E' FALLITO (git ha risposto $LASTEXITCODE)." -ForegroundColor Red
Write-Host " il commit e' in locale: rilancia 'git push' quando la rete torna." -ForegroundColor Yellow
exit 1
}
Write-Host "committato e pushato." -ForegroundColor Green
} finally { Pop-Location }#Requires -Version 5.1
# Applica il PRESET delle "impostazioni di gioco" a TUTTI i personaggi, Stantu incluso.
# Il preset sta qui sotto ed e' lui la fonte di verita': prima i valori si copiavano dal
# config-cache.wtf di Stantu, con due effetti indesiderati -- un ritocco qualsiasi fatto
# in gioco su Stantu si propagava a tutti, e una chiave che Stantu non aveva veniva
# saltata in silenzio (sembrava sincronizzata e non lo era).
# Scrive SOLO le chiavi dichiarate: tutto il resto dello stato per-PG resta intatto.
# Due blocchi, perche' il client salva i CVar in due posti: $CharValues (per-PG, in ogni
# cartella di personaggio) e $AccountValues (account-wide, nel file di account).
# IMPORTANTE: eseguire con WoW COMPLETAMENTE CHIUSO. -Prova = dice cosa cambierebbe.
param([switch]$Prova)
$ErrorActionPreference = 'Stop'
# --- PRESET per-PG: valore voluto, applicato a ogni personaggio ------------------------
$CharValues = [ordered]@{
# Gameplay
enableMouseoverCast = '1'
autoLootDefault = '1'
AutoPushSpellToActionBar = '0'
enableMultiActionBars = '15'
SoftTargetEnemy = '2' # Action Targeting da tastiera
assistedCombatHighlight = '1'
cooldownViewerEnabled = '1'
damageMeterEnabled = '1'
damageMeterResetOnNewInstance = '1'
# Riquadri gruppo / raid
raidFramesDisplayClassColor = '1'
raidFramesDisplayPowerBars = '1'
# Nameplate. I due valori strani (MinAlpha, MinAlphaDistance) non sono refusi: sono
# quelli che scrive il client muovendo gli slider, e li teniamo come sono.
nameplateSelectedScale = '1.15'
nameplateSelectedAlpha = '1'
nameplateMaxDistance = '60'
nameplateMinScale = '1'
nameplateMaxScale = '1'
nameplateMinScaleDistance = '0'
nameplateMaxScaleDistance = '40'
nameplateMinAlpha = '0.90135484'
nameplateMaxAlpha = '1'
nameplateMinAlphaDistance = '-158489.31924611'
nameplateTargetBehindMaxDistance = '30'
nameplateShowDebuffsOnFriendly = '0'
# Camera. ATTENZIONE: queste quattro il client le riscrive da se' a ogni sessione
# (zoom, inclinazione), quindi divergono per conto loro e ogni giro le riporta qui.
cameraSavedDistance = '39.000000'
cameraSavedPitch = '19.726063'
cameraSavedVehicleDistance = '50.000000'
cameraSavedPetBattleDistance = '14.999997'
# Interfaccia varia
calendarShowBattlegrounds = '0'
characterNeedsTurnStrafeDialog = '0'
miniDressUpFrame = '1'
showTokenFrame = '1'
showTamers = '0'
}
# --- PRESET account-wide: CVar che nei file per-PG non esistono proprio ----------------
# Scriverli per-PG non farebbe nulla: stanno solo in Account/<ACC>/config-cache.wtf.
$AccountValues = [ordered]@{
# Social > New Whispers = Both. Valori del client: inline | popout | popout_and_inline
whisperMode = 'popout_and_inline'
}
# NON sincronizzabile: il blocco degli inviti in gilda. Verificato sul client 12.0.7
# enumerando le stringhe di Wow.exe: 'blockTrades' e 'blockChannelInvites' esistono,
# un 'blockGuildInvites' NO -- quel flag e' server-side per personaggio
# (C_GuildInfo.SetAutoDeclineGuildInvites) e non passa da nessun file WTF. Si toglie
# solo in gioco, su ogni PG:
# /run (C_GuildInfo and C_GuildInfo.SetAutoDeclineGuildInvites or SetAutoDeclineGuildInvites)(false)
# Cercata, non scritta a mano: la cartella porta il nome dell'account Battle.net,
# che non va pubblicato (questo script e' reso per intero sulla pagina Extra).
$AccRoot = 'C:\Program Files (x86)\World of Warcraft\_retail_\WTF\Account'
$Acc = (Get-ChildItem -LiteralPath $AccRoot -Directory |
Where-Object { $_.Name -ne 'SavedVariables' } |
Select-Object -First 1).FullName
if (-not $Acc) { throw "Nessuna cartella account sotto: $AccRoot" }
# A gioco aperto e' lavoro buttato: al logout il client riscrive i config-cache col suo
# stato in memoria e le chiavi appena messe qui tornano come prima.
if (-not $Prova) {
if (Get-Process -Name 'Wow' -ErrorAction SilentlyContinue) {
throw "WoW e' aperto: chiudilo prima, oppure lancia con -Prova per vedere solo cosa cambierebbe."
}
}
# Porta le chiavi dichiarate al valore del preset in un file WTF; ritorna quante ne ha cambiate.
function Set-WtfKeys {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][System.Collections.IDictionary]$Values,
[switch]$DryRun
)
$lines = @(Get-Content -LiteralPath $Path)
$changed = 0
foreach ($k in @($Values.Keys)) {
$new = 'SET ' + $k + ' "' + $Values[$k] + '"'
$rx = '^SET ' + [regex]::Escape($k) + ' ".*"\s*$'
$idx = -1
for ($i = 0; $i -lt $lines.Count; $i++) { if ($lines[$i] -match $rx) { $idx = $i; break } }
if ($idx -ge 0) {
if ($lines[$idx] -ne $new) { $lines[$idx] = $new; $changed++ } # sostituisci riga esistente
}
else {
$lines += $new; $changed++ # aggiungi in coda
}
}
if ($changed -gt 0 -and -not $DryRun) {
# Riscrivi con CRLF e senza BOM (il client vuole testo semplice).
$text = ($lines -join "`r`n") + "`r`n"
[System.IO.File]::WriteAllText($Path, $text, [System.Text.UTF8Encoding]::new($false))
}
return $changed
}
if ($Prova) { Write-Host "PROVA: nessun file verra' scritto." -ForegroundColor Yellow; Write-Host "" }
Write-Host ("Preset per-PG ({0} chiavi):" -f $CharValues.Count)
foreach ($k in $CharValues.Keys) { Write-Host (" {0,-32} = {1}" -f $k, $CharValues[$k]) }
Write-Host ""
# Ogni cartella di personaggio. Fuori il file account-level (contiene solo CVar
# account-wide: le chiavi per-PG li' il gioco le scarta) e le cartelle _backup-*.
$accCache = Join-Path $Acc 'config-cache.wtf'
$targets = Get-ChildItem -LiteralPath $Acc -Recurse -File -Filter 'config-cache.wtf' |
Where-Object { $_.FullName -ne $accCache -and $_.FullName -notmatch '\\_backup-' } |
Sort-Object FullName
$files = 0
$keys = 0
foreach ($f in $targets) {
$n = Set-WtfKeys -Path $f.FullName -Values $CharValues -DryRun:$Prova
$pg = $f.Directory.FullName.Substring($Acc.Length + 1)
if ($n -gt 0) {
$files++; $keys += $n
Write-Host (" {0,-44} {1} chiavi" -f $pg, $n)
}
else {
Write-Host (" {0,-44} -" -f $pg)
}
}
# Account-wide: un file solo.
if ($AccountValues.Count -gt 0 -and (Test-Path -LiteralPath $accCache)) {
Write-Host ""
Write-Host "Preset account-wide (config-cache.wtf di account):"
foreach ($k in $AccountValues.Keys) { Write-Host (" {0,-32} = {1}" -f $k, $AccountValues[$k]) }
$n = Set-WtfKeys -Path $accCache -Values $AccountValues -DryRun:$Prova
if ($n -gt 0) { $files++; $keys += $n; Write-Host (" -> aggiornate {0} chiavi" -f $n) }
else { Write-Host " -> gia' a posto" }
}
Write-Host ""
if ($Prova) {
Write-Host ("Da aggiornare: {0} file su {1}, {2} chiavi. Niente scritto." -f $files, ($targets.Count + 1), $keys)
}
else {
Write-Host ("Fatto: {0} file aggiornati su {1}, {2} chiavi scritte." -f $files, ($targets.Count + 1), $keys)
}// Verifica di COERENZA fra i dati del repo. Non guarda il sito: guarda se i file si
// tengono fra loro.
//
// node scripts/validate-data.mjs stampa il quadro, esce 1 se trova un errore
// node scripts/validate-data.mjs --tutto stampa anche i conteggi passati in silenzio
//
// ⚠️ PERCHE' ESISTE. Le guardie forti del repo (il campo `sospetto` dei dump, la soglia
// sulla perdita di icone, `mismatches`) vivono dentro gli script di sync, quindi coprono
// il solo dato macchina-generato. Tutto cio' che si tocca a mano — pg.md, char-specs.ts,
// builds.json, i manifest — non aveva nessuna rete: un riferimento rotto restava li'
// finche' non lo notava qualcuno. E' successo davvero, e per giorni: i 19 PG pianificati
// tolti da pg.md il 2026-08-04 hanno lasciato le loro spec in char-specs.ts senza che
// nulla lo segnalasse.
//
// ⚠️ E' un controllo di INTEGRITA' REFERENZIALE, non di merito: dice «questo nome non
// esiste da nessuna parte», mai «questa scelta e' sbagliata». Le scelte redazionali (quale
// spec dare a un PG, quale ordine consigliare in una professione) non sono affar suo.
//
// Niente dipendenze: solo `node:fs`. Gira in CI (.github/workflows/verifica.yml) e a mano.
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const verboso = process.argv.includes('--tutto');
const errori = [];
const avvisi = [];
const note = [];
const err = (dove, testo) => errori.push(`${dove}: ${testo}`);
const avviso = (dove, testo) => avvisi.push(`${dove}: ${testo}`);
const nota = (testo) => note.push(testo);
const leggi = (rel) => readFileSync(join(root, rel), 'utf8');
const json = (rel) => JSON.parse(leggi(rel));
const ci_sta = (rel) => existsSync(join(root, rel));
const elenco = (rel) => (ci_sta(rel) ? readdirSync(join(root, rel), { withFileTypes: true }).filter((d) => d.isFile()).map((d) => d.name) : []);
// Spec valide per classe (slug con trattino, come le chiavi di `collected` e il campo
// `class` delle macro). Il file icona invece toglie il trattino: `death-knight` ->
// `deathknight-blood.jpg`.
const SPEC = {
'warrior': ['arms', 'fury', 'protection'],
'paladin': ['holy', 'protection', 'retribution'],
'hunter': ['beastmastery', 'marksmanship', 'survival'],
'rogue': ['assassination', 'outlaw', 'subtlety'],
'priest': ['discipline', 'holy', 'shadow'],
'death-knight': ['blood', 'frost', 'unholy'],
'shaman': ['elemental', 'enhancement', 'restoration'],
'mage': ['arcane', 'fire', 'frost'],
'warlock': ['affliction', 'demonology', 'destruction'],
'monk': ['brewmaster', 'mistweaver', 'windwalker'],
'druid': ['balance', 'feral', 'guardian', 'restoration'],
'demon-hunter': ['havoc', 'vengeance', 'devourer'],
'evoker': ['devastation', 'preservation', 'augmentation'],
};
const senzaTrattino = (s) => s.replace(/-/g, '');
// Abbreviazione di colonna in pg.md -> slug icona (senza trattino), come CLASS_ABBR.
const ABBR = {
War: 'warrior', Pal: 'paladin', Hun: 'hunter', Rog: 'rogue', Pri: 'priest',
DK: 'deathknight', Sha: 'shaman', Mag: 'mage', Loc: 'warlock', Mon: 'monk',
Dru: 'druid', DH: 'demonhunter', Evo: 'evoker',
};
// slug icona -> slug con trattino, per rientrare in SPEC.
const CON_TRATTINO = { deathknight: 'death-knight', demonhunter: 'demon-hunter' };
const specDi = (slugIcona) => SPEC[CON_TRATTINO[slugIcona] ?? slugIcona];
// ── Professioni ────────────────────────────────────────────────────────────────
const prof = json('professions/manifest.json');
const trees = json('professions/trees.json');
const leveling = json('professions/leveling.json');
const builds = json('professions/builds.json');
const specsProf = json('professions/specs.json');
const chiaviProf = prof.professions.map((p) => p.key);
for (const [nome, file] of [['trees', trees], ['leveling', leveling], ['builds', builds], ['specs', specsProf]]) {
const suo = Object.keys(file.professions ?? {});
for (const k of chiaviProf) if (!suo.includes(k)) err('professioni', `${k} manca in ${nome}.json`);
for (const k of suo) if (!chiaviProf.includes(k)) err('professioni', `${nome}.json ha ${k}, che non e' nel manifest`);
}
for (const k of chiaviProf) {
if (!ci_sta(`public/icons/prof/${k}.jpg`)) err('professioni', `icona mancante public/icons/prof/${k}.jpg`);
}
// Nomi-nodo dell'albero del client, per professione (uniti su tutte le espansioni).
const nodiDi = {};
for (const [k, perEsp] of Object.entries(trees.professions ?? {})) {
const set = new Set();
const cammina = (n) => {
if (n?.name != null) set.add(n.name);
for (const c of n?.children ?? []) cammina(c);
};
for (const albero of Object.values(perEsp)) for (const s of albero.specs ?? []) cammina(s);
nodiDi[k] = set;
}
// Uno step di builds.json puo' essere COMPOSTO: `spec` e `alt` sono allora etichette da
// leggere («Sculpted / Large Plate / Articulating Armor») e i nomi veri stanno in `nodes`.
// ⚠️ Quindi il controllo cambia con la forma: con `nodes` si verificano quelli e non
// l'etichetta; senza, `spec` e `alt` devono essere nomi di nodo veri.
const passiDi = (v) => (Array.isArray(v) ? v : [...(v.steps ?? []), ...(v.variants ?? []).flatMap((x) => x.steps ?? [])]);
for (const [k, perEsp] of Object.entries(builds.professions ?? {})) {
const noti = nodiDi[k];
if (!noti) { err('builds.json', `${k} non ha un albero in trees.json`); continue; }
for (const [esp, v] of Object.entries(perEsp)) {
for (const st of passiDi(v)) {
const daControllare = st.nodes?.length ? st.nodes : [st.spec, st.alt].filter(Boolean);
for (const n of daControllare) {
if (!noti.has(n)) err('builds.json', `${k}/${esp}: «${n}» non e' un nodo di trees.json`);
}
}
}
}
let nodiTot = 0, desc = 0;
for (const [k, noti] of Object.entries(nodiDi)) {
nodiTot += noti.size;
for (const n of Object.keys(specsProf.professions?.[k] ?? {})) {
if (noti.has(n)) desc++;
else err('specs.json', `${k}: descrizione per «${n}», che non e' un nodo di trees.json`);
}
}
nota(`professioni: ${chiaviProf.length} · nodi ${nodiTot} · descrizioni ${desc}/${nodiTot}`);
// ── Macro ──────────────────────────────────────────────────────────────────────
const macros = json('macros/manifest.json').macros ?? {};
const usati = new Set();
for (const [k, m] of Object.entries(macros)) {
if (!m.body_file && !m.body) { err('macro', `${k} non ha ne' body_file ne' body`); continue; }
if (m.body_file) {
usati.add(m.body_file);
if (!ci_sta(`macros/${m.body_file}`)) err('macro', `${k}: manca macros/${m.body_file}`);
}
if (m.class && !SPEC[m.class]) { err('macro', `${k}: classe sconosciuta «${m.class}»`); continue; }
if (m.spec && !m.class) err('macro', `${k}: spec «${m.spec}» senza classe`);
if (m.spec && m.class && !SPEC[m.class].includes(m.spec)) err('macro', `${k}: «${m.spec}» non e' una spec di ${m.class}`);
if (m.spec && m.class && !ci_sta(`public/icons/spec/${senzaTrattino(m.class)}-${m.spec}.jpg`)) {
err('macro', `${k}: manca public/icons/spec/${senzaTrattino(m.class)}-${m.spec}.jpg`);
}
// Il repertorio si traccia per classe/spec, mai per personaggio.
if (m.character) avviso('macro', `${k}: il campo character e' valorizzato (${m.character})`);
}
// .txt sul disco che nessuna macro dichiara.
const camminaTxt = (rel) => {
for (const d of readdirSync(join(root, rel), { withFileTypes: true })) {
if (d.isDirectory()) camminaTxt(`${rel}/${d.name}`);
else if (d.name.endsWith('.txt')) {
const relativo = `${rel}/${d.name}`.slice('macros/'.length);
if (!usati.has(relativo)) err('macro', `macros/${relativo} non e' dichiarato da nessuna macro`);
}
}
};
camminaTxt('macros');
nota(`macro: ${Object.keys(macros).length}`);
// ── Addon ──────────────────────────────────────────────────────────────────────
const addons = json('addons/manifest.json').addons ?? {};
for (const [k, a] of Object.entries(addons)) {
for (const campo of ['name', 'desc', 'version', 'interface', 'source', 'folders']) {
if (a[campo] == null || (Array.isArray(a[campo]) && !a[campo].length)) err('addon', `${k}: campo ${campo} mancante o vuoto`);
}
if (!['png', 'jpg', 'jpeg'].some((e) => ci_sta(`public/icons/addon/${k}.${e}`))) {
err('addon', `${k}: nessuna icona in public/icons/addon/`);
}
}
// File senza padrone nelle due cartelle di immagini degli addon.
for (const [cartella, estensione] of [['public/icons/addon', null], ['public/addon-img', '.webp']]) {
for (const f of elenco(cartella)) {
if (estensione && !f.endsWith(estensione)) continue;
const chiave = f.replace(/\.[^.]+$/, '');
if (!(chiave in addons)) err('addon', `${cartella}/${f} non appartiene a nessun addon`);
}
}
nota(`addon: ${Object.keys(addons).length}`);
// ── Extra (script / link / note) ───────────────────────────────────────────────
const extra = json('scripts/manifest.json').extra ?? {};
const dichiarati = new Set();
for (const [k, e] of Object.entries(extra)) {
if (e.kind === 'script') {
if (!e.body_file) { err('extra', `${k}: kind script senza body_file`); continue; }
dichiarati.add(e.body_file);
if (!ci_sta(`scripts/${e.body_file}`)) err('extra', `${k}: manca scripts/${e.body_file}`);
}
if (e.kind === 'link' && !e.url) err('extra', `${k}: kind link senza url`);
}
// Ogni script nella cartella deve avere la sua card: e' cosi' che compare su /extra.
const ESEGUIBILI = /\.(ps1|sh|lua|py|bat|txt|mjs)$/;
for (const f of elenco('scripts')) {
if (ESEGUIBILI.test(f) && !dichiarati.has(f)) err('extra', `scripts/${f} non e' dichiarato in scripts/manifest.json`);
}
nota(`extra: ${Object.keys(extra).length} voci`);
// ── PG: pg.md vs char-specs.ts ─────────────────────────────────────────────────
// Tabelle markdown sotto «## Orda» e «## Alleanza», stessa lettura di content.ts.
const pgRaw = leggi('pg.md');
const tabella = (sezione) => {
const righe = pgRaw.split('\n');
let i = righe.findIndex((l) => new RegExp(`^##\\s+${sezione}`, 'i').test(l));
if (i < 0) return null;
const out = [];
for (i++; i < righe.length; i++) {
const l = righe[i];
if (l.trim().startsWith('|')) {
const celle = l.trim().split('|').slice(1, -1);
if (celle.every((c) => /^\s*:?-{2,}:?\s*$/.test(c))) continue;
out.push(celle);
} else if (out.length) break;
}
return out.length ? out : null;
};
const pg = []; // { nome, razza, slugIcona }
for (const sez of ['Orda', 'Alleanza']) {
const t = tabella(sez);
if (!t) { err('pg.md', `sezione «${sez}» non trovata`); continue; }
const testata = t[0].map((c) => c.trim());
for (const riga of t.slice(1)) {
if (riga.length !== testata.length) {
err('pg.md', `${sez}: riga «${riga[0]?.trim()}» ha ${riga.length} celle invece di ${testata.length}`);
continue;
}
const razza = riga[0].replace(/·[A-Z]/g, '').trim();
for (let j = 1; j < riga.length; j++) {
const cella = riga[j].trim();
if (!cella || cella === 'X') continue;
const slug = ABBR[testata[j]];
if (!slug) { err('pg.md', `${sez}: colonna «${testata[j]}» sconosciuta`); continue; }
for (const pezzo of cella.split(/<br\s*\/?>/i)) {
let nome = pezzo.trim().replace(/·[A-Z]$/, '').trim();
if (!nome) continue;
const pianificato = nome.startsWith('*');
nome = nome.replace(/^[*_]/, '').trim();
if (!pianificato) pg.push({ nome, razza, slug });
}
}
}
}
// char-specs.ts: i due blocchi si leggono a regex — e' un file di dati con una forma sola.
const csRaw = leggi('src/lib/char-specs.ts');
const blocco = (nomeVar) => {
const m = csRaw.match(new RegExp(`${nomeVar}:\\s*Record<string, string>\\s*=\\s*\\{([\\s\\S]*?)\\n\\};`));
if (!m) { err('char-specs.ts', `blocco ${nomeVar} non riconosciuto (la forma del file e' cambiata?)`); return {}; }
const out = {};
for (const riga of m[1].split('\n')) {
const t = riga.trim();
if (!t || t.startsWith('//')) continue;
const v = t.match(/^'?([^':]+?)'?\s*:\s*'([^']*)'/);
if (v) out[v[1].toLowerCase()] = v[2];
}
return out;
};
const CHAR_SPEC = blocco('CHAR_SPEC');
const CHAR_SPEC_BY_RACE = blocco('CHAR_SPEC_BY_RACE');
if (!Object.keys(CHAR_SPEC).length) err('char-specs.ts', 'CHAR_SPEC risulta vuota: la lettura non ha agganciato nulla');
for (const p of pg) {
const perRazza = CHAR_SPEC_BY_RACE[`${p.nome}|${p.razza}`.toLowerCase()];
const valore = perRazza ?? CHAR_SPEC[p.nome.toLowerCase()];
if (valore == null) { err('pg', `${p.nome} (${p.razza} ${p.slug}) non ha una spec in char-specs.ts`); continue; }
const valide = specDi(p.slug) ?? [];
for (const pezzo of String(valore).split(',')) {
const s = pezzo.trim().replace(/\*$/, '');
if (!s || s === '?') continue;
if (!valide.includes(s)) { err('pg', `${p.nome}: «${s}» non e' una spec di ${p.slug}`); continue; }
if (!ci_sta(`public/icons/spec/${p.slug}-${s}.jpg`)) err('pg', `manca public/icons/spec/${p.slug}-${s}.jpg (per ${p.nome})`);
}
}
// Omonimi: senza una chiave `nome|razza` per ciascuno, il secondo eredita la spec del primo.
const perNome = {};
for (const p of pg) (perNome[p.nome.toLowerCase()] ??= []).push(p);
for (const [nome, gruppo] of Object.entries(perNome)) {
if (gruppo.length < 2) continue;
const scoperti = gruppo.filter((p) => !(`${p.nome}|${p.razza}`.toLowerCase() in CHAR_SPEC_BY_RACE));
if (scoperti.length) err('pg', `omonimi «${nome}» senza chiave nome|razza in CHAR_SPEC_BY_RACE: ${scoperti.map((p) => p.razza).join(', ')}`);
}
// ⚠️ Le chiavi di CHAR_SPEC che non compaiono in pg.md NON sono un errore: sono i PG
// pianificati e mai creati, tenuti apposta per quando li si reintroduce con `*Nome`
// (vedi il blocco «PARCHEGGIATE» in char-specs.ts). Si contano e basta.
const nomiVivi = new Set(pg.map((p) => p.nome.toLowerCase()));
const parcheggiate = Object.keys(CHAR_SPEC).filter((k) => !nomiVivi.has(k));
nota(`pg: ${pg.length} in tabella · ${Object.keys(CHAR_SPEC).length} spec di cui ${parcheggiate.length} parcheggiate`);
// Tracker delle professioni per PG: i nomi devono mappare sul manifest, o l'icona non esce.
const chars = json('professions/characters.json').characters ?? {};
const nomiProf = new Set(prof.professions.map((p) => p.name.toLowerCase()));
for (const [chi, v] of Object.entries(chars)) {
for (const p of v.professions ?? []) {
if (p && !nomiProf.has(p.toLowerCase())) err('characters.json', `${chi}: professione «${p}» fuori dal manifest`);
}
}
// ── Mount ──────────────────────────────────────────────────────────────────────
const mm = json('mounts/manifest.json');
const mounts = mm.mounts ?? [];
const presi = mounts.filter((m) => m.got === 1).length;
if (mounts.length !== mm._meta?.total) err('mount', `_meta.total = ${mm._meta?.total} ma le voci sono ${mounts.length}`);
if (presi !== mm._meta?.collected) err('mount', `_meta.collected = ${mm._meta?.collected} ma le prese sono ${presi}`);
const sorgenti = new Set(Object.keys(mm.sources ?? {}));
const iconeMount = new Set(elenco('public/icons/mount').filter((f) => f.endsWith('.jpg')).map((f) => f.slice(0, -4)));
const renderMount = new Set(elenco('public/mounts').filter((f) => f.endsWith('.webp')).map((f) => f.slice(0, -5)));
const idVisti = new Set(), iconeUsate = new Set(), renderUsati = new Set();
const displayDi = new Map();
let senzaSpell = 0;
for (const m of mounts) {
if (idVisti.has(m.id)) err('mount', `id ${m.id} duplicato (e' la chiave di /mount/dettagli.json)`);
idVisti.add(m.id);
if (!m.spell) senzaSpell++;
// I segnaposto di lavorazione di Blizzard non devono arrivare in pagina: il marchio puo'
// stare in testa O IN CODA, e le quadre contano quanto le tonde.
if (/[[(](PH|DND)[\])]/i.test(m.name)) err('mount', `segnaposto in elenco: ${m.name}`);
if (!sorgenti.has(String(m.src)) && m.src !== 0) avviso('mount', `src ${m.src} non e' in sources (${m.name})`);
if (m.icon) {
iconeUsate.add(m.icon);
if (!iconeMount.has(m.icon)) err('mount', `${m.name}: icona dichiarata «${m.icon}» ma il file non c'e'`);
}
if (m.display) { renderUsati.add(String(m.display)); displayDi.set(m.id, String(m.display)); }
}
// ⚠️ Il `display` del manifest e' quello dell'ULTIMO dump, e per qualche mount cambia da
// se': Ash'adar ha una forma solare e una lunare (dipende dall'ora in cui gira il dump),
// altre seguono la personalizzazione attiva. Il render dell'altra forma resterebbe senza
// padrone e verrebbe segnalato qui a ogni ribaltamento -- rosso in CI e un viaggio sulla
// postazione con Node per ricancellarlo e riscaricare l'altro, in circolo. display-noti.json
// e' l'elenco durevole dei display gia' visti per ogni mount: i loro file sono legittimi.
const displayNoti = ci_sta('mounts/display-noti.json') ? json('mounts/display-noti.json') : {};
let altriRender = 0;
for (const [id, visti] of Object.entries(displayNoti)) {
if (!idVisti.has(Number(id))) { err('mount', `display-noti.json: la mount ${id} non e' nel manifest`); continue; }
const ora = displayDi.get(Number(id));
// Se il sync ha scritto il manifest ma non ha unito la storia, il file corrente
// manca dall'elenco: e' il solo modo in cui questi due file possono divergere.
if (ora && !visti.map(String).includes(ora)) err('mount', `display-noti.json: la mount ${id} ha display ${ora} nel manifest ma non nell'elenco`);
for (const d of visti) { if (!renderUsati.has(String(d))) altriRender++; renderUsati.add(String(d)); }
}
for (const f of iconeMount) if (!iconeUsate.has(f)) err('mount', `public/icons/mount/${f}.jpg non appartiene a nessuna mount`);
for (const f of renderMount) if (!renderUsati.has(f)) err('mount', `public/mounts/${f}.webp non appartiene a nessuna mount`);
if (senzaSpell > mounts.length * 0.05) err('mount', `${senzaSpell} voci senza spellID: dump sospetto`);
nota(`mount: ${mounts.length} · prese ${presi} · icone ${iconeMount.size} · render ${renderMount.size} (di cui ${altriRender} di forme alternative)`);
// ── Transmog ───────────────────────────────────────────────────────────────────
const tm = json('transmog/manifest.json');
const colonne = (tm.columns ?? []).map((c) => c.key);
const espansioni = new Set((tm.expansions ?? []).map((e) => e.key));
const tiers = tm.tiers ?? [];
const chiaviTier = tiers.map((t) => t.key);
const classi = Object.keys(tm.collected ?? {});
for (const t of tiers) {
if (!espansioni.has(t.exp)) err('transmog', `${t.key}: espansione «${t.exp}» inesistente`);
for (const k of Object.keys(t.versions ?? {})) if (!colonne.includes(k)) err('transmog', `${t.key}: versions.${k} non e' una colonna`);
for (const [da, a] of Object.entries(t.colonna ?? {})) {
if (!colonne.includes(da)) err('transmog', `${t.key}: colonna.${da} non e' una colonna`);
if (!colonne.includes(a)) err('transmog', `${t.key}: colonna.${da} punta a «${a}», che non e' una colonna`);
}
for (const k of Object.keys(t.spans ?? {})) if (!colonne.includes(k)) err('transmog', `${t.key}: spans.${k} non e' una colonna`);
// ⚠️ Il testo di `fonte` non deve contenere virgole: formatMissing taglia li' per
// separare boss e raid, e una virgola farebbe comparire una sigla inventata.
const virgole = (dove, v) => { if (typeof v === 'string' && v.includes(',')) err('transmog', `${t.key}/${dove}: «${v}» contiene una virgola`); };
if (typeof t.fonte === 'string') virgole('fonte', t.fonte);
else for (const [slot, v] of Object.entries(t.fonte ?? {})) {
if (typeof v === 'string') virgole(slot, v);
else for (const [ver, testo] of Object.entries(v ?? {})) {
virgole(`${slot}/${ver}`, testo);
if (!colonne.includes(ver)) err('transmog', `${t.key}/fonte/${slot}: versione «${ver}» sconosciuta`);
}
}
}
for (const [slug, tier] of Object.entries(tm.classStart ?? {})) {
if (!classi.includes(slug)) err('transmog', `classStart: classe «${slug}» sconosciuta`);
if (!chiaviTier.includes(tier)) err('transmog', `classStart.${slug}: tier «${tier}» inesistente`);
}
// ⚠️ `versions` deve combaciare con le colonne che il dump produce davvero: una colonna
// dichiarata e senza dati mostra un contatore fantasma, una con dati e non dichiarata
// SPARISCE dalla pagina.
let celle = 0, pezziPresi = 0, pezziTot = 0;
for (const t of tiers) {
const dichiarate = Object.keys(t.versions ?? {});
const iTier = chiaviTier.indexOf(t.key);
for (const slug of classi) {
const iStart = chiaviTier.indexOf(tm.classStart?.[slug] ?? '');
if (!t.armorType && iStart >= 0 && iTier < iStart) continue; // riga non mostrata per questa classe
const dati = tm.collected[slug]?.[t.key] ?? {};
for (const k of dichiarate) {
if (!(k in dati)) err('transmog', `${t.key}/${slug}: colonna «${k}» dichiarata in versions ma senza dati in collected`);
}
for (const k of Object.keys(dati)) {
if (!dichiarate.includes(k)) err('transmog', `${t.key}/${slug}: colonna «${k}» ha dati ma non e' dichiarata in versions`);
}
}
}
// collected e pieceList li scrive lo stesso dump in due campi: se se ne copia uno solo, il
// popover contraddice la frazione della cella.
for (const [slug, perTier] of Object.entries(tm.collected ?? {})) {
for (const [tk, perVer] of Object.entries(perTier)) {
for (const [ver, v] of Object.entries(perVer)) {
celle++;
const got = Array.isArray(v) ? Number(v[0]) : Number(v);
const tot = Array.isArray(v) ? Number(v[1]) : NaN;
pezziPresi += got || 0;
if (Number.isFinite(tot)) pezziTot += tot;
const lista = tm.pieceList?.[slug]?.[tk]?.[ver];
if (!lista) { err('transmog', `${slug}/${tk}/${ver}: c'e' in collected ma non in pieceList`); continue; }
if (Number.isFinite(tot) && lista.length !== tot) err('transmog', `${slug}/${tk}/${ver}: collected dice ${tot} pezzi, pieceList ne elenca ${lista.length}`);
const marcati = lista.filter((p) => Number(p[1]) === 1).length;
if (marcati !== got) err('transmog', `${slug}/${tk}/${ver}: collected dice ${got} presi, pieceList ne marca ${marcati}`);
}
}
}
nota(`transmog: ${classi.length} classi · ${tiers.length} tier · ${celle} celle · ${pezziPresi}/${pezziTot} pezzi`);
// ── Screenshot UI ──────────────────────────────────────────────────────────────
const shots = elenco('public/screenshots').filter((f) => f.endsWith('.webp'));
const thumbs = new Set(elenco('public/screenshots/thumb').filter((f) => f.endsWith('.webp')));
for (const f of shots) {
if (!thumbs.has(f)) err('ui', `public/screenshots/${f} non ha la miniatura in thumb/`);
// Il nome del file E' il dato: /ui ne ricava classe e spec.
const parti = f.replace(/\.webp$/, '').split('-');
const valide = specDi(parti[0]);
if (!valide) { err('ui', `${f}: «${parti[0]}» non e' una classe`); continue; }
if (parti.length < 3) { err('ui', `${f}: il nome non e' <classe>-<spec>-<nome>.webp`); continue; }
if (!valide.includes(parti[1])) err('ui', `${f}: «${parti[1]}» non e' una spec di ${parti[0]}`);
}
for (const f of thumbs) if (!shots.includes(f)) err('ui', `miniatura orfana: public/screenshots/thumb/${f}`);
nota(`ui: ${shots.length} screenshot`);
// ── Hardware (postazione di gioco) ────────────────────────────────────
// Manifest interamente redazionale: nessuno script lo rigenera, quindi non c'e' un dump
// da confrontare. Qui si controlla solo che si tenga insieme — che ogni impostazione
// finisca dentro un componente che esiste, e che ogni disegno esista davvero.
const hw = json('hardware/manifest.json');
const componenti = hw.components ?? [];
const opzioni = hw.settings ?? [];
// I disegni sono SVG in linea in UN file solo, quindi l'elenco dei nomi validi si legge
// da li': e' la stessa idea del confronto fra `spec` di builds.json e i nodi del client.
// ⚠️ Se questo grep smette di trovare nulla vuol dire che HwArt.astro ha cambiato forma
// (non piu' art === '...'), non che i disegni siano spariti: si segnala invece di
// dichiarare inesistenti tutti i nomi.
const ARTI = new Set([...leggi('src/components/HwArt.astro').matchAll(/art === '([a-z-]+)'/g)].map((m) => m[1]));
if (!ARTI.size) err('hardware', 'src/components/HwArt.astro: nessun disegno riconosciuto, il confronto dei nomi non funziona');
const artUsate = new Set();
const linkStrano = (u) => typeof u === 'string' && !/^https:\/\//.test(u);
const chiaviHw = new Set();
for (const c of componenti) {
const dove = `componente ${c.key ?? '(senza key)'}`;
for (const campo of ['key', 'label', 'name']) {
if (!c[campo]) err('hardware', `${dove}: manca ${campo}`);
}
if (chiaviHw.has(c.key)) err('hardware', `${dove}: chiave doppia`);
chiaviHw.add(c.key);
if (c.art) { artUsate.add(c.art); if (ARTI.size && !ARTI.has(c.art)) err('hardware', `${dove}: disegno «${c.art}» non esiste in HwArt.astro`); }
else avviso('hardware', `${dove}: nessun campo art, il blocco resta senza disegno`);
if (linkStrano(c.url)) err('hardware', `${dove}: il campo url non e un indirizzo https`);
}
// ⚠️ `where` NON e' piu' un «posto» a se': e' la chiave del componente che ospita
// l'impostazione (il FreeSync nel monitor, l'XMP nel BIOS). Se non combacia, la pagina
// non mostra l'impostazione da nessuna parte e non se ne accorge nessuno.
const chiaviOpz = new Set();
for (const s of opzioni) {
const dove = `impostazione ${s.key ?? '(senza key)'}`;
for (const campo of ['key', 'name', 'where']) if (!s[campo]) err('hardware', `${dove}: manca ${campo}`);
if (chiaviOpz.has(s.key)) err('hardware', `${dove}: chiave doppia`);
chiaviOpz.add(s.key);
if (s.where && !chiaviHw.has(s.where)) err('hardware', `${dove}: il componente «${s.where}» non esiste, l impostazione sparirebbe dalla pagina`);
// ⚠️ La pagina elenca la configurazione APPLICATA, non le cose da fare: il campo
// `state` (ok/todo/warn) e il contatore «N da sistemare» erano la deriva per cui il
// setup era stato tolto una prima volta. Nessuno lo legge piu': se torna e' dato morto.
if (linkStrano(s.url)) err('hardware', `${dove}: il campo url non e un indirizzo https`);
if (s.state) avviso('hardware', `${dove}: il campo state non e letto da nessuno, la pagina non ha piu' stati`);
}
// Dati che nessuno legge piu': `places` era l'asse del pannello «Setup» separato, `groups`
// le intestazioni per tipo tolte in favore del mosaico. Se tornano, sono dati morti.
if (hw.places) avviso('hardware', 'manifest.places non e letto da nessuno: le impostazioni stanno dentro il componente (settings[].where)');
if (hw.groups || componenti.some((c) => c.group)) avviso('hardware', 'groups/group non sono letti da nessuno: i blocchi stanno in un mosaico unico, senza intestazioni');
for (const a of ARTI) if (!artUsate.has(a)) avviso('hardware', `disegno «${a}» in HwArt.astro non e usato da nessun componente`);
if (!ci_sta('public/icons/section/hardware.jpg')) err('hardware', "manca public/icons/section/hardware.jpg (icona della sezione in nav)");
nota(`hardware: ${componenti.length} blocchi, ${opzioni.length} impostazioni, ${ARTI.size} disegni`);
// ── Esito ──────────────────────────────────────────────────────────────────────
if (verboso || !errori.length) for (const t of note) console.log(` ${t}`);
for (const t of avvisi) console.log(`AVVISO ${t}`);
if (!errori.length) {
console.log(`\nDati coerenti${avvisi.length ? ` (${avvisi.length} avvisi, non bloccanti)` : ''}.`);
process.exit(0);
}
console.error(`\n${errori.length} incoerenze:`);
for (const t of errori) console.error(` ERRORE ${t}`);
process.exit(1);// Vincoli d'uso delle cavalcature: riempie i campi `class` e `race` di
// mounts/manifest.json.
// Lo lancia mount-sync.ps1 dopo aver riscritto il manifest, oppure a mano:
//
// node scripts/mount-classes.mjs risolve solo le mount senza campo `class`
// node scripts/mount-classes.mjs --rifai ricontrolla tutte (una richiesta per mount)
//
// ⚠️ IL CLIENT NON LO SA, ed e' gia' stato verificato due volte: il tooltip di gioco non
// ha righe "Requires <classe>" (misurato su tutte le 1532 mount: zero) e la provenienza
// cita una classe in 23 casi su 1532. Quindi la fonte e' Wowhead, con TRE segnali
// diversi -- nessuno dei quali da solo copre l'insieme:
//
// 1. `wowhead-tooltip-requirements` ("Requires Paladin") nel tooltip JSON. Copre le
// cavalcature di classe classiche (Charger, Felsteed, Acherus Deathcharger...): 19.
// 2. La FRASE nella descrizione della spell -- "This mount is only available to
// Hunters." -- che sta nello stesso tooltip JSON. Copre le 11 versioni Felscorned
// di Legion Remix, e nient'altro: e' un modo di dire recente, le mount di Legion
// non ce l'hanno.
// 3. La QUEST da cui viene la mount: le quest della campagna di classe hanno una
// maschera `reqclass` nei dati di Wowhead. E' l'unico appiglio macchina per le 11
// cavalcature di classe di Legion (Archmage's Prismatic Disc & co.), il cui vincolo
// non e' sulla spell ma su come la si ottiene. Si legge dalla pagina della spell,
// agganciando la quest PER NOME (quello che il gioco scrive in `srcText`): la
// pagina cita anche le quest delle altre classi, quindi prendere "l'unica maschera
// presente" darebbe Guerriero a una mount da mago.
//
// Resta fuori un solo gruppo, i RICOLORI di Legion, che non si prendono da una quest ma
// dal quartiermastro della propria sede di classe: per quelli si guarda dove il gioco
// stesso dice che si comprano (vedi SEDI piu' sotto). E' l'unica parte non macchina, ed
// e' ancorata a un dato che sta gia' nel manifest.
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const file = join(root, 'mounts', 'manifest.json');
const rifai = process.argv.includes('--rifai');
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) WowManagerMountSync/1.0';
const PARALLELE = 6;
const CLASSI = ['Warrior', 'Paladin', 'Hunter', 'Rogue', 'Priest', 'Death Knight', 'Shaman', 'Mage', 'Warlock', 'Monk', 'Druid', 'Demon Hunter', 'Evoker'];
// Ordine dei bit della maschera reqclass di Wowhead: 1 Warrior, 2 Paladin, 4 Hunter...
const MASCHERA = Object.fromEntries(CLASSI.map((c, i) => [1 << i, c]));
// Le SEDI DI CLASSE di Legion, dove si comprano i ricolori del proprio mount di classe.
// ⚠️ Si guarda la sede, non il nome della mount: per nome si sbaglia due volte subito --
// «Archmage's Great Raven» e' un mount del negozio aperto a tutti, e i «Grandmaster's
// Board» del Trading Post non c'entrano nulla con Ban-Lu del monaco.
// ⚠️ Dove la zona non basta si guarda l'NPC: il quartiermastro dei ladri sta sotto
// Dalaran, e il gioco scrive «Zone: Dalaran», che di classe non ha niente.
// Le quest che consegnano la cavalcatura di classe di Legion, con la classe letta dalla
// maschera `reqclass` di Wowhead (una volta, dalla pagina di una qualsiasi di quelle
// mount: le citano tutte). Serve per DUE sole mount -- Ban-Lu e Slayer's Felbroken
// Shrieker -- le cui pagine Wowhead non portano il blocco dati della loro quest, quindi
// la ricerca per nome li' non trova nulla.
// ⚠️ Non e' un dato inventato: si ricontrolla aprendo `wowhead.com/quest=<id>` e
// cercando `reqclass` (512 = Monk, 2048 = Demon Hunter, ...).
const QUEST_DI_CLASSE = {
'The Trial of Rage': ['Warrior', 46207],
'Stirring in the Shadows': ['Paladin', 45770],
'Night of the Wilds': ['Hunter', 46337],
'Hiding In Plain Sight': ['Rogue', 46178],
'The Sunken Vault': ['Priest', 45789],
'The Lost Glacier': ['Death Knight', 46813],
'Gathering of the Storms': ['Shaman', 46792],
'Dispersion of the Discs': ['Mage', 45354],
'The Wrathsteed of Xoroth': ['Warlock', 46243],
'The Trial of Ban-Lu': ['Monk', 46350],
"You Can't Take the Sky from Me": ['Druid', 46319],
'To Fel and Back': ['Demon Hunter', 46334],
};
const SEDI = [
[/Sanctum of Light/i, 'Paladin'],
[/Trueshot Lodge/i, 'Hunter'],
[/Dreadscar Rift/i, 'Warlock'],
[/Netherlight Temple/i, 'Priest'],
[/Acherus/i, 'Death Knight'],
[/Skyhold|Valarjar Strongbox/i, 'Warrior'],
[/Dreamgrove/i, 'Druid'],
[/Hall of the Guardian/i, 'Mage'],
[/Peak of Serenity|Wandering Isle/i, 'Monk'],
[/Heart of Azeroth|The Maelstrom/i, 'Shaman'],
[/Fel Hammer/i, 'Demon Hunter'],
[/Hall of Shadows|Zan Shivsproket/i, 'Rogue'],
[/Hel'Nurath/i, 'Warlock'],
];
// ── RAZZA ────────────────────────────────────────────────────────────────────────
// ⚠️ Fino a ieri qui c'era scritto che la razza «non la sa nessuna delle due fonti».
// Era sbagliato: non la sa il TOOLTIP, ma i dati della spell su Wowhead hanno due
// campi -- `races` (elenco gia' risolto) e `reqrace` (maschera di bit) -- e li' i
// cavalli razziali dei paladini ci sono tutti.
//
// Si usa l'INTERSEZIONE dei due, non uno solo, perche' sbagliano in modi opposti:
// · `races` e' largo -- per il Sunwalker Kodo dice [Tauren, Dracthyr], e il Dracthyr
// non c'entra nulla con un cavallo razziale dei tauren;
// · `reqrace` e' preciso ma ha bit che Wowhead stessa non risolve: sul Charger accende
// anche un terzo bit che, letto come id di razza, darebbe «Fel Orc».
// L'intersezione tiene le razze su cui i due concordano, che sono quelle giuste.
const RAZZE = {
1: 'Human', 2: 'Orc', 3: 'Dwarf', 4: 'Night Elf', 5: 'Undead', 6: 'Tauren', 7: 'Gnome',
8: 'Troll', 9: 'Goblin', 10: 'Blood Elf', 11: 'Draenei', 22: 'Worgen', 24: 'Pandaren',
25: 'Pandaren', 26: 'Pandaren', 27: 'Nightborne', 28: 'Highmountain Tauren', 29: 'Void Elf',
30: 'Lightforged Draenei', 31: 'Zandalari Troll', 32: 'Kul Tiran', 34: 'Dark Iron Dwarf',
35: 'Vulpera', 36: "Mag'har Orc", 37: 'Mechagnome', 52: 'Dracthyr', 70: 'Dracthyr',
84: 'Earthen', 85: 'Earthen', 86: 'Haranir', 91: 'Haranir',
};
// Le due mount dove `races` non c'e' e la maschera non si sa leggere. Non sono
// indovinate: la maschera dice comunque QUANTE razze sono (il Ramolith ne accende due
// adiacenti, e l'Earthen e' due razze, una per fazione) e il paio Dawnforge/Darkforge
// e' il classico nano/nano scuro, con Dawnforge che su Wowhead risulta `races:[3]`.
const RAZZE_A_MANO = {
270562: 'Dark Iron Dwarf', // Darkforge Ram -- il gemello del Dawnforge Ram (nano)
453785: 'Earthen', // Earthen Ordinant's Ramolith
};
// Piu' di tre razze non e' un vincolo razziale ma l'elenco delle razze che possono
// avere quella CLASSE (l'Acherus Deathcharger ne elenca 23) o una fazione intera
// (Mechano-Hog e Chopper, 12-13): quello lo dicono gia' il bollino classe e il bollino
// fazione, e ripeterlo con dodici icone di razza non aiuterebbe nessuno.
const MAX_RAZZE = 3;
function razzeDaDati(o) {
if (!o) return null;
const arr = (o.match(/"races":\[([0-9,]*)\]/) || [, ''])[1];
const mask = +(o.match(/"reqrace":(\d+)/) || [, 0])[1];
if (!arr) return null;
const ids = arr.split(',').map(Number).filter(Boolean);
// bit = id - 1, valido fino a 32 (oltre la maschera non ci arriva).
const daMask = ids.filter((id) => id <= 32 && (mask & (2 ** (id - 1))));
const scelti = daMask.length ? daMask : ids;
if (!scelti.length || scelti.length > MAX_RAZZE) return null;
const nomi = [...new Set(scelti.map((id) => RAZZE[id]).filter(Boolean))];
return nomi.length ? nomi.join(' / ') : null;
}
const singolare = (s) => {
const n = s.trim().replace(/s$/, '');
return CLASSI.includes(n) ? n : null;
};
async function testo(url) {
const r = await fetch(url, { headers: { 'User-Agent': UA } });
if (!r.ok) return null;
return r.text();
}
// (1) e (2): un tooltip solo, due segnali.
async function daTooltip(spell) {
const t = await testo(`https://nether.wowhead.com/tooltip/spell/${spell}`);
if (!t) return null;
let j;
try { j = JSON.parse(t); } catch { return null; }
const html = (j.tooltip || '') + ' ' + (j.buff || '');
const req = html.match(/wowhead-tooltip-requirements[^>]*>Requires ([^<]+)</);
if (req) return singolare(req[1]);
const frase = html.replace(/<[^>]*>/g, ' ').match(/only available to\s+([A-Za-z ']+?)[.,]/i);
return frase ? singolare(frase[1]) : null;
}
// (3): la quest nominata dal gioco, cercata per nome nei dati della pagina.
async function daQuest(spell, nomeQuest) {
const h = await testo(`https://www.wowhead.com/spell=${spell}`);
if (!h) return null;
const esc = nomeQuest.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const voci = h.match(new RegExp(`"name_enus":"${esc}"[^}]*`, 'g'));
if (!voci) return null;
const maschere = [...new Set(voci.map((v) => +(v.match(/"reqclass":(\d+)/) || [0, 0])[1]).filter(Boolean))];
// Una maschera sola e con un bit solo: se la quest e' aperta a piu' classi non e' un
// vincolo di classe, e se le maschere discordano il nome ha agganciato due quest.
if (maschere.length !== 1) return null;
return MASCHERA[maschere[0]] || null;
}
// L'elenco mount di Wowhead in una richiesta sola: porta `races`/`reqrace` per 1507
// spell su 1532. Le 25 che mancano sono proprio quelle che interessano -- i cavalli dei
// paladini sono ABILITA' di classe, non stanno nel database delle mount -- e si chiedono
// una per una piu' sotto.
async function elencoMount() {
const h = await testo('https://www.wowhead.com/spells/mounts');
const out = new Map();
if (!h) return out;
for (const o of h.match(/\{"cat":-5.*?\}(?=,\{|\])/g) || []) {
const id = +(o.match(/"id":(\d+)/) || [])[1];
if (id) out.set(id, o);
}
return out;
}
// Il blocco dati della spell dentro la sua pagina, per chi non e' nell'elenco.
async function datiDiPagina(spell) {
const h = await testo(`https://www.wowhead.com/spell=${spell}`);
if (!h) return null;
return (h.match(new RegExp(`\\{[^{}]*"id":${spell}[^{}]*\\}`)) || [])[0] || null;
}
const raw = readFileSync(file, 'utf8');
const manifest = JSON.parse(raw);
// Argomenti numerici = ricontrolla solo quelle spell (utile dopo aver toccato le regole,
// per non rifare un giro da 1532 richieste).
const solo = process.argv.slice(2).filter((a) => /^\d+$/.test(a)).map(Number);
const daFare = manifest.mounts.filter((m) =>
solo.length ? solo.includes(m.spell) : rifai || m.class === undefined || m.class === null);
console.log(`mount da controllare: ${daFare.length} / ${manifest.mounts.length}`);
const elenco = daFare.length ? await elencoMount() : new Map();
const esiti = new Map();
let i = 0;
const coda = [...daFare];
await Promise.all(Array.from({ length: PARALLELE }, async () => {
for (let m = coda.shift(); m; m = coda.shift()) {
let cls = null, razza = null;
try {
cls = await daTooltip(m.spell);
if (!cls) {
const q = (m.srcText || '').match(/(?:^|\n)Quest: (.+)/);
if (q) cls = (await daQuest(m.spell, q[1].trim())) || (QUEST_DI_CLASSE[q[1].trim()] || [])[0] || null;
}
razza = razzeDaDati(elenco.get(m.spell) || (await datiDiPagina(m.spell)));
} catch { /* una mount che va storta non ferma il giro */ }
if (!cls) {
const sede = SEDI.find(([re]) => re.test(m.srcText || ''));
if (sede) cls = sede[1];
}
esiti.set(m.spell, { class: cls || '', race: razza || RAZZE_A_MANO[m.spell] || '' });
if (++i % 100 === 0) console.log(` ...${i}/${daFare.length}`);
}
}));
// Riscrittura riga per riga: il manifest lo genera mount-sync.ps1 con una mount per
// riga, e rigenerarlo con JSON.stringify ne stravolgerebbe la forma (e il diff).
const righe = raw.split('\n');
const cambi = [];
// ⚠️ Il `\r?` in coda non e' pedanteria: il manifest lo scrive PowerShell, quindi ha
// fine riga CRLF. Senza, l'aggiunta di un campo nuovo non aggancia mai la graffa finale
// e fallisce in silenzio -- il campo `class`, che sulla riga c'era gia', veniva
// sostituito benissimo, e `race`, che era nuovo, non si scriveva affatto.
const scrivi = (riga, campo, valore) => (
new RegExp(`"${campo}":(?:null|"[^"]*")`).test(riga)
? riga.replace(new RegExp(`"${campo}":(?:null|"[^"]*")`), `"${campo}":"${valore}"`)
: riga.replace(/\}(,?)(\r?)$/, `,"${campo}":"${valore}"}$1$2`)
);
for (let n = 0; n < righe.length; n++) {
const sp = righe[n].match(/"spell":(\d+)/);
if (!sp || !esiti.has(+sp[1])) continue;
const nuovo = esiti.get(+sp[1]);
const nome = (righe[n].match(/"name":"([^"]*)"/) || [, '?'])[1];
for (const campo of ['class', 'race']) {
const vecchio = (righe[n].match(new RegExp(`"${campo}":"([^"]*)"`)) || [, null])[1];
if (vecchio === nuovo[campo]) continue;
righe[n] = scrivi(righe[n], campo, nuovo[campo]);
// Il campo che non c'era e resta vuoto non e' un cambiamento da raccontare: e' il
// segno "cercato, nessun vincolo", e sono il 96% delle mount.
if (!vecchio && !nuovo[campo]) continue;
cambi.push(`${campo.padEnd(5)} ${(vecchio || '-').padEnd(16)} -> ${(nuovo[campo] || '-').padEnd(22)} ${nome}`);
}
}
const testoNuovo = righe.join('\n');
JSON.parse(testoNuovo); // non si scrive un manifest rotto
writeFileSync(file, testoNuovo);
console.log(`vincoli: ${[...esiti.values()].filter((e) => e.class).length} di classe, ${[...esiti.values()].filter((e) => e.race).length} di razza, su ${esiti.size} controllate`);
if (cambi.length) {
console.log(`cambiati ${cambi.length}:`);
for (const c of cambi.sort()) console.log(' ' + c);
} else {
console.log('niente da cambiare.');
}Ancora nessun contenuto extra.