fix(07-11): #06-17-6 preserve follower instances across loadState

- HeroFollowersController 新增 restoreFollowers 保留式恢复入口
- 同索引同图块数字原地 loadState 并保留实例;否则替换槽位并按既有语义触发增删钩子
- 存档中不存在的末尾跟随者按存档为准删除
- HeroState.loadState 改用 restoreFollowers,不再 removeAllFollowers 全量重建(保持同步)
- IHeroFollowersController 补声明与 jsDoc;新增同实例/替换/删除/补齐回归用例
This commit is contained in:
unanmed 2026-09-17 13:53:46 +08:00
parent 9964326210
commit 78f6796915
5 changed files with 210 additions and 7 deletions

View File

@ -5,6 +5,7 @@ import {
Dir8FaceHandler,
FaceDirection,
ItemStore,
SaveCompression,
TileStore,
TileType
} from '@user/data-common';
@ -266,3 +267,103 @@ describe('HeroFollowersController gathering', () => {
expect(result.ret.num).toBe(0);
});
});
describe('HeroFollowersController same-reference restore (#06-17-6)', () => {
// 验证同索引同图块数字的跟随者在原地读档,且不触发增删钩子
it('keeps the follower instance when the index and number match', () => {
const env = createController();
const added: number[] = [];
const removed: number[] = [];
env.controller
.addHook({
onAddFollower: (_follower, index) => added.push(index),
onRemoveFollower: (_follower, index) => removed.push(index)
})
.load();
const follower = env.controller.addFollower(100);
follower.location.setPos(2, 3);
follower.rendering.setAlpha(0.5);
const saved = [follower.saveState(SaveCompression.NoCompression)];
follower.location.setPos(9, 9);
follower.rendering.setAlpha(1);
env.controller.restoreFollowers(saved, SaveCompression.NoCompression);
expect(env.controller.getFollower(0)).toBe(follower);
expect(follower.location.x).toBe(2);
expect(follower.location.y).toBe(3);
expect(follower.rendering.alpha).toBe(0.5);
expect(added).toEqual([0]);
expect(removed).toEqual([]);
});
// 验证同索引但图块数字不同的槽位替换实例并通知移除与新增钩子
it('replaces a slot with a different number and notifies both hooks', () => {
const env = createController();
const added: number[] = [];
const removed: number[] = [];
env.controller
.addHook({
onAddFollower: (_follower, index) => added.push(index),
onRemoveFollower: (_follower, index) => removed.push(index)
})
.load();
const follower = env.controller.addFollower(100);
const save = follower.saveState(SaveCompression.NoCompression);
env.controller.restoreFollowers(
[{ ...save, num: 101 }],
SaveCompression.NoCompression
);
expect(env.controller.getFollower(0)).not.toBe(follower);
expect(env.controller.getFollower(0)!.num).toBe(101);
expect(added).toEqual([0, 0]);
expect(removed).toEqual([0]);
});
// 验证存档短于现有列表时末尾跟随者被删除并通知移除钩子
it('deletes trailing followers absent from the save', () => {
const env = createController();
const removed: number[] = [];
env.controller
.addHook({
onRemoveFollower: (_follower, index) => removed.push(index)
})
.load();
const first = env.controller.addFollower(100);
env.controller.addFollower(100);
env.controller.restoreFollowers(
[first.saveState(SaveCompression.NoCompression)],
SaveCompression.NoCompression
);
expect(env.controller.getAllFollowers()).toEqual([first]);
expect(removed).toEqual([1]);
});
// 验证存档长于现有列表时按索引补齐新跟随者并通知新增钩子
it('appends followers missing from the live list', () => {
const env = createController();
const added: number[] = [];
env.controller
.addHook({
onAddFollower: (_follower, index) => added.push(index)
})
.load();
const first = env.controller.addFollower(100);
const save = first.saveState(SaveCompression.NoCompression);
env.controller.restoreFollowers(
[save, { ...save }],
SaveCompression.NoCompression
);
const all = env.controller.getAllFollowers();
expect(all[0]).toBe(first);
expect(all.length).toBe(2);
expect(added).toEqual([0, 1]);
});
});

View File

@ -113,13 +113,21 @@ export class HeroFollowersController
return new HookController(this, hook);
}
addFollower(num: number | string): IHeroFollower {
/**
* 在勇士当前位置创建一个跟随者对象,此时不加入跟随者列表
* @param num 跟随者的图块数字或图块 id
*/
private createFollower(num: number | string): HeroFollower {
const loc: IFacedTileLocator = {
x: this.heroLocation.x,
y: this.heroLocation.y,
direction: this.heroLocation.mover.faceDirection
};
const follower = new HeroFollower(num, loc, this.faceHandler, this);
return new HeroFollower(num, loc, this.faceHandler, this);
}
addFollower(num: number | string): IHeroFollower {
const follower = this.createFollower(num);
this.followers.push(follower);
const index = this.followers.length - 1;
this.forEachHook(hook => {
@ -128,6 +136,41 @@ export class HeroFollowersController
return follower;
}
restoreFollowers(
saves: readonly IHeroFollowerSave[],
compression: SaveCompression
): void {
// 以存档为准:先删除末尾多出的跟随者,并按原索引通知移除
const extra = this.followers.splice(saves.length);
for (let i = 0; i < extra.length; i++) {
const index = saves.length + i;
this.forEachHook(hook => {
hook.onRemoveFollower?.(extra[i], index);
});
}
for (let i = 0; i < saves.length; i++) {
const save = saves[i];
const current = this.followers[i];
// 索引相同且图块数字相同:在保留的实例上原地读档,此时不触发增删钩子
if (current && current.num === save.num) {
current.loadState(save, compression);
continue;
}
// 索引不同或图块数字不同:该槽位替换为新实例,并按既有钩子语义通知增删
if (current) {
this.forEachHook(hook => {
hook.onRemoveFollower?.(current, i);
});
}
const follower = this.createFollower(save.num);
follower.loadState(save, compression);
this.followers[i] = follower;
this.forEachHook(hook => {
hook.onAddFollower?.(follower, i);
});
}
}
getFollower(index: number): IHeroFollower | null {
return this.followers[index] ?? null;
}

View File

@ -204,6 +204,15 @@ function createHeroState(): IHeroState<IHeroAttr> {
);
}
/** 构造一个已注册跟随者图块、可直接添加跟随者的勇士状态对象 */
function createFollowerHero(): IHeroState<IHeroAttr> {
return new HeroState<IHeroAttr>(
createFollowerState(),
new Dir8FaceHandler(),
new HeroAttribute<IHeroAttr>(createBaseAttr())
);
}
/** 构造一个已注册装备定义并设置好装备槽的勇士状态对象(基础 atk 为 10) */
function createEquipHero(): IHeroState<IHeroAttr> {
const env = createEquipEnv();
@ -741,3 +750,43 @@ describe('HeroState same-reference equipment load (#06-17-4)', () => {
expect(hero.items.equipment.get(extra)).toBeNull();
});
});
describe('HeroState same-reference follower load (#06-17-6)', () => {
// 验证经勇士容器三档往返后同索引同图块数字的跟随者为同一实例,位置与渲染恢复到存档点
it('keeps the follower instance and restores its state across all compressions', () => {
for (const compression of SAVE_COMPRESSIONS) {
const hero = createFollowerHero();
const follower = hero.followers.addFollower(100);
follower.location.setPos(2, 3);
follower.location.mover.setFaceDir(FaceDirection.Up);
follower.rendering.setAlpha(0.5);
const saved = hero.saveState(compression);
follower.location.setPos(9, 9);
follower.location.mover.setFaceDir(FaceDirection.Down);
follower.rendering.setAlpha(1);
hero.loadState(saved, compression);
expect(hero.followers.getFollower(0)).toBe(follower);
expect(follower.location.x).toBe(2);
expect(follower.location.y).toBe(3);
expect(follower.rendering.alpha).toBe(0.5);
}
});
// 验证跟随者数量以存档为准:存档中不存在的跟随者读档后被删除
it('deletes followers absent from the save', () => {
const hero = createFollowerHero();
const kept = hero.followers.addFollower(100);
const saved = hero.saveState(SaveCompression.NoCompression);
hero.followers.addFollower(100);
hero.loadState(saved, SaveCompression.NoCompression);
const all = hero.followers.getAllFollowers();
expect(all).toHaveLength(1);
expect(all[0]).toBe(kept);
});
});

View File

@ -145,10 +145,7 @@ export class HeroState<THero>
this.rendering.loadState(state.rendering, compression);
this.items.loadState(state.items, compression);
this.equip.loadState(state.equip, compression);
void this.followers.removeAllFollowers();
for (const save of state.followers) {
const follower = this.followers.addFollower(save.num);
follower.loadState(save, compression);
}
// 跟随者保留式恢复:同索引同图块数字的实例原地读档,其余按存档替换或删除
this.followers.restoreFollowers(state.followers, compression);
}
}

View File

@ -506,6 +506,19 @@ export interface IHeroFollowersController
*/
addFollower(num: number | string): IHeroFollower;
/**
* 按存档恢复跟随者列表:索引相同且图块数字相同的跟随者在其原实例上原地读档,
* 索引相同但图块数字不同的槽位替换为新实例,存档中不存在的末尾跟随者按存档为准删除。
* 仅发生替换或删除时触发 `onRemoveFollower`,仅新建实例时触发 `onAddFollower`,
* 原地读档的跟随者不触发增删钩子。
* @param saves 跟随者存档列表
* @param compression 存档压缩级别
*/
restoreFollowers(
saves: readonly IHeroFollowerSave[],
compression: SaveCompression
): void;
/**
* 根据跟随者的索引数字获取跟随者对象
* @param index 跟随者的索引数字