feat(play): hide NPC tokens from the player screen

A token can be hidden per session from the control menu. The GM still sees it, and its path keeps moving. Scene editor grid labels no longer call a removed helper.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-10-02 10:15:32 +08:00
parent 060bbd471a
commit f6c301a12b
10 changed files with 165 additions and 23 deletions
@@ -82,6 +82,22 @@ void test('SceneNpcTokensSessionStore setInactive is session flag', () => {
assert.equal(s3.byPlacementId.a?.inactive, undefined);
});
void test('SceneNpcTokensSessionStore hide from players keeps the token for the GM', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'a', hidden: true });
assert.equal(s1.byPlacementId.a?.hiddenFromPlayers, true);
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.4 });
assert.equal(s2.byPlacementId.a?.hiddenFromPlayers, true);
assert.equal(s2.byPlacementId.a?.nx, 0.2);
const s3 = store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'a', hidden: false });
assert.equal(s3.byPlacementId.a?.hiddenFromPlayers, undefined);
assert.equal(s3.byPlacementId.a?.nx, 0.2);
const only = store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'b', hidden: true });
store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'b', hidden: false });
assert.equal(store.getState().byPlacementId.b, undefined);
assert.ok(only.byPlacementId.b?.hiddenFromPlayers);
});
void test('SceneNpcTokensSessionStore setSize is session-only and survives move', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({ kind: 'setSize', placementId: 'a', sizeN: 0.2 });
+30 -1
View File
@@ -21,7 +21,8 @@ function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
p.ny === undefined &&
p.sizeN === undefined &&
!p.disposition &&
!p.inactive
!p.inactive &&
!p.hiddenFromPlayers
);
}
@@ -32,6 +33,7 @@ function restOf(prev: SceneNpcTokensSessionPlacement | undefined): SceneNpcToken
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
...(prev?.disposition ? { disposition: prev.disposition } : {}),
...(prev?.inactive ? { inactive: true } : {}),
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
};
}
@@ -238,6 +240,7 @@ export class SceneNpcTokensSessionStore {
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
...(prev?.disposition ? { disposition: prev.disposition } : {}),
...(inactive ? { inactive: true } : {}),
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
};
if (isEmptyPlacement(next)) {
this.state = withoutPlacement(this.state, placementId);
@@ -254,6 +257,32 @@ export class SceneNpcTokensSessionStore {
};
return this.state;
}
case 'setHiddenFromPlayers': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const hidden = Boolean(event.hidden);
const prev = this.state.byPlacementId[placementId];
if (Boolean(prev?.hiddenFromPlayers) === hidden) return this.state;
const next: SceneNpcTokensSessionPlacement = {
...restOf(prev),
...(hidden ? { hiddenFromPlayers: true } : {}),
};
if (!hidden) delete next.hiddenFromPlayers;
if (isEmptyPlacement(next)) {
this.state = withoutPlacement(this.state, placementId);
return this.state;
}
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: next,
},
scale: this.state.scale,
spawned: this.state.spawned,
};
return this.state;
}
case 'setSize': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;