Update class mechanics, stat rulesets, and CoA reference catalogs

This commit is contained in:
phenom committed 2026-10-04 12:56:30 -04:00
1 parent b16821e92e
commit 39458d5de6
221 files changed
+2584962 -568

No files matched your search

+99 -26
View File
@@ -39,6 +39,7 @@ import {
} from "./companionSpecReadiness";
import type { RuntimeTalentRanks as TalentRanks } from "./talentRuntimeCatalog";
import type { EnemyControlMechanic } from "./enemyAttacks";
import { normalizeCharacterStatRuleset, type CharacterStatRuleset } from "./characterStatRuleset";
import type { ContentCategoryId } from "../app/contentCategories";
import { isValidRomClassPair, ROM_SECONDARY_UNLOCK_LEVEL } from "../app/romDualClass";
import type { PartyRole, PartySize } from "./partyRoles";
@@ -89,6 +90,7 @@ export interface PartyMember {
readonly gender: GenderId;
readonly appearance: CharacterAppearance;
readonly classId: ClassId;
readonly statRuleset?: CharacterStatRuleset;
readonly secondaryClassId?: ClassId | null;
readonly specialization: string;
readonly specializationId: string | null;
@@ -106,6 +108,7 @@ export interface PartyMember {
readonly resource: number;
readonly maxResource: number;
readonly resourcePools: Readonly<Partial<Record<ResourceType, number>>>;
readonly resourceMaximums?: Readonly<Partial<Record<ResourceType, number>>>;
readonly lastResourceSpentAt: Readonly<Partial<Record<ResourceType, number>>>;
readonly warriorAngerManagementRageRemainderMs?: number;
readonly comboPoints: number;
@@ -127,8 +130,10 @@ export interface PartyMember {
interface PartyTemplate {
readonly name: string;
readonly classId: ClassId;
readonly statRuleset?: CharacterStatRuleset;
readonly secondaryClassId?: ClassId | null;
readonly raceId?: RaceId;
readonly gender?: GenderId;
readonly specialization: string;
readonly talentTreeId: string;
readonly talentProfileId?: string | null;
@@ -137,8 +142,10 @@ interface PartyTemplate {
export interface ConfiguredPartyMemberSeed {
readonly name: string;
readonly classId: ClassId;
readonly statRuleset?: CharacterStatRuleset;
readonly secondaryClassId?: ClassId | null;
readonly raceId?: RaceId;
readonly gender?: GenderId;
readonly specialization: string;
readonly specializationId?: string | null;
readonly talentTreeId?: string;
@@ -195,20 +202,20 @@ function wowTemplates(
}
const ROM_TANK_TEMPLATES: readonly PartyTemplate[] = Object.freeze([
{ name: "Aldric", classId: "rom-warrior", specialization: "Primary", talentTreeId: "" },
{ ...romFiveIdentity("rom-knight", "tank")!, specialization: "Primary", talentTreeId: "" },
{ name: "Maerwen", classId: "rom-warden", specialization: "Primary", talentTreeId: "" },
{ name: "Bronn", classId: "rom-champion", specialization: "Primary", talentTreeId: "" },
{ name: "Aldric", classId: "rom-warrior", statRuleset: "rom", specialization: "Primary", talentTreeId: "" },
{ ...romFiveIdentity("rom-knight", "tank")!, statRuleset: "rom", specialization: "Primary", talentTreeId: "" },
{ name: "Maerwen", classId: "rom-warden", statRuleset: "rom", specialization: "Primary", talentTreeId: "" },
{ name: "Bronn", classId: "rom-champion", statRuleset: "rom", specialization: "Primary", talentTreeId: "" },
]);
const ROM_HEALER_TEMPLATES: readonly PartyTemplate[] = Object.freeze([
{ ...romFiveIdentity("rom-priest", "healer")!, specialization: "Primary", talentTreeId: "" },
{ name: "Sylva", classId: "rom-druid", specialization: "Primary", talentTreeId: "" },
{ ...romFiveIdentity("rom-priest", "healer")!, statRuleset: "rom", specialization: "Primary", talentTreeId: "" },
{ name: "Sylva", classId: "rom-druid", statRuleset: "rom", specialization: "Primary", talentTreeId: "" },
]);
const ROM_DAMAGE_TEMPLATES: readonly PartyTemplate[] = Object.freeze([
{ ...romFiveIdentity("rom-scout", "damage")!, specialization: "Primary", talentTreeId: "" },
{ ...romFiveIdentity("rom-rogue", "damage")!, specialization: "Primary", talentTreeId: "" },
{ ...romFiveIdentity("rom-mage", "damage")!, specialization: "Primary", talentTreeId: "" },
{ name: "Morgha", classId: "rom-warlock", specialization: "Primary", talentTreeId: "" },
{ ...romFiveIdentity("rom-scout", "damage")!, statRuleset: "rom", specialization: "Primary", talentTreeId: "" },
{ ...romFiveIdentity("rom-rogue", "damage")!, statRuleset: "rom", specialization: "Primary", talentTreeId: "" },
{ ...romFiveIdentity("rom-mage", "damage")!, statRuleset: "rom", specialization: "Primary", talentTreeId: "" },
{ name: "Morgha", classId: "rom-warlock", statRuleset: "rom", specialization: "Primary", talentTreeId: "" },
]);
export const TANK_SPECIALIZATIONS = Object.freeze(new Set(
@@ -251,10 +258,16 @@ function memberMaxHealth(
level: number,
role: PartyRole,
gearStats: ItemStats = EMPTY_ITEM_STATS,
_classId: ClassId = "warrior",
_raceId: RaceId = "human",
classId: ClassId = "warrior",
raceId: RaceId = "human",
statRuleset: CharacterStatRuleset = "wow",
secondaryClassId?: ClassId | null,
gender: GenderId = "male",
): number {
const safeLevel = Math.max(1, Math.trunc(level));
if (statRuleset === "rom") {
return deriveCharacterStats(classId, safeLevel, gearStats, raceId, {}, secondaryClassId, gender, statRuleset).health;
}
const base = role === "tank"
? 185 + safeLevel * 32
: role === "healer"
@@ -268,10 +281,13 @@ function memberMaxResource(
level: number,
raceId: RaceId,
gearStats: ItemStats = EMPTY_ITEM_STATS,
statRuleset: CharacterStatRuleset = "wow",
secondaryClassId?: ClassId | null,
gender: GenderId = "male",
): number {
const profile = resourceProfileForClass(classId);
return profile.type === "mana"
? deriveCharacterStats(classId, level, gearStats, raceId).mana
? deriveCharacterStats(classId, level, gearStats, raceId, {}, secondaryClassId, gender, statRuleset).mana
: profile.maximum;
}
@@ -280,6 +296,12 @@ function secondaryResourceMaximum(type: ResourceType, member: PartyMember): numb
return naturePowerMaximumForCharacter(member.classId, member.secondaryClassId, member.level);
}
if (type === "psi") return 6;
if (type === "mana" && member.statRuleset === "rom") {
return deriveCharacterStats(
member.classId, member.level, member.gearStats, member.raceId, {}, member.secondaryClassId,
member.gender, "rom",
).mana;
}
return 100;
}
@@ -292,6 +314,19 @@ function initialSecondaryResource(type: ResourceType, maximum: number): number {
/** Keeps every resource used by the member's learned kit available to companion AI. */
function withMemberAbilityResourcePools(member: PartyMember): PartyMember {
const types = new Set<ResourceType>([member.resourceType]);
if (member.statRuleset === "rom" && isRomClassId(member.classId)
&& deriveCharacterStats(
member.classId,
member.level,
member.gearStats,
member.raceId,
{},
member.secondaryClassId,
member.gender,
"rom",
).romNative?.usesMana) {
types.add("mana");
}
for (const ability of abilitiesForCharacter(member.classId, member.secondaryClassId)) {
const learned = learnedAbilityForBuild(ability, member.level, member.talentRanks);
if (!learned) continue;
@@ -300,18 +335,26 @@ function withMemberAbilityResourcePools(member: PartyMember): PartyMember {
}
}
const resourcePools: Partial<Record<ResourceType, number>> = {};
const resourceMaximums: Partial<Record<ResourceType, number>> = {};
for (const type of types) {
const maximum = type === member.resourceType
? member.maxResource
: member.resourceMaximums?.[type] ?? secondaryResourceMaximum(type, member);
resourceMaximums[type] = maximum;
if (type === member.resourceType) {
resourcePools[type] = member.resource;
continue;
}
const maximum = secondaryResourceMaximum(type, member);
resourcePools[type] = Math.max(0, Math.min(
maximum,
member.resourcePools[type] ?? initialSecondaryResource(type, maximum),
));
}
return { ...member, resourcePools: Object.freeze(resourcePools) };
return {
...member,
resourcePools: Object.freeze(resourcePools),
resourceMaximums: Object.freeze(resourceMaximums),
};
}
function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMember {
@@ -321,16 +364,22 @@ function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMe
gearStats,
member.classId,
member.raceId,
member.statRuleset,
member.secondaryClassId,
member.gender,
);
const health = member.health <= 0
? 0
: Math.max(1, Math.round(member.health / Math.max(1, member.maxHealth) * maxHealth));
const maxResource = memberMaxResource(member.classId, member.level, member.raceId, gearStats);
const maxResource = memberMaxResource(
member.classId, member.level, member.raceId, gearStats, member.statRuleset, member.secondaryClassId, member.gender,
);
const resource = Math.max(0, Math.round(
member.resource / Math.max(1, member.maxResource) * maxResource,
));
return withMemberAbilityResourcePools({
...member,
resourceMaximums: undefined,
gearStats,
health,
maxHealth,
@@ -367,9 +416,16 @@ function createMember(
const race = racePool.find((candidate) => candidate.id === template.raceId)
?? racePool[Math.floor(nextRandom(identityRandom) * racePool.length)] ?? racePool[0];
const genders = supportedGenders(race.id);
const gender: GenderId = genders[Math.floor(nextRandom(identityRandom) * genders.length)] ?? genders[0] ?? "male";
const gender: GenderId = template.gender
?? genders[Math.floor(nextRandom(identityRandom) * genders.length)]
?? genders[0]
?? "male";
const appearance = randomAppearance(race.id, gender, () => nextRandom(identityRandom));
const id = `party-${role}-${index}-${template.classId}`;
const statRuleset = normalizeCharacterStatRuleset(
template.statRuleset ?? "wow",
template.classId,
);
const specializationId = specializationsForClass(template.classId)
.find((definition) => definition.displayName === template.specialization)?.id ?? null;
const gearStats = resolvedEquipmentStats({}, [], {
@@ -381,9 +437,14 @@ function createMember(
role,
level,
});
const maxHealth = memberMaxHealth(level, role, gearStats, template.classId, race.id);
const secondaryClassId = level >= ROM_SECONDARY_UNLOCK_LEVEL ? template.secondaryClassId ?? null : null;
const maxHealth = memberMaxHealth(
level, role, gearStats, template.classId, race.id, statRuleset, secondaryClassId, gender,
);
const resourceProfile = resourceProfileForClass(template.classId);
const maxResource = memberMaxResource(template.classId, level, race.id, gearStats);
const maxResource = memberMaxResource(
template.classId, level, race.id, gearStats, statRuleset, secondaryClassId, gender,
);
const resource = Math.round(
maxResource * (resourceProfile.maximum > 0 ? resourceProfile.initial / resourceProfile.maximum : 0),
);
@@ -394,7 +455,8 @@ function createMember(
gender,
appearance,
classId: template.classId,
secondaryClassId: level >= ROM_SECONDARY_UNLOCK_LEVEL ? template.secondaryClassId ?? null : null,
statRuleset,
secondaryClassId,
specialization: template.specialization,
specializationId,
talentTreeId: template.talentTreeId,
@@ -439,8 +501,10 @@ export function createConfiguredPartyMember(
const member = createMember({
name: configuration.name,
classId: configuration.classId,
statRuleset: configuration.statRuleset ?? "wow",
secondaryClassId: configuration.secondaryClassId,
raceId: configuration.raceId,
gender: configuration.gender,
specialization: configuration.specialization,
talentTreeId: configuration.talentTreeId ?? configuration.specializationId ?? "",
talentProfileId: configuration.talentProfileId ?? null,
@@ -583,6 +647,7 @@ export interface PartyState {
| "resource"
| "maxResource"
| "resourcePools"
| "resourceMaximums"
| "lastResourceSpentAt"
| "cooldowns"
| "globalCooldownEndsAt"
@@ -678,12 +743,21 @@ export const usePartyStore = create<PartyState>((set, get) => ({
if (state.members.every((member) => member.level === safeLevel)) return state;
return {
members: state.members.map((member) => {
const nextSecondaryClassId = isRomClassId(member.classId)
&& romFiveIdentity(member.classId, member.role)?.name === member.name
? (safeLevel >= ROM_SECONDARY_UNLOCK_LEVEL
? romFiveIdentity(member.classId, member.role)?.secondaryClassId ?? null
: null)
: member.secondaryClassId;
const maxHealth = memberMaxHealth(
safeLevel,
member.role,
member.gearStats,
member.classId,
member.raceId,
member.statRuleset,
nextSecondaryClassId,
member.gender,
);
const health = member.health <= 0
? 0
@@ -693,19 +767,18 @@ export const usePartyStore = create<PartyState>((set, get) => ({
safeLevel,
member.raceId,
member.gearStats,
member.statRuleset,
nextSecondaryClassId,
member.gender,
);
const resource = Math.max(0, Math.round(
member.resource / Math.max(1, member.maxResource) * maxResource,
));
return withMemberAbilityResourcePools({
...member,
resourceMaximums: undefined,
level: safeLevel,
secondaryClassId: isRomClassId(member.classId)
&& romFiveIdentity(member.classId, member.role)?.name === member.name
? (safeLevel >= ROM_SECONDARY_UNLOCK_LEVEL
? romFiveIdentity(member.classId, member.role)?.secondaryClassId ?? null
: null)
: member.secondaryClassId,
secondaryClassId: nextSecondaryClassId,
health,
maxHealth,
resource,