Files
healer-man/src/game/companionSpecReadiness.ts
T
2026-09-20 21:50:50 -04:00

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 };
}
}