Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

raidemulator: Refactor CombatantState object #5387

Merged
merged 6 commits into from
May 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,070 changes: 1,070 additions & 0 deletions resources/world_id.ts

Large diffs are not rendered by default.

37 changes: 20 additions & 17 deletions ui/raidboss/emulator/data/AnalyzedEncounter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,12 @@ export default class AnalyzedEncounter extends EventBus {
const partyMember = this.encounter?.combatantTracker?.combatants[id];
if (!partyMember)
throw new UnreachableCode();
const initState = partyMember.nextSignificantState(0);
return {
id: id,
worldId: 0,
name: partyMember.name,
job: Util.jobToJobEnum(partyMember.job ?? 'NONE'),
name: initState.Name ?? '',
job: initState.Job ?? 0,
inParty: true,
};
}),
Expand All @@ -77,32 +78,32 @@ export default class AnalyzedEncounter extends EventBus {
timestamp: number,
popupText: PopupTextAnalysis | RaidEmulatorPopupText,
): void {
const job = combatant.job;
const state = combatant.getState(timestamp);
const job = state.Job;
if (!job)
throw new UnreachableCode();
const state = combatant.getState(timestamp);
popupText?.OnPlayerChange({
detail: {
id: parseInt(combatant.id),
name: combatant.name,
job: job,
level: combatant.level ?? 0,
currentHP: state.hp,
maxHP: state.maxHp,
currentMP: state.mp,
maxMP: state.maxMp,
id: state.ID ?? 0,
name: state.Name ?? '',
job: Util.jobEnumToJob(job),
level: state.Level ?? 0,
currentHP: state.CurrentHP,
maxHP: state.MaxHP,
currentMP: state.CurrentMP,
maxMP: state.MaxMP,
currentCP: 0,
maxCP: 0,
currentGP: 0,
maxGP: 0,
currentShield: 0,
jobDetail: null,
pos: {
x: state.posX,
y: state.posY,
z: state.posZ,
x: state.PosX,
y: state.PosY,
z: state.PosZ,
},
rotation: state.heading,
rotation: state.Heading,
bait: 0,
debugJob: '',
},
Expand Down Expand Up @@ -138,7 +139,9 @@ export default class AnalyzedEncounter extends EventBus {
if (!partyMember)
return;

if (!partyMember.job) {
const initState = partyMember?.nextSignificantState(0);

if (initState.Job === 0) {
this.perspectives[id] = {
initialData: {},
triggers: [],
Expand Down
128 changes: 46 additions & 82 deletions ui/raidboss/emulator/data/Combatant.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,20 @@
import { UnreachableCode } from '../../../../resources/not_reached';
import { Job } from '../../../../types/job';

import CombatantState from './CombatantState';

export default class Combatant {
id: string;
name = '';
server = '';
tempStates?: { [timestamp: number]: Partial<CombatantState> } = {};
states: { [timestamp: number]: CombatantState } = {};
significantStates: number[] = [];
latestTimestamp = -1;
job?: Job;
jobId?: number;
level?: number;
npcBaseId?: number;
ownerId?: number;
npcNameId?: number;

constructor(id: string, name: string) {
this.id = id;
this.setName(name);
}

setName(name: string): void {
// Sometimes network lines arrive after the combatant has been cleared
// from memory in the client, so the network line will have a valid ID
// but the name will be blank. Since we're tracking the name for the
// entire fight and not on a state-by-state basis, we don't want to
// blank out a name in this case.
// If a combatant actually has a blank name, that's still allowed by
// the constructor.
if (name === '')
return;

const parts = name.split('(');
this.name = parts[0] ?? '';
if (parts.length > 1)
this.server = parts[1]?.replace(/\)$/, '') ?? '';
}

hasState(timestamp: number): boolean {
return this.states[timestamp] !== undefined;
}

pushState(timestamp: number, state: CombatantState): void {
this.states[timestamp] = state;
this.latestTimestamp = timestamp;
if (!this.significantStates.includes(timestamp))
this.significantStates.push(timestamp);
if (!this.tempStates)
throw new Error('Invalid Combatant state');
this.tempStates[timestamp] = state;
}

nextSignificantState(timestamp: number): CombatantState {
Expand Down Expand Up @@ -75,50 +42,15 @@ export default class Combatant {
}

pushPartialState(timestamp: number, props: Partial<CombatantState>): void {
if (this.states[timestamp] === undefined) {
// Clone the last state before this timestamp
let stateTimestamp = this.significantStates[0] ?? timestamp;
// It's faster to start at the last timestamp and work our way backwards
// since realistically timestamp skew is only a couple ms at most
// Additionally, because cloning a 3000+ element array a few thousand times is slow,
// don't for-in over a reverse of the array
for (let i = this.significantStates.length - 1; i >= 0; --i) {
const ts = this.significantStates[i] ?? 0;
if (ts <= timestamp) {
stateTimestamp = ts;
break;
}
}

if (stateTimestamp === undefined)
throw new UnreachableCode();
const state = this.states[stateTimestamp];
if (!state)
throw new UnreachableCode();
this.states[timestamp] = state.partialClone(props);
if (!this.tempStates)
throw new Error('Invalid Combatant state');
if (this.tempStates[timestamp] === undefined) {
this.tempStates[timestamp] = props;
} else {
const state = this.states[timestamp];
const state = this.tempStates[timestamp];
if (!state)
throw new UnreachableCode();
this.states[timestamp] = state.partialClone(props);
}
this.latestTimestamp = Math.max(this.latestTimestamp, timestamp);

const lastSignificantStateTimestamp = this.significantStates[this.significantStates.length - 1];
if (!lastSignificantStateTimestamp)
throw new UnreachableCode();
const oldState = this.states[lastSignificantStateTimestamp];
const newState = this.states[timestamp];
if (!oldState || !newState)
throw new UnreachableCode();

if (
lastSignificantStateTimestamp !== timestamp &&
oldState.json &&
oldState.json !== newState.json
) {
delete oldState.json;
this.significantStates.push(timestamp);
this.tempStates[timestamp] = { ...state, ...props };
}
}

Expand Down Expand Up @@ -146,11 +78,43 @@ export default class Combatant {
}

finalize(): void {
for (const state of Object.values(this.states))
delete state.json;
if (!this.tempStates)
throw new Error('Invalid Combatant state');
const stateEntries = Object.entries(this.tempStates)
.sort((left, right) => left[0].localeCompare(right[0]));
if (stateEntries.length < 1)
return;
const lastState = {
key: parseInt(stateEntries[0]?.[0] ?? '0'),
state: new CombatantState(
stateEntries[0]?.[1] ?? {},
stateEntries[0]?.[1]?.targetable ?? false,
),
json: JSON.stringify(stateEntries[0]?.[1]),
};

this.states[lastState.key] = lastState.state;
this.significantStates.push(lastState.key);

for (const state of stateEntries.slice(1)) {
const curKey = parseInt(state[0] ?? '0');
let curState = lastState.state.partialClone(state[1]);
const curJson = JSON.stringify(curState);

if (curJson !== lastState.json) {
this.significantStates.push(curKey);
lastState.key = curKey;
lastState.state = curState;
lastState.json = curJson;
} else {
// Re-use state to reduce memory usage
curState = lastState.state;
}

this.states[curKey] = curState;
}

if (!this.significantStates.includes(this.latestTimestamp))
this.significantStates.push(this.latestTimestamp);
delete this.tempStates;
}

// Should only be called when `index` is valid.
Expand Down
123 changes: 61 additions & 62 deletions ui/raidboss/emulator/data/CombatantState.ts
Original file line number Diff line number Diff line change
@@ -1,76 +1,75 @@
import { worldNameToWorld } from '../../../../resources/world_id';
import { PluginCombatantState } from '../../../../types/event';

import Combatant from './Combatant';
export default class CombatantState implements PluginCombatantState {
CurrentWorldID?: number | undefined;
WorldID?: number | undefined;
WorldName?: string | undefined;
BNpcID?: number | undefined;
BNpcNameID?: number | undefined;
PartyType?: number | undefined;
ID?: number | undefined;
OwnerID?: number | undefined;
WeaponId?: number | undefined;
type?: number | undefined;
Job?: number | undefined;
Level?: number | undefined;
Name?: string | undefined;
CurrentHP: number;
MaxHP: number;
CurrentMP: number;
MaxMP: number;
PosX: number;
PosY: number;
PosZ: number;
Heading: number;

export default class CombatantState {
posX: number;
posY: number;
posZ: number;
heading: number;
targetable: boolean;
hp: number;
maxHp: number;
mp: number;
maxMp: number;

// This is a temporary variable used during CombatantTracker initialization and is `delete`d
// after the combatant states have been determined to keep memory usage low.
json?: string;
constructor(props: Partial<PluginCombatantState>, targetable: boolean) {
Object.assign(this, props);

// Force these values to something sane in case they're not in `props`
valarnin marked this conversation as resolved.
Show resolved Hide resolved
this.CurrentHP ??= 0;
this.MaxHP ??= 0;
this.CurrentMP ??= 0;
this.MaxMP ??= 0;
this.PosX ??= 0;
this.PosY ??= 0;
this.PosZ ??= 0;
this.Heading ??= 0;

constructor(
posX: number,
posY: number,
posZ: number,
heading: number,
targetable: boolean,
hp: number,
maxHp: number,
mp: number,
maxMp: number,
) {
this.posX = posX;
this.posY = posY;
this.posZ = posZ;
this.heading = heading;
this.targetable = targetable;
this.hp = hp;
this.maxHp = maxHp;
this.mp = mp;
this.maxMp = maxMp;
this.json = JSON.stringify(this);
}

partialClone(props: Partial<CombatantState>): CombatantState {
return new CombatantState(
props.posX ?? this.posX,
props.posY ?? this.posY,
props.posZ ?? this.posZ,
props.heading ?? this.heading,
props.targetable ?? this.targetable,
props.hp ?? this.hp,
props.maxHp ?? this.maxHp,
props.mp ?? this.mp,
props.maxMp ?? this.maxMp,
);
return new CombatantState({ ...this, ...props }, this.targetable);
}

fullClone(): CombatantState {
return new CombatantState({ ...this }, this.targetable);
}

toPluginState(combatant: Combatant): PluginCombatantState {
return {
ID: parseInt(combatant.id, 16),
Name: combatant.name,
Level: combatant.level,
Job: combatant.jobId,
PosX: this.posX,
PosY: this.posY,
PosZ: this.posZ,
Heading: this.heading,
CurrentHP: this.hp,
MaxHP: this.maxHp,
CurrentMP: this.mp,
MaxMP: this.maxMp,
OwnerID: combatant.ownerId,
BNpcNameID: combatant.npcNameId,
BNpcID: combatant.npcBaseId,
};
setName(name: string): void {
// Sometimes network lines arrive after the combatant has been cleared
// from memory in the client, so the network line will have a valid ID
// but the name will be blank. Since we're tracking the name for the
// entire fight and not on a state-by-state basis, we don't want to
// blank out a name in this case.
// If a combatant actually has a blank name, that's still allowed by
// the constructor.
if (name === '')
return;

const parts = name.split('(');
this.Name = parts[0] ?? '';
if (parts.length > 1) {
const worldName = parts[1]?.replace(/\)$/, '');
if (worldName !== undefined) {
const world = worldNameToWorld(worldName);
if (world !== undefined)
this.WorldID = world.id;
}
}
}
}
Loading