144 lines
5.9 KiB
TypeScript
144 lines
5.9 KiB
TypeScript
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<Record<string, CompanionSpecReadinessStatus>>;
|
|
}
|
|
|
|
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<string>(BASE_CLASS_IDS);
|
|
const READY_IDS = new Set<string>(READY_COMPANION_SPECIALIZATION_IDS);
|
|
const VALID_STATUSES = new Set<CompanionSpecReadinessStatus>(["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<CompanionSpecReadinessPolicy>;
|
|
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<Record<string, CompanionSpecReadinessStatus>>)[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<PublishedCompanionSpecReadinessPolicy> | 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 };
|
|
}
|
|
}
|