Skip to content

Commit 795565b

Browse files
committed
feat(world): 熵可视化世界信号派生层与深海实体辉光接线(宪法P1第一批)
- worldState.ts:retention引擎数据→世界信号集纯函数派生(vitality/mist/depthNorm/firefly/warmth),宪法边界硬编码:雾上限0.4、关闭留存→纯净亮度映射、冷启动=未点亮混沌、辉光域0.6-1.15无惩罚性暗淡 - useWorldSignals.ts:细粒度selector订阅hook,帧率敏感场景防连带重渲染 - worldState.test.ts:7用例覆盖宪法§1/§5/§7关键约束 - ModuleEntity/SpatialNav:深海实体辉光=交互态基值×世界活力(珊瑚健康度),宪法第一条映射表首次落地
1 parent f4260ad commit 795565b

5 files changed

Lines changed: 295 additions & 1 deletion

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* 世界信号订阅 Hook
3+
* World signals subscription hook
4+
*
5+
* @ai-context: 订阅珊瑚生态/深海发现/留存设置 store,实时派生世界信号集,
6+
* 供 3D 场景层(SpatialNav 实体辉光、后续混沌雾/萤火/洋流)消费。
7+
* streak 尚未接入 store(StreakBubble 走 props 传递),潮汐批次接线时补真实值。
8+
*
9+
* @ai-context: Subscribes to retention stores and derives live world signals
10+
* for the 3D scene layer. Streak wiring lands with the tide batch.
11+
*/
12+
import { useMemo } from 'react';
13+
import { useEcosystemStore } from '../store/useEcosystemStore';
14+
import { useDiscoveryStore } from '../store/useDiscoveryStore';
15+
import { useRetentionSettings } from '../store/useRetentionSettings';
16+
import {
17+
deriveWorldSignals,
18+
vitalityToGlowScale,
19+
type WorldSignals,
20+
} from '../lib/worldState';
21+
22+
/** 世界信号 + 派生的实体辉光乘数 / Signals plus derived glow multiplier */
23+
export type WorldSignalsWithGlow = WorldSignals & { glowScale: number };
24+
25+
export function useWorldSignals(): WorldSignalsWithGlow {
26+
// 细粒度 selector:避免整 store 订阅连带重渲染(3D 场景对帧率敏感)
27+
const corals = useEcosystemStore((s) => s.corals);
28+
const totalDepth = useEcosystemStore((s) => s.totalDepth);
29+
const discoveriesCount = useDiscoveryStore((s) => s.totalCount);
30+
const enabled = useRetentionSettings((s) => s.enabled);
31+
32+
return useMemo(() => {
33+
const signals = deriveWorldSignals({
34+
corals,
35+
totalDepth,
36+
discoveriesCount,
37+
// TODO(潮汐批次): streak store 接线后替换为真实连击值
38+
currentStreak: 0,
39+
enabled,
40+
});
41+
return { ...signals, glowScale: vitalityToGlowScale(signals.vitality) };
42+
}, [corals, totalDepth, discoveriesCount, enabled]);
43+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* 世界状态派生层单元测试
3+
* Unit tests for the world state derivation layer
4+
*
5+
* @ai-context: 覆盖《熵可视化设计宪法》关键约束:焦虑防线雾上限 0.4、
6+
* 关闭留存→中性信号、冷启动非空白、无负向输出语义。
7+
*/
8+
import { describe, it, expect } from 'vitest';
9+
import {
10+
deriveWorldSignals,
11+
vitalityToGlowScale,
12+
WORLD_ANCHORS,
13+
} from './worldState';
14+
import type { CoralRecord } from '../types';
15+
16+
function makeCoral(health: CoralRecord['health'], id = Math.random().toString(36)): CoralRecord {
17+
return {
18+
id,
19+
type: 'branching',
20+
health,
21+
plantedAt: new Date(),
22+
sourceSession: 'test',
23+
depth: 10,
24+
};
25+
}
26+
27+
const base = {
28+
corals: [] as CoralRecord[],
29+
totalDepth: 0,
30+
discoveriesCount: 0,
31+
currentStreak: 0,
32+
enabled: true,
33+
};
34+
35+
describe('deriveWorldSignals', () => {
36+
it('should return neutral signals when retention is disabled (焦虑防线 §5)', () => {
37+
// Arrange:留存关闭,即使存在白化珊瑚
38+
const input = { ...base, corals: [makeCoral('bleached')], enabled: false };
39+
40+
// Act
41+
const s = deriveWorldSignals(input);
42+
43+
// Assert:纯净亮度映射——满活力、无雾
44+
expect(s.vitality).toBe(1);
45+
expect(s.mist).toBe(0);
46+
expect(s.enabled).toBe(false);
47+
});
48+
49+
it('should return unlit-chaos world on cold start (宪法第七条:非空白)', () => {
50+
// Arrange:无任何珊瑚
51+
const input = { ...base };
52+
53+
// Act
54+
const s = deriveWorldSignals(input);
55+
56+
// Assert:中性活力 + 薄雾,暗示可能性而非空洞
57+
expect(s.vitality).toBe(WORLD_ANCHORS.VITALITY_COLD_START);
58+
expect(s.mist).toBe(WORLD_ANCHORS.MIST_COLD_START);
59+
expect(s.depthNorm).toBe(0);
60+
});
61+
62+
it('should map healthy ratio to vitality and zero mist when all healthy', () => {
63+
// Arrange:全部健康
64+
const input = { ...base, corals: [makeCoral('healthy'), makeCoral('healthy')] };
65+
66+
// Act
67+
const s = deriveWorldSignals(input);
68+
69+
// Assert
70+
expect(s.vitality).toBe(1);
71+
expect(s.mist).toBe(0);
72+
});
73+
74+
it('should map bleached ratio to mist proportionally', () => {
75+
// Arrange:一半白化
76+
const input = { ...base, corals: [makeCoral('healthy'), makeCoral('bleached')] };
77+
78+
// Act
79+
const s = deriveWorldSignals(input);
80+
81+
// Assert:0.5 × 0.4
82+
expect(s.vitality).toBe(0.5);
83+
expect(s.mist).toBeCloseTo(0.2);
84+
});
85+
86+
it('should never exceed mist cap 0.4 even when fully bleached (焦虑防线 §1)', () => {
87+
// Arrange:全部白化
88+
const input = { ...base, corals: [makeCoral('bleached'), makeCoral('bleached')] };
89+
90+
// Act
91+
const s = deriveWorldSignals(input);
92+
93+
// Assert:雾封顶 40%,朦胧可拨开,永不吞噬世界
94+
expect(s.mist).toBeLessThanOrEqual(WORLD_ANCHORS.MIST_MAX);
95+
expect(s.vitality).toBe(0);
96+
});
97+
98+
it('should clamp depth/firefly/warmth into 0-1', () => {
99+
// Arrange:远超饱和锚点的输入
100+
const input = {
101+
...base,
102+
corals: [makeCoral('healthy')],
103+
totalDepth: WORLD_ANCHORS.DEPTH_FULL * 10,
104+
discoveriesCount: WORLD_ANCHORS.FIREFLY_FULL * 5,
105+
currentStreak: WORLD_ANCHORS.STREAK_FULL * 3,
106+
};
107+
108+
// Act
109+
const s = deriveWorldSignals(input);
110+
111+
// Assert
112+
expect(s.depthNorm).toBe(1);
113+
expect(s.firefly).toBe(1);
114+
expect(s.warmth).toBe(1);
115+
});
116+
});
117+
118+
describe('vitalityToGlowScale', () => {
119+
it('should stay within the 0.6-1.15 readable band (奖赏回来:无惩罚性暗淡)', () => {
120+
// Act & Assert:边界内外均收敛于可读域
121+
expect(vitalityToGlowScale(0)).toBeCloseTo(0.6);
122+
expect(vitalityToGlowScale(1)).toBeCloseTo(1.15);
123+
expect(vitalityToGlowScale(-2)).toBeCloseTo(0.6);
124+
expect(vitalityToGlowScale(9)).toBeCloseTo(1.15);
125+
});
126+
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* 世界状态派生层(宪法 P1 · 数据接线)
3+
* World state derivation layer (constitution P1 · data wiring)
4+
*
5+
* @ai-context: 从留存引擎(珊瑚/发现)数据派生熵可视化单范式所需的世界信号集。
6+
* 纯函数、无副作用、可单测。所有输出遵循《熵可视化设计宪法》第一条映射表
7+
* 与第二条焦虑防线:vitality→发光体亮度、mist→混沌雾(上限 0.4)、
8+
* depthNorm→地质层、firefly→萤火数量、warmth→洋流温度。
9+
* 留存关闭时返回中性信号(第二条 §5 可关闭性:纯净亮度映射,无雾)。
10+
*
11+
* @ai-context: Derives world signals for the entropy-visualization paradigm
12+
* from retention engine data (corals/discoveries). Pure, testable, bounded
13+
* by the design constitution's mapping table and anxiety-defense limits.
14+
*/
15+
16+
import type { CoralRecord } from '../types';
17+
18+
/** 归一化锚点(世界饱和度常量) / Normalization anchors */
19+
export const WORLD_ANCHORS = {
20+
/** 地质层满饱和的累计深度(米) / Depth where strata saturate */
21+
DEPTH_FULL: 2000,
22+
/** 萤火密度满饱和的发现数 / Discoveries where firefly density saturates */
23+
FIREFLY_FULL: 20,
24+
/** 洋流温度满饱和的连击天数 / Streak days where warmth saturates */
25+
STREAK_FULL: 7,
26+
/** 混沌雾最大强度(宪法第二条 §1:≤40%) / Max mist per constitution */
27+
MIST_MAX: 0.4,
28+
/** 冷启动基底雾(宪法第七条:未点亮的混沌,非空白) / Cold-start base mist */
29+
MIST_COLD_START: 0.12,
30+
/** 冷启动世界活力中性值 / Cold-start neutral vitality */
31+
VITALITY_COLD_START: 0.55,
32+
} as const;
33+
34+
/** 派生输入:全部来自 retention 引擎存量 / Inputs from existing retention stores */
35+
export interface WorldSignalInput {
36+
corals: CoralRecord[];
37+
totalDepth: number;
38+
discoveriesCount: number;
39+
currentStreak: number;
40+
/** 留存总开关:关闭→中性信号(纯净亮度映射) / Master toggle off → neutral */
41+
enabled: boolean;
42+
}
43+
44+
/** 世界信号集:每个字段只映射一个学习变量(宪法第一条) / One signal = one variable */
45+
export interface WorldSignals {
46+
/** 世界活力 0–1:健康珊瑚占比 → 发光体亮度 / Healthy coral ratio → glow */
47+
vitality: number;
48+
/** 混沌雾强度 0–0.4:白化占比 → 遗忘(熵增) / Bleached ratio → mist */
49+
mist: number;
50+
/** 地质层规模 0–1:累计深度归一 / Cumulative depth normalized */
51+
depthNorm: number;
52+
/** 萤火密度 0–1:发现数归一 / Discovery count normalized */
53+
firefly: number;
54+
/** 洋流温度 0–1:连击归一 / Streak normalized */
55+
warmth: number;
56+
/** 留存是否开启 / Retention enabled flag */
57+
enabled: boolean;
58+
}
59+
60+
const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));
61+
62+
/**
63+
* 派生世界信号集 / Derive the world signal set
64+
*
65+
* @ai-context 奖赏回来原则:白化珊瑚映射为"朦胧雾"而非消亡——
66+
* 雾永远可拨开,复习(新的种植)即恢复。无任何负向输出语义。
67+
*/
68+
export function deriveWorldSignals(input: WorldSignalInput): WorldSignals {
69+
const { corals, totalDepth, discoveriesCount, currentStreak, enabled } = input;
70+
const depthNorm = clamp01(totalDepth / WORLD_ANCHORS.DEPTH_FULL);
71+
const firefly = clamp01(discoveriesCount / WORLD_ANCHORS.FIREFLY_FULL);
72+
const warmth = clamp01(currentStreak / WORLD_ANCHORS.STREAK_FULL);
73+
74+
// 焦虑防线 §5:用户关闭留存/混沌雾渲染 → 纯净亮度映射,无雾
75+
if (!enabled) {
76+
return { vitality: 1, mist: 0, depthNorm, firefly: 0, warmth: 0, enabled: false };
77+
}
78+
79+
// 冷启动:未点亮的混沌世界(宪法第七条)——中性活力 + 薄雾,暗示可能性
80+
if (corals.length === 0) {
81+
return {
82+
vitality: WORLD_ANCHORS.VITALITY_COLD_START,
83+
mist: WORLD_ANCHORS.MIST_COLD_START,
84+
depthNorm: 0,
85+
firefly,
86+
warmth,
87+
enabled: true,
88+
};
89+
}
90+
91+
const healthy = corals.filter((c) => c.health === 'healthy').length;
92+
// 白化是"暂停生长"而非消亡:占比映射为雾,上限被宪法锁死
93+
const bleachedRatio = 1 - healthy / corals.length;
94+
95+
return {
96+
vitality: healthy / corals.length,
97+
mist: Math.min(WORLD_ANCHORS.MIST_MAX, bleachedRatio * WORLD_ANCHORS.MIST_MAX),
98+
depthNorm,
99+
firefly,
100+
warmth,
101+
enabled: true,
102+
};
103+
}
104+
105+
/**
106+
* 世界活力 → 发光体辉光乘数 / Vitality → entity glow multiplier
107+
*
108+
* @ai-context 输出域 0.6–1.15:只调节明暗程度,永不低于可读下限,
109+
* 避免"暗淡=惩罚"的负向语义(奖赏回来原则)。
110+
*/
111+
export function vitalityToGlowScale(vitality: number): number {
112+
return 0.6 + clamp01(vitality) * 0.55;
113+
}

‎client/src/lib/3d/navigation/SpatialNav.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { OrbitControls } from '@react-three/drei';
1919
import { useOrbitalStore, MODULE_POSITIONS, type ModuleId } from './OrbitalStore';
2020
import { useSceneTheme } from '../hooks/useSceneTheme';
2121
import { ModuleEntity } from '../objects/ModuleEntity';
22+
import { useWorldSignals } from '@/features/retention/hooks/useWorldSignals';
2223
import { AuroraModuleEntity } from '../objects/AuroraModuleEntity';
2324
import { CameraController } from '../core/CameraController';
2425
import { useCameraFlight } from '../hooks/useCameraFlight';
@@ -76,6 +77,8 @@ export function SpatialNav() {
7677
const { phase, overlayVisible, currentModule, hoveredModule, highlightAll, enterModule, setHovered } = useOrbitalStore();
7778
const { flyTo, update } = useCameraFlight();
7879
const { shouldDegrade3D } = useRuntimeEnv();
80+
// 世界信号:实体辉光随学习数据(珊瑚健康度)明暗——宪法第一条接线(第一批仅深海实体)
81+
const worldSignals = useWorldSignals();
7982

8083
// 点击入口记录的行星实时世界坐标(飞行 effect 优先消费),点击后才有效
8184
const clickFlightPosRef = useRef<Partial<Record<ModuleId, [number, number, number]>>>({});
@@ -200,6 +203,7 @@ export function SpatialNav() {
200203
geometry={config.geometry}
201204
color={config.color}
202205
emissiveColor={config.emissiveColor}
206+
glowScale={worldSignals.glowScale}
203207
isHovered={hoveredModule === module.id || highlightAll}
204208
// 选中态需结合相位:Esc 退出后 currentModule 保留(页面状态存活),但视觉选中必须解除
205209
isActive={phase !== 'overview' && currentModule === module.id}

‎client/src/lib/3d/objects/ModuleEntity.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ interface ModuleEntityProps {
2222
isHovered: boolean;
2323
isActive: boolean;
2424
showLabel?: boolean;
25+
/**
26+
* 世界活力辉光乘数(宪法第一条:掌握度=亮度)。
27+
* 由 useWorldSignals 派生,域 0.6–1.15:只调明暗、永不惩罚性暗淡。
28+
* World vitality glow multiplier (constitution §1: mastery = brightness).
29+
*/
30+
glowScale?: number;
2531
onClick: () => void;
2632
onPointerOver: () => void;
2733
onPointerOut: () => void;
@@ -75,6 +81,7 @@ export function ModuleEntity({
7581
isHovered,
7682
isActive,
7783
showLabel = false,
84+
glowScale = 1,
7885
onClick,
7986
onPointerOver,
8087
onPointerOut,
@@ -96,7 +103,8 @@ export function ModuleEntity({
96103

97104
// Target states for lerp transitions
98105
const targetScale = isActive ? 1.3 : isHovered ? 1.15 : 1.0;
99-
const targetEmissive = isActive ? 1.2 : isHovered ? 0.8 : 0.3;
106+
// 辉光 = 交互态基值 × 世界活力(学习数据的亮度映射,宪法第一条)
107+
const targetEmissive = (isActive ? 1.2 : isHovered ? 0.8 : 0.3) * glowScale;
100108

101109
useFrame((_, delta) => {
102110
if (!meshRef.current) return;

0 commit comments

Comments
 (0)