import { BASE_CLASS_IDS, ROM_CLASS_IDS } from "../app/characterCatalog"; import { partyRolesForClass, type PartyRole } from "./partyRoles"; import { SPECIALIZATION_DEFINITIONS, romGearProfile, type SpecializationDefinition, } from "./specializationCatalog"; export type CompanionSpecReadinessStatus = "ready" | "beta" | "needs-testing"; export interface CompanionSpecReadinessPolicy { readonly schemaVersion: 1; readonly statuses: Readonly>; } export interface PublishedCompanionSpecReadinessPolicy { readonly revision: number; readonly updatedAt: number | null; readonly updatedBy?: string | null; readonly config: CompanionSpecReadinessPolicy; } export const READY_COMPANION_SPECIALIZATION_IDS = Object.freeze([ "warrior:protection", "priest:discipline", "hunter:marksmanship", "mage:fire", "rogue:assassination", ] as const); const CLASSIC_CLASS_IDS = new Set(BASE_CLASS_IDS); const READY_IDS = new Set(READY_COMPANION_SPECIALIZATION_IDS); const VALID_STATUSES = new Set(["ready", "beta", "needs-testing"]); export const WOW_COMPANION_SPECIALIZATIONS: readonly SpecializationDefinition[] = Object.freeze( SPECIALIZATION_DEFINITIONS.filter((definition) => CLASSIC_CLASS_IDS.has(definition.classId)), ); /** * Complete certification catalog. CoA specializations and RoM class/role * profiles are visible as Needs Testing, but remain outside the publishable * WoW policy until their companion implementations enter certification. */ export const COMPANION_READINESS_SPECIALIZATIONS: readonly SpecializationDefinition[] = Object.freeze([ ...SPECIALIZATION_DEFINITIONS, ...ROM_CLASS_IDS.flatMap((classId) => partyRolesForClass(classId).map((role) => ( romGearProfile(classId, role) ))), ]); const WOW_SPECIALIZATION_IDS = new Set(WOW_COMPANION_SPECIALIZATIONS.map((definition) => definition.id)); export const DEFAULT_COMPANION_SPEC_READINESS_POLICY: CompanionSpecReadinessPolicy = Object.freeze({ schemaVersion: 1, statuses: Object.freeze(Object.fromEntries( WOW_COMPANION_SPECIALIZATIONS.map((definition) => [ definition.id, (READY_IDS.has(definition.id) ? "ready" : "needs-testing") as CompanionSpecReadinessStatus, ]), )), }); function invalid(message: string): Error { return new Error(message); } export function readinessStatusFor( specializationId: string | null | undefined, policy: CompanionSpecReadinessPolicy = DEFAULT_COMPANION_SPEC_READINESS_POLICY, ): CompanionSpecReadinessStatus { if (!specializationId || !WOW_SPECIALIZATION_IDS.has(specializationId)) return "needs-testing"; return policy.statuses[specializationId] ?? "needs-testing"; } export function validateReadyRoleCoverage(policy: CompanionSpecReadinessPolicy): void { const readyByRole = (role: PartyRole) => WOW_COMPANION_SPECIALIZATIONS.filter((definition) => ( definition.role === role && readinessStatusFor(definition.id, policy) === "ready" )); if (readyByRole("tank").length < 1) throw invalid("At least one tank specialization must be Ready."); if (readyByRole("healer").length < 1) throw invalid("At least one healer specialization must be Ready."); if (new Set(readyByRole("damage").map((definition) => definition.id)).size < 3) { throw invalid("At least three distinct damage specializations must be Ready."); } } export function normalizeCompanionSpecReadinessPolicy(value: unknown): CompanionSpecReadinessPolicy { if (!value || typeof value !== "object" || Array.isArray(value)) { throw invalid("Companion readiness policy must be an object."); } const input = value as Partial; if (input.schemaVersion !== 1) throw invalid("Companion readiness policy schemaVersion must be 1."); if (!input.statuses || typeof input.statuses !== "object" || Array.isArray(input.statuses)) { throw invalid("Companion readiness statuses are required."); } for (const [specializationId, status] of Object.entries(input.statuses)) { if (!WOW_SPECIALIZATION_IDS.has(specializationId)) { throw invalid(`Unknown WoW specialization: ${specializationId}.`); } if (!VALID_STATUSES.has(status as CompanionSpecReadinessStatus)) { throw invalid(`Invalid readiness status for ${specializationId}.`); } } const policy: CompanionSpecReadinessPolicy = Object.freeze({ schemaVersion: 1, statuses: Object.freeze(Object.fromEntries(WOW_COMPANION_SPECIALIZATIONS.map((definition) => [ definition.id, (input.statuses as Readonly>)[definition.id] ?? "needs-testing", ]))), }); validateReadyRoleCoverage(policy); return policy; } export function companionSpecializationsForPolicy( policy: CompanionSpecReadinessPolicy, includeBetaSpecs: boolean, role?: PartyRole, ): readonly SpecializationDefinition[] { return WOW_COMPANION_SPECIALIZATIONS.filter((definition) => { if (role && definition.role !== role) return false; const status = readinessStatusFor(definition.id, policy); return status === "ready" || (includeBetaSpecs && status === "beta"); }); } export function cloneDefaultCompanionSpecReadinessPolicy(): CompanionSpecReadinessPolicy { return JSON.parse(JSON.stringify(DEFAULT_COMPANION_SPEC_READINESS_POLICY)) as CompanionSpecReadinessPolicy; } export function companionSpecReadinessFromCache(value: string | null): PublishedCompanionSpecReadinessPolicy { try { const parsed = JSON.parse(value ?? "null") as Partial | null; if (!parsed || !Number.isInteger(parsed.revision)) throw new Error("missing cache"); return { revision: parsed.revision!, updatedAt: parsed.updatedAt ?? null, updatedBy: parsed.updatedBy ?? null, config: normalizeCompanionSpecReadinessPolicy(parsed.config), }; } catch { return { revision: 0, updatedAt: null, updatedBy: null, config: DEFAULT_COMPANION_SPEC_READINESS_POLICY }; } }