refactor: ReplaySystem.route -> ReplaySystem.array,修复 disabled 标记在 setReplayArray 中清零可能导致意外记录的问题

This commit is contained in:
unanmed 2026-09-17 19:18:30 +08:00
parent 3a3a6ac82d
commit a2a8e6ec35
19 changed files with 96 additions and 87 deletions

1
dev.md
View File

@ -118,6 +118,7 @@
- 通常情况下,一个文件应只包含一个类,但如果一个类仅是另一个类的依赖,或一个类的长度远远小于另一个类等特殊情况时,允许将多个类放在同一个文件中。
- 私有方法应该放到调用之的方法之前,同时保证其处在合理的 region 内。
- 对象的非空判断用 `!obj`,字面量的非空判断用 `!isNil(value)`。
- 任何情况下都不建议写 `try-catch-finally`,哪怕有时会因为报错导致某些关键状态产生错误,产生错误就应该让用户去修,而不是让系统兜底。
## 双端分离

View File

@ -192,7 +192,7 @@ export class HeroEquipment<THero> implements IHeroEquipment<THero> {
// 最后恢复录像记录并记录录像
replay.revert();
replay.route.add(ReplayCode.Equip, [uid]);
replay.array.add(ReplayCode.Equip, [uid]);
return curr;
}
@ -209,7 +209,7 @@ export class HeroEquipment<THero> implements IHeroEquipment<THero> {
// 记录录像
const replay = this.state.replaySystem;
replay.route.add(ReplayCode.Unequip, [slot]);
replay.array.add(ReplayCode.Unequip, [slot]);
this.unloadEquipEffect(state);
this.equips.delete(slot);
@ -346,16 +346,8 @@ export class HeroEquipment<THero> implements IHeroEquipment<THero> {
});
// 由于装备修饰器不进存档,所以此时的勇士处于没有任何装备修饰器的状态,故可以安全清除
this.equips.clear();
// 读档期间经 equip 恢复槽位会产生真实录像指令,故暂时禁用录像记录
const replay = this.state.replaySystem;
replay.disable();
try {
for (const [index, uid] of state.equipped) {
this.equip(uid, index);
}
} finally {
// 必须保证所有路径都恢复录像记录,否则会永久吞掉后续录像
replay.revert();
for (const [index, uid] of state.equipped) {
this.equip(uid, index);
}
}
}

View File

@ -127,7 +127,7 @@ export class HeroItems<THero> implements IHeroItems<THero> {
if (!raw.effect.canUse(raw)) return false;
const replay = this.state.replaySystem;
replay.route.add(ReplayCode.UseItem, [raw.num]);
replay.array.add(ReplayCode.UseItem, [raw.num]);
raw.effect.useEffect(raw);

View File

@ -176,16 +176,16 @@ export class HeroMover<T extends IHeroLocation>
const replay = this.state.replaySystem;
switch (handler.direction) {
case FaceDirection.Up:
replay.route.add(ReplayCode.Up, []);
replay.array.add(ReplayCode.Up, []);
break;
case FaceDirection.Right:
replay.route.add(ReplayCode.Right, []);
replay.array.add(ReplayCode.Right, []);
break;
case FaceDirection.Left:
replay.route.add(ReplayCode.Left, []);
replay.array.add(ReplayCode.Left, []);
break;
case FaceDirection.Down:
replay.route.add(ReplayCode.Down, []);
replay.array.add(ReplayCode.Down, []);
break;
default:
logger.warn(176);

View File

@ -853,7 +853,6 @@ export class ReplayArray implements IReplayArray {
paramBuffer: ArrayBuffer,
length: number
): void {
this.disabled = 0;
this.commandWidth = commandWidth;
this.commandBuffer = commandBuffer;
this.commandArray = new Uint8Array(commandBuffer);
@ -896,4 +895,8 @@ export class ReplayArray implements IReplayArray {
this.disabled--;
}
}
clearDisableFlag(): void {
this.disabled = 0;
}
}

View File

@ -66,7 +66,7 @@ export function beginReplaySafetyCollection(system: IReplaySystem): void {
}
replaySystem = system;
collecting = true;
beforeLength = system.route.length;
beforeLength = system.array.length;
shouldIgnore = false;
collection.messages.length = 0;
currentCollection = collection;
@ -82,7 +82,7 @@ export function endReplaySafetyCollection(): void {
}
try {
if (shouldIgnore) return;
if (replaySystem.route.length > beforeLength) return;
if (replaySystem.array.length > beforeLength) return;
if (collection.messages.length === 0) return;
// 需要把收集内容输出,这里只输出一层,完整输出需要在控制台手动调用

View File

@ -33,7 +33,7 @@ function createCommand(
// 用录像系统构造一个可手动驱动的录像沙箱
function createSandbox(system: ReplaySystem): IManualReplaySandbox {
return system.createReplaySandbox({
route: system.route,
route: system.array,
reseter: { reset: () => {} }
}) as IManualReplaySandbox;
}
@ -204,7 +204,7 @@ describe('ReplaySandbox stepping', () => {
system.record(1);
const sandbox = createSandbox(system);
start(sandbox);
system.route.add(1, []);
system.array.add(1, []);
await expect(sandbox.step()).resolves.toBe(false);
@ -226,7 +226,7 @@ describe('ReplaySandbox stepping', () => {
system.record(1);
system.record(1);
const sandbox = system.createReplaySandbox({
route: system.route,
route: system.array,
reseter: { reset: () => {} },
startIndex: 2
}) as IManualReplaySandbox;
@ -386,7 +386,7 @@ describe('ReplaySandbox playback control', () => {
);
system.record(1);
const sandbox = createSandbox(system);
system.route.add(1, []);
system.array.add(1, []);
sandbox.play();
await new Promise<void>(resolve => setTimeout(resolve, 0));

View File

@ -40,9 +40,9 @@ describe('ReplaySystem save and load round trips', () => {
system.replaying = true;
system.loadState(snapshot);
expect(system.route.length).toBe(1);
expect(system.route.commandWidth).toBe(snapshot.commandWidth);
expect(system.route.get(0)).toEqual({
expect(system.array.length).toBe(1);
expect(system.array.commandWidth).toBe(snapshot.commandWidth);
expect(system.array.get(0)).toEqual({
command: 3,
params: [1, true, 'param'],
index: 0
@ -60,9 +60,9 @@ describe('ReplaySystem save and load round trips', () => {
system.record(5, 9);
system.loadState(snapshot);
expect(system.route.getCommandArray()).toBe(snapshot.commandArray);
expect(system.route.getParamArray()).toBe(snapshot.paramArray);
expect(system.route.get(0).command).toBe(2);
expect(system.array.getCommandArray()).toBe(snapshot.commandArray);
expect(system.array.getParamArray()).toBe(snapshot.paramArray);
expect(system.array.get(0).command).toBe(2);
});
// 验证读档整体替换缓冲区后,此前创建的活跃沙箱读流被标记过期并触发告警码 156(#06-17-3)
@ -75,7 +75,7 @@ describe('ReplaySystem save and load round trips', () => {
system.record(1, 9);
const sandbox = system.createReplaySandbox({
route: system.route,
route: system.array,
reseter: { reset: () => {} }
});
system.loadState(snapshot);

View File

@ -41,8 +41,8 @@ describe('ReplaySystem registration and recording', () => {
it('builds an empty uint8 route', () => {
const system = new ReplaySystem();
expect(system.route.commandWidth).toBe(ReplayCommandWidth.Uint8);
expect(system.route.length).toBe(0);
expect(system.array.commandWidth).toBe(ReplayCommandWidth.Uint8);
expect(system.array.length).toBe(0);
});
// 验证注册后可取回命令且未知码返回 null
@ -85,8 +85,8 @@ describe('ReplaySystem registration and recording', () => {
system.record(5, 1, true);
expect(system.route.length).toBe(1);
expect(system.route.get(0)).toEqual({
expect(system.array.length).toBe(1);
expect(system.array.get(0)).toEqual({
command: 5,
params: [1, true],
index: 0
@ -102,8 +102,8 @@ describe('ReplaySystem registration and recording', () => {
first.record(1);
expect(second.getCommand(1)).toBeNull();
expect(second.route.length).toBe(0);
expect(first.route.length).toBe(1);
expect(second.array.length).toBe(0);
expect(first.array.length).toBe(1);
});
});
@ -123,7 +123,7 @@ describe('ReplaySystem sandbox lifecycle', () => {
const save = new Map<string, unknown>([['hp', 10]]);
const sandbox = system.createReplaySandbox({
route: system.route,
route: system.array,
reseter,
save
});
@ -138,7 +138,7 @@ describe('ReplaySystem sandbox lifecycle', () => {
const system = new ReplaySystem();
const { reseter } = createReseter();
const sandbox = system.createReplaySandbox({
route: system.route,
route: system.array,
reseter
});
const stop = vi.spyOn(sandbox, 'stop').mockResolvedValue(undefined);
@ -163,19 +163,19 @@ describe('ReplaySystem disable and revert', () => {
// 验证禁用后 record 被忽略,恢复后继续记录并委托给 route
it('delegates disable and revert to the route', () => {
const system = new ReplaySystem();
const disable = vi.spyOn(system.route, 'disable');
const revert = vi.spyOn(system.route, 'revert');
const disable = vi.spyOn(system.array, 'disable');
const revert = vi.spyOn(system.array, 'revert');
system.record(1);
system.disable();
system.record(2);
expect(disable).toHaveBeenCalledTimes(1);
expect(system.route.length).toBe(1);
expect(system.array.length).toBe(1);
system.revert();
system.record(3);
expect(revert).toHaveBeenCalledTimes(1);
expect(system.route.length).toBe(2);
expect(system.route.get(1).command).toBe(3);
expect(system.array.length).toBe(2);
expect(system.array.get(1).command).toBe(3);
});
});

View File

@ -24,14 +24,14 @@ export class ReplaySystem
{
replaying: boolean = false;
sandbox: IReplaySandbox | null = null;
readonly route: IReplayArray;
readonly array: IReplayArray;
/** 所有注册的指令 */
private readonly commands: Map<number, IReplayCommand> = new Map();
constructor() {
super();
this.route = new ReplayArray({
this.array = new ReplayArray({
initCommandLength: 1000,
initParamLength: 10000,
commandExpandMultiplier: 1.2,
@ -61,9 +61,9 @@ export class ReplaySystem
}
record(code: number, ...params: ReplayParamValue[]): void {
this.route.add(code, params);
this.array.add(code, params);
this.forEachHook(hook =>
hook.onRecordCommand?.(code, this.route.length - 1, params)
hook.onRecordCommand?.(code, this.array.length - 1, params)
);
}
@ -87,24 +87,24 @@ export class ReplaySystem
}
disable(): void {
this.route.disable();
this.array.disable();
}
revert(): void {
this.route.revert();
this.array.revert();
}
saveState(): IReplaySystemSave {
return {
length: this.route.length,
commandWidth: this.route.commandWidth,
commandArray: this.route.getCommandArray(),
paramArray: this.route.getParamArray()
length: this.array.length,
commandWidth: this.array.commandWidth,
commandArray: this.array.getCommandArray(),
paramArray: this.array.getParamArray()
};
}
loadState(state: IReplaySystemSave): void {
this.route.setReplayArray(
this.array.setReplayArray(
state.commandWidth,
state.commandArray,
state.paramArray,

View File

@ -292,9 +292,16 @@ export interface IReplayArray {
disable(): void;
/**
* 将录像记录功能从禁用状态恢复为启用状态
* 将录像记录功能从禁用状态恢复为上一个禁用状态。
* 具体来说,每次调用 `disable` 时都会使得录像禁用层数加一,此方法可以使其减一,直到减为 0。
* 这么做的目的是为了防止嵌套禁用调用时出现下层启用后上层意外记录录像的问题。
*/
revert(): void;
/**
* 清空录像记录的 `disable` 记录,直接跳出所有的 `disable` 层
*/
clearDisableFlag(): void;
}
export interface IReplaySystemHooks extends IHookBase {
@ -369,7 +376,7 @@ export interface IReplaySystem
/** 当前正在播放的录像沙箱实例 */
readonly sandbox: IReplaySandbox | null;
/** 当前的录像操作器,用于直接操作或读取录像数据 */
readonly route: IReplayArray;
readonly array: IReplayArray;
/**
* 注册一个录像命令

View File

@ -495,6 +495,8 @@ export class CoreState implements ICoreState {
state: ReadonlyMap<string, unknown>,
compression: SaveCompression
): void {
// 需要先清空录像的禁用标记
this.replaySystem.array.clearDisableFlag();
for (const [key, value] of this.saveables) {
// 使用 has 判断是否在映射中,而非值的非空判断,因为空值也有可能是存档的一部分
if (!state.has(key)) {
@ -502,7 +504,11 @@ export class CoreState implements ICoreState {
continue;
}
const data = state.get(key);
// 使用禁用录像包裹所有的读档行为,避免产生意外记录
this.replaySystem.disable();
value.loadState(data, compression);
this.replaySystem.revert();
// 但 Executor 不包裹,因为它确实可能产生需要记录的行为
const executor = this.executors.get(value);
if (executor) {
executor.afterLoad(value, this);

View File

@ -280,7 +280,7 @@ describe('replay commands', () => {
replay.record(ReplayCode.Right);
replay.record(ReplayCode.UseItem, 5);
const sandbox = replay.createReplaySandbox({
route: replay.route,
route: replay.array,
reseter: { reset: () => {} }
}) as IManualReplaySandbox;
sandbox.playing = true;

View File

@ -269,7 +269,7 @@ describe('DATA-01 closure', () => {
]);
state.replaySystem.record(ReplayCode.Right, 4, 'route');
expect(state.replaySystem.route.get(0)).toMatchObject({
expect(state.replaySystem.array.get(0)).toMatchObject({
command: ReplayCode.Right,
params: [4, 'route']
});
@ -291,7 +291,7 @@ describe('DATA-01 closure', () => {
replay.record(0);
replay.record(1);
const sandbox = replay.createReplaySandbox({
route: replay.route,
route: replay.array,
reseter: { reset: () => {} }
}) as IManualReplaySandbox;
sandbox.pausing = false;

View File

@ -169,7 +169,7 @@ export function createClosedLoopFixture(): IClosedLoopFixture {
const replay = state.replaySystem;
replay.record(ReplayCode.Right);
const route = replay.route;
const route = replay.array;
const initialState: IClosedLoopInitialState = {
hero: state.hero.saveState(SaveCompression.NoCompression),
@ -245,7 +245,7 @@ export function createClosedLoopFixture(): IClosedLoopFixture {
);
};
const sandbox = replay.createReplaySandbox({
route: replay.route,
route: replay.array,
reseter: { reset }
});

View File

@ -118,12 +118,12 @@ function createHarness(options: ITestHarnessOptions): ITestHarness {
const sandbox = { ended: false };
let cursor = 0;
const runtime: IReplayVerifierRuntime = {
route: replay.route,
route: replay.array,
expected: createSnapshot(),
sandbox,
getCommand: code => replay.getCommand(code),
step: async () => {
const step = replay.route.get(cursor++);
const step = replay.array.get(cursor++);
const command = replay.getCommand(step.command);
if (!command) return false;
return command.execute(step);

View File

@ -159,7 +159,7 @@ async function waitForEnded(sandbox: IReplaySandbox): Promise<void> {
async function playRoute(state: CoreState): Promise<void> {
const replay = state.replaySystem;
const sandbox = replay.createReplaySandbox({
route: replay.route,
route: replay.array,
reseter: { reset: () => resetHero(state) }
});
replay.disable();
@ -236,16 +236,16 @@ describe('replay recording and route read-back', () => {
replay.disable();
replay.record(ReplayCode.Right);
expect(replay.route.length).toBe(0);
expect(replay.array.length).toBe(0);
replay.disable();
replay.revert();
replay.record(ReplayCode.Right);
expect(replay.route.length).toBe(0);
expect(replay.array.length).toBe(0);
replay.revert();
replay.record(ReplayCode.Right);
expect(replay.route.length).toBe(1);
expect(replay.array.length).toBe(1);
});
// 验证八个稳定指令码及其参数都能经录像数组逐条读回
@ -258,24 +258,24 @@ describe('replay recording and route read-back', () => {
replay.record(ReplayCode.Equip, 99, 1, true);
replay.record(ReplayCode.Unequip, 1);
expect(replay.route.length).toBe(5);
expect(replay.route.get(0)).toMatchObject({
expect(replay.array.length).toBe(5);
expect(replay.array.get(0)).toMatchObject({
command: ReplayCode.Up,
params: []
});
expect(replay.route.get(1)).toMatchObject({
expect(replay.array.get(1)).toMatchObject({
command: ReplayCode.Teleport,
params: [3, 4]
});
expect(replay.route.get(2)).toMatchObject({
expect(replay.array.get(2)).toMatchObject({
command: ReplayCode.UseItem,
params: [12]
});
expect(replay.route.get(3)).toMatchObject({
expect(replay.array.get(3)).toMatchObject({
command: ReplayCode.Equip,
params: [99, 1, true]
});
expect(replay.route.get(4)).toMatchObject({
expect(replay.array.get(4)).toMatchObject({
command: ReplayCode.Unequip,
params: [1]
});
@ -286,12 +286,12 @@ describe('replay recording and route read-back', () => {
const state = createCoreState();
withReplayDisabled(state, () => createSmallMapScene(state));
expect(state.replaySystem.route.length).toBe(0);
expect(state.replaySystem.array.length).toBe(0);
await runHeroStep(state, FaceDirection.Right);
expect(state.replaySystem.route.length).toBe(1);
expect(state.replaySystem.route.get(0)).toMatchObject({
expect(state.replaySystem.array.length).toBe(1);
expect(state.replaySystem.array.get(0)).toMatchObject({
command: ReplayCode.Right,
params: []
});
@ -325,14 +325,14 @@ describe('small-map replay playback and second recording', () => {
replay.record(ReplayCode.Teleport, 1, 0);
replay.record(ReplayCode.Up);
const firstSteps = snapshotRoute(replay.route);
const firstSteps = snapshotRoute(replay.array);
expect(firstSteps.map(item => item.code)).toEqual([
ReplayCode.Right,
ReplayCode.Right,
ReplayCode.Teleport,
ReplayCode.Up
]);
expect(replay.route.get(2)).toMatchObject({
expect(replay.array.get(2)).toMatchObject({
command: ReplayCode.Teleport,
params: [1, 0]
});
@ -342,7 +342,7 @@ describe('small-map replay playback and second recording', () => {
expect(state.hero.location.y).toBe(0);
replay.loadState(emptyReplay, SaveCompression.NoCompression);
expect(replay.route.length).toBe(0);
expect(replay.array.length).toBe(0);
withReplayDisabled(state, () => resetHero(state));
await runHeroStep(state, FaceDirection.Right);
@ -350,7 +350,7 @@ describe('small-map replay playback and second recording', () => {
replay.record(ReplayCode.Teleport, 1, 0);
replay.record(ReplayCode.Up);
const secondSteps = snapshotRoute(replay.route);
const secondSteps = snapshotRoute(replay.array);
expectReplayEqual(secondSteps, firstSteps);
await playRoute(state);
@ -480,7 +480,7 @@ describe('replay playback error codes 2001-2008', () => {
// 验证录像不可存档属主为 ReplaySystem,ReplayArray 不再提供 saveState/loadState
it('keeps the saveable owner on ReplaySystem instead of ReplayArray', () => {
const state = createCoreState();
const route = state.replaySystem.route;
const route = state.replaySystem.array;
const save = state.replaySystem.saveState(
SaveCompression.NoCompression
);

View File

@ -240,7 +240,7 @@ function assertRestored(state: CoreState, seeded: SeededState): void {
expect(state.enemyManager.getPrefab(1)!.getAttribute('hp')).toBe(30);
expect(state.enemyManager.getPrefab(1)!.getAttribute('atk')).toBe(9);
const route = state.replaySystem.route;
const route = state.replaySystem.array;
expect(route.length).toBe(REPLAY_STEPS.length);
REPLAY_STEPS.forEach((step, index) => {
expect(route.get(index)).toEqual({
@ -454,13 +454,13 @@ describe('CoreState container coverage for compression-less saveables', () => {
expect(state.flags.getFieldValue<number>('score')).toBe(5);
expect(state.flags.getFieldValue<number>('stage')).toBe(3);
expect(state.replaySystem.route.length).toBe(2);
expect(state.replaySystem.route.get(0)).toEqual({
expect(state.replaySystem.array.length).toBe(2);
expect(state.replaySystem.array.get(0)).toEqual({
command: 1,
params: [],
index: 0
});
expect(state.replaySystem.route.get(1)).toEqual({
expect(state.replaySystem.array.get(1)).toEqual({
command: 2,
params: [7],
index: 1

View File

@ -99,7 +99,7 @@ export class PathfindingSystem implements IPathfindingSystem {
// TODO:后续需要调整设计方式,最好不用 instanceof
if (this.mover instanceof HeroMover) {
const replay = this.state.replaySystem;
replay.route.add(ReplayCode.Teleport, [target.x, target.y]);
replay.array.add(ReplayCode.Teleport, [target.x, target.y]);
}
return this.startMove(path, true);
}