gridstamp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cursorrules +74 -0
- package/CLAUDE.md +61 -0
- package/LICENSE +190 -0
- package/README.md +107 -0
- package/dist/index.js +194 -0
- package/package.json +84 -0
- package/src/antispoofing/detector.ts +509 -0
- package/src/antispoofing/index.ts +7 -0
- package/src/gamification/badges.ts +429 -0
- package/src/gamification/fleet-leaderboard.ts +293 -0
- package/src/gamification/index.ts +44 -0
- package/src/gamification/streaks.ts +243 -0
- package/src/gamification/trust-tiers.ts +393 -0
- package/src/gamification/zone-mastery.ts +256 -0
- package/src/index.ts +341 -0
- package/src/memory/index.ts +9 -0
- package/src/memory/place-cells.ts +279 -0
- package/src/memory/spatial-memory.ts +375 -0
- package/src/navigation/index.ts +1 -0
- package/src/navigation/pathfinding.ts +403 -0
- package/src/perception/camera.ts +249 -0
- package/src/perception/index.ts +2 -0
- package/src/types/index.ts +416 -0
- package/src/utils/crypto.ts +94 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/math.ts +204 -0
- package/src/verification/index.ts +9 -0
- package/src/verification/spatial-proof.ts +442 -0
- package/tests/antispoofing/detector.test.ts +196 -0
- package/tests/gamification/badges.test.ts +163 -0
- package/tests/gamification/fleet-leaderboard.test.ts +181 -0
- package/tests/gamification/streaks.test.ts +158 -0
- package/tests/gamification/trust-tiers.test.ts +165 -0
- package/tests/gamification/zone-mastery.test.ts +143 -0
- package/tests/memory/place-cells.test.ts +128 -0
- package/tests/stress/load.test.ts +499 -0
- package/tests/stress/security.test.ts +378 -0
- package/tests/stress/simulation.test.ts +361 -0
- package/tests/utils/crypto.test.ts +115 -0
- package/tests/utils/math.test.ts +195 -0
- package/tests/verification/spatial-proof.test.ts +299 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
ReplayDetector,
|
|
4
|
+
AdversarialPatchDetector,
|
|
5
|
+
checkDepthIntegrity,
|
|
6
|
+
CanarySystem,
|
|
7
|
+
FrameIntegrityChecker,
|
|
8
|
+
} from '../../src/antispoofing/detector.js';
|
|
9
|
+
import { signFrame } from '../../src/utils/crypto.js';
|
|
10
|
+
import type { CameraFrame } from '../../src/types/index.js';
|
|
11
|
+
|
|
12
|
+
const TEST_SECRET = 'a'.repeat(32);
|
|
13
|
+
|
|
14
|
+
function makeFrame(overrides: Partial<CameraFrame> = {}): CameraFrame {
|
|
15
|
+
const rgb = new Uint8Array(100 * 100 * 3);
|
|
16
|
+
// Fill with some varied data to avoid duplicate detection
|
|
17
|
+
for (let i = 0; i < rgb.length; i++) {
|
|
18
|
+
rgb[i] = (i * 17 + (overrides.sequenceNumber ?? 1) * 31) % 256;
|
|
19
|
+
}
|
|
20
|
+
const timestamp = overrides.timestamp ?? Date.now();
|
|
21
|
+
const seq = overrides.sequenceNumber ?? 1;
|
|
22
|
+
return {
|
|
23
|
+
id: `frame-${seq}`,
|
|
24
|
+
timestamp,
|
|
25
|
+
rgb,
|
|
26
|
+
width: 100,
|
|
27
|
+
height: 100,
|
|
28
|
+
sequenceNumber: seq,
|
|
29
|
+
hmac: signFrame(rgb, timestamp, seq, TEST_SECRET),
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('ReplayDetector', () => {
|
|
35
|
+
it('accepts normal sequential frames', () => {
|
|
36
|
+
const detector = new ReplayDetector(30);
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const f1 = makeFrame({ sequenceNumber: 1, timestamp: now });
|
|
39
|
+
const f2 = makeFrame({ sequenceNumber: 2, timestamp: now + 33 });
|
|
40
|
+
const f3 = makeFrame({ sequenceNumber: 3, timestamp: now + 66 });
|
|
41
|
+
expect(detector.check(f1)).toHaveLength(0);
|
|
42
|
+
expect(detector.check(f2)).toHaveLength(0);
|
|
43
|
+
expect(detector.check(f3)).toHaveLength(0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('detects sequence number regression (replay)', () => {
|
|
47
|
+
const detector = new ReplayDetector(30);
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
detector.check(makeFrame({ sequenceNumber: 5, timestamp: now }));
|
|
50
|
+
const threats = detector.check(makeFrame({ sequenceNumber: 3, timestamp: now + 33 }));
|
|
51
|
+
expect(threats.length).toBeGreaterThan(0);
|
|
52
|
+
expect(threats.some(t => t.type === 'replay' && t.severity === 'critical')).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('detects negative time delta', () => {
|
|
56
|
+
const detector = new ReplayDetector(30);
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
detector.check(makeFrame({ sequenceNumber: 1, timestamp: now }));
|
|
59
|
+
const threats = detector.check(makeFrame({ sequenceNumber: 2, timestamp: now - 100 }));
|
|
60
|
+
expect(threats.some(t => t.details.includes('Negative time delta'))).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('detects duplicate frame content', () => {
|
|
64
|
+
const detector = new ReplayDetector(30);
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
const frame = makeFrame({ sequenceNumber: 1, timestamp: now });
|
|
67
|
+
detector.check(frame);
|
|
68
|
+
// Same content but different sequence/timestamp — still a replay
|
|
69
|
+
const threats = detector.check({
|
|
70
|
+
...frame,
|
|
71
|
+
id: 'frame-replay',
|
|
72
|
+
sequenceNumber: 2,
|
|
73
|
+
timestamp: now + 33,
|
|
74
|
+
});
|
|
75
|
+
expect(threats.some(t => t.details.includes('duplicate'))).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('detects burst injection (too fast)', () => {
|
|
79
|
+
const detector = new ReplayDetector(30); // 33ms expected interval
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
detector.check(makeFrame({ sequenceNumber: 1, timestamp: now }));
|
|
82
|
+
const threats = detector.check(makeFrame({ sequenceNumber: 2, timestamp: now + 2 })); // 2ms = way too fast
|
|
83
|
+
expect(threats.some(t => t.details.includes('fast'))).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('AdversarialPatchDetector', () => {
|
|
88
|
+
it('accepts normal frames', () => {
|
|
89
|
+
const detector = new AdversarialPatchDetector();
|
|
90
|
+
const frame = makeFrame();
|
|
91
|
+
const threats = detector.check(frame);
|
|
92
|
+
// Normal pseudo-random data should not trigger
|
|
93
|
+
expect(threats.filter(t => t.severity === 'high' || t.severity === 'critical')).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('detects extreme color saturation', () => {
|
|
97
|
+
const detector = new AdversarialPatchDetector(0.05); // low threshold
|
|
98
|
+
const rgb = new Uint8Array(100 * 100 * 3);
|
|
99
|
+
// Fill with extreme saturated pixels
|
|
100
|
+
for (let i = 0; i < 100 * 100; i++) {
|
|
101
|
+
rgb[i * 3] = 255; // max red
|
|
102
|
+
rgb[i * 3 + 1] = 0; // min green
|
|
103
|
+
rgb[i * 3 + 2] = 0; // min blue
|
|
104
|
+
}
|
|
105
|
+
const frame = makeFrame({ rgb });
|
|
106
|
+
const threats = detector.check(frame);
|
|
107
|
+
expect(threats.some(t => t.type === 'adversarial-patch')).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('Depth Integrity', () => {
|
|
112
|
+
it('accepts normal depth data', () => {
|
|
113
|
+
const depth = new Float32Array(1000);
|
|
114
|
+
// Normal depth with noise and some invalid pixels
|
|
115
|
+
for (let i = 0; i < depth.length; i++) {
|
|
116
|
+
if (i % 30 === 0) {
|
|
117
|
+
depth[i] = 0; // ~3% invalid (occlusion)
|
|
118
|
+
} else {
|
|
119
|
+
depth[i] = 1 + Math.random() * 4; // 1-5m with noise
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const frame = makeFrame({ depth });
|
|
123
|
+
const threats = checkDepthIntegrity(frame);
|
|
124
|
+
expect(threats.filter(t => t.severity === 'critical')).toHaveLength(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('detects zero-variance depth (synthetic)', () => {
|
|
128
|
+
const depth = new Float32Array(1000).fill(2.5); // perfectly flat = suspicious
|
|
129
|
+
const frame = makeFrame({ depth });
|
|
130
|
+
const threats = checkDepthIntegrity(frame);
|
|
131
|
+
expect(threats.some(t => t.type === 'depth-injection')).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('detects high invalid ratio (camera tampered)', () => {
|
|
135
|
+
const depth = new Float32Array(1000).fill(0); // all invalid
|
|
136
|
+
const frame = makeFrame({ depth });
|
|
137
|
+
const threats = checkDepthIntegrity(frame);
|
|
138
|
+
expect(threats.some(t => t.type === 'camera-tampering')).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('CanarySystem', () => {
|
|
143
|
+
it('plants and detects canary activation', () => {
|
|
144
|
+
const canaries = new CanarySystem(TEST_SECRET);
|
|
145
|
+
canaries.plant('fake-landmark-1', { x: 10, y: 20, z: 0 });
|
|
146
|
+
expect(canaries.count).toBe(1);
|
|
147
|
+
|
|
148
|
+
// A synthetic feed that references the canary position
|
|
149
|
+
const threats = canaries.checkForCanaryActivation([
|
|
150
|
+
{ x: 10.1, y: 20.1, z: 0 }, // close to canary
|
|
151
|
+
]);
|
|
152
|
+
expect(threats).toHaveLength(1);
|
|
153
|
+
expect(threats[0]!.type).toBe('memory-poisoning');
|
|
154
|
+
expect(threats[0]!.severity).toBe('critical');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('does not trigger for real positions', () => {
|
|
158
|
+
const canaries = new CanarySystem(TEST_SECRET);
|
|
159
|
+
canaries.plant('fake-1', { x: 100, y: 100, z: 100 }); // far away
|
|
160
|
+
const threats = canaries.checkForCanaryActivation([
|
|
161
|
+
{ x: 0, y: 0, z: 0 },
|
|
162
|
+
]);
|
|
163
|
+
expect(threats).toHaveLength(0);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('FrameIntegrityChecker', () => {
|
|
168
|
+
it('accepts valid signed frames', () => {
|
|
169
|
+
const checker = new FrameIntegrityChecker(TEST_SECRET, 30);
|
|
170
|
+
const frame = makeFrame({ sequenceNumber: 1 });
|
|
171
|
+
// Need to re-sign with the checker's key derivation
|
|
172
|
+
const integrity = checker.check(frame);
|
|
173
|
+
// hmacValid may be false since we signed with a different derived key
|
|
174
|
+
// But sequence and timing should be fine for first frame
|
|
175
|
+
expect(integrity.frameId).toBe(frame.id);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('isSafe rejects frames with critical threats', () => {
|
|
179
|
+
const checker = new FrameIntegrityChecker(TEST_SECRET, 30);
|
|
180
|
+
const integrity = {
|
|
181
|
+
frameId: 'test',
|
|
182
|
+
hmacValid: false, // HMAC failed
|
|
183
|
+
sequenceValid: true,
|
|
184
|
+
timingValid: true,
|
|
185
|
+
threats: [{
|
|
186
|
+
type: 'mitm' as const,
|
|
187
|
+
severity: 'critical' as const,
|
|
188
|
+
confidence: 0.99,
|
|
189
|
+
details: 'HMAC failed',
|
|
190
|
+
timestamp: Date.now(),
|
|
191
|
+
mitigationApplied: true,
|
|
192
|
+
}],
|
|
193
|
+
};
|
|
194
|
+
expect(checker.isSafe(integrity)).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
BadgeSystem,
|
|
4
|
+
BadgeCategory,
|
|
5
|
+
BadgeRarity,
|
|
6
|
+
verifyBadgeAward,
|
|
7
|
+
BADGE_CATALOG,
|
|
8
|
+
type RobotMetrics,
|
|
9
|
+
} from '../../src/gamification/badges.js';
|
|
10
|
+
|
|
11
|
+
const SECRET = 'a'.repeat(32);
|
|
12
|
+
|
|
13
|
+
function makeMetrics(overrides: Partial<RobotMetrics> = {}): RobotMetrics {
|
|
14
|
+
return {
|
|
15
|
+
successful_verifications: 0,
|
|
16
|
+
spoofing_incidents: 0,
|
|
17
|
+
threats_detected: 0,
|
|
18
|
+
zones_mapped: 0,
|
|
19
|
+
max_zone_mastery: 0,
|
|
20
|
+
unique_routes: 0,
|
|
21
|
+
consecutive_days: 0,
|
|
22
|
+
night_operations: 0,
|
|
23
|
+
total_verifications: 0,
|
|
24
|
+
success_rate: 0,
|
|
25
|
+
max_ssim: 0,
|
|
26
|
+
max_zones_per_trip: 0,
|
|
27
|
+
fast_operations: 0,
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('BadgeSystem', () => {
|
|
33
|
+
it('requires 32-char secret', () => {
|
|
34
|
+
expect(() => new BadgeSystem('short')).toThrow('32 chars');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('has a catalog of 20+ badges', () => {
|
|
38
|
+
expect(BADGE_CATALOG.length).toBeGreaterThanOrEqual(20);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('awards first-delivery badge', () => {
|
|
42
|
+
const system = new BadgeSystem(SECRET);
|
|
43
|
+
const metrics = makeMetrics({ successful_verifications: 1 });
|
|
44
|
+
const newBadges = system.evaluate('robot-1', metrics);
|
|
45
|
+
const firstDelivery = newBadges.find(b => b.badgeId === 'first-delivery');
|
|
46
|
+
expect(firstDelivery).toBeDefined();
|
|
47
|
+
expect(firstDelivery!.robotId).toBe('robot-1');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('does not re-award same badge', () => {
|
|
51
|
+
const system = new BadgeSystem(SECRET);
|
|
52
|
+
const metrics = makeMetrics({ successful_verifications: 1 });
|
|
53
|
+
system.evaluate('robot-1', metrics);
|
|
54
|
+
const second = system.evaluate('robot-1', metrics);
|
|
55
|
+
expect(second.find(b => b.badgeId === 'first-delivery')).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('awards clean-record badge (compound criteria)', () => {
|
|
59
|
+
const system = new BadgeSystem(SECRET);
|
|
60
|
+
const metrics = makeMetrics({
|
|
61
|
+
successful_verifications: 50,
|
|
62
|
+
spoofing_incidents: 0,
|
|
63
|
+
});
|
|
64
|
+
const badges = system.evaluate('robot-1', metrics);
|
|
65
|
+
expect(badges.find(b => b.badgeId === 'clean-record')).toBeDefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('does not award clean-record with spoofing', () => {
|
|
69
|
+
const system = new BadgeSystem(SECRET);
|
|
70
|
+
const metrics = makeMetrics({
|
|
71
|
+
successful_verifications: 50,
|
|
72
|
+
spoofing_incidents: 1,
|
|
73
|
+
});
|
|
74
|
+
const badges = system.evaluate('robot-1', metrics);
|
|
75
|
+
expect(badges.find(b => b.badgeId === 'clean-record')).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('awards streak badges', () => {
|
|
79
|
+
const system = new BadgeSystem(SECRET);
|
|
80
|
+
const metrics = makeMetrics({ consecutive_days: 7 });
|
|
81
|
+
const badges = system.evaluate('robot-1', metrics);
|
|
82
|
+
expect(badges.find(b => b.badgeId === 'on-a-roll')).toBeDefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('awards navigation badges', () => {
|
|
86
|
+
const system = new BadgeSystem(SECRET);
|
|
87
|
+
const metrics = makeMetrics({ zones_mapped: 5 });
|
|
88
|
+
const badges = system.evaluate('robot-1', metrics);
|
|
89
|
+
expect(badges.find(b => b.badgeId === 'explorer')).toBeDefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('awards threshold badges (zone-master)', () => {
|
|
93
|
+
const system = new BadgeSystem(SECRET);
|
|
94
|
+
const metrics = makeMetrics({ max_zone_mastery: 0.92 });
|
|
95
|
+
const badges = system.evaluate('robot-1', metrics);
|
|
96
|
+
expect(badges.find(b => b.badgeId === 'zone-master')).toBeDefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('badges are HMAC-signed', () => {
|
|
100
|
+
const system = new BadgeSystem(SECRET);
|
|
101
|
+
const metrics = makeMetrics({ successful_verifications: 1 });
|
|
102
|
+
const badges = system.evaluate('robot-1', metrics);
|
|
103
|
+
const badge = badges[0]!;
|
|
104
|
+
expect(verifyBadgeAward(badge, SECRET)).toBe(true);
|
|
105
|
+
expect(verifyBadgeAward(badge, 'b'.repeat(32))).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('getBadgeCount returns correct count', () => {
|
|
109
|
+
const system = new BadgeSystem(SECRET);
|
|
110
|
+
const metrics = makeMetrics({
|
|
111
|
+
successful_verifications: 1,
|
|
112
|
+
consecutive_days: 7,
|
|
113
|
+
zones_mapped: 5,
|
|
114
|
+
});
|
|
115
|
+
system.evaluate('robot-1', metrics);
|
|
116
|
+
expect(system.getBadgeCount('robot-1')).toBeGreaterThanOrEqual(3);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('getTotalBadgePoints sums correctly', () => {
|
|
120
|
+
const system = new BadgeSystem(SECRET);
|
|
121
|
+
const metrics = makeMetrics({ successful_verifications: 1 });
|
|
122
|
+
system.evaluate('robot-1', metrics);
|
|
123
|
+
const total = system.getTotalBadgePoints('robot-1');
|
|
124
|
+
expect(total).toBe(50); // first-delivery = 50 points
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('verifyAllBadges detects forgery', () => {
|
|
128
|
+
const system = new BadgeSystem(SECRET);
|
|
129
|
+
const metrics = makeMetrics({ successful_verifications: 1 });
|
|
130
|
+
system.evaluate('robot-1', metrics);
|
|
131
|
+
const result = system.verifyAllBadges('robot-1');
|
|
132
|
+
expect(result.valid).toBe(true);
|
|
133
|
+
expect(result.forged).toHaveLength(0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('getDefinition returns badge by id', () => {
|
|
137
|
+
const system = new BadgeSystem(SECRET);
|
|
138
|
+
const def = system.getDefinition('first-delivery');
|
|
139
|
+
expect(def).toBeDefined();
|
|
140
|
+
expect(def!.name).toBe('First Delivery');
|
|
141
|
+
expect(def!.category).toBe(BadgeCategory.OPERATIONAL);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('awards multiple badges at once', () => {
|
|
145
|
+
const system = new BadgeSystem(SECRET);
|
|
146
|
+
const metrics = makeMetrics({
|
|
147
|
+
successful_verifications: 100,
|
|
148
|
+
consecutive_days: 30,
|
|
149
|
+
zones_mapped: 25,
|
|
150
|
+
spoofing_incidents: 0,
|
|
151
|
+
unique_routes: 50,
|
|
152
|
+
});
|
|
153
|
+
const badges = system.evaluate('robot-1', metrics);
|
|
154
|
+
// Should get: first-delivery, century-club, clean-record, explorer, cartographer,
|
|
155
|
+
// on-a-roll, hot-streak, pathfinder
|
|
156
|
+
expect(badges.length).toBeGreaterThanOrEqual(7);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('rejects empty robotId', () => {
|
|
160
|
+
const system = new BadgeSystem(SECRET);
|
|
161
|
+
expect(() => system.evaluate('', makeMetrics())).toThrow('robotId is required');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
FleetLeaderboard,
|
|
4
|
+
LeaderboardMetric,
|
|
5
|
+
verifyLeaderboardEntry,
|
|
6
|
+
type RobotStats,
|
|
7
|
+
} from '../../src/gamification/fleet-leaderboard.js';
|
|
8
|
+
import { TrustTier } from '../../src/gamification/trust-tiers.js';
|
|
9
|
+
|
|
10
|
+
const SECRET = 'a'.repeat(32);
|
|
11
|
+
|
|
12
|
+
function makeStats(overrides: Partial<RobotStats> = {}): RobotStats {
|
|
13
|
+
return {
|
|
14
|
+
robotId: 'robot-1',
|
|
15
|
+
fleetId: 'fleet-A',
|
|
16
|
+
trustTier: TrustTier.VERIFIED,
|
|
17
|
+
points: 500,
|
|
18
|
+
totalVerifications: 100,
|
|
19
|
+
successfulVerifications: 95,
|
|
20
|
+
zonesExplored: 10,
|
|
21
|
+
badgeCount: 5,
|
|
22
|
+
streakDays: 14,
|
|
23
|
+
maxZoneMastery: 0.6,
|
|
24
|
+
spoofingIncidents: 0,
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('FleetLeaderboard', () => {
|
|
30
|
+
it('requires 32-char secret', () => {
|
|
31
|
+
expect(() => new FleetLeaderboard('short')).toThrow('32 chars');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('updates stats and generates leaderboard', () => {
|
|
35
|
+
const lb = new FleetLeaderboard(SECRET);
|
|
36
|
+
lb.updateStats(makeStats({ robotId: 'r1', points: 1000 }));
|
|
37
|
+
lb.updateStats(makeStats({ robotId: 'r2', points: 500 }));
|
|
38
|
+
lb.updateStats(makeStats({ robotId: 'r3', points: 750 }));
|
|
39
|
+
|
|
40
|
+
const board = lb.getFleetLeaderboard('fleet-A', LeaderboardMetric.TRUST_SCORE);
|
|
41
|
+
expect(board).toHaveLength(3);
|
|
42
|
+
expect(board[0]!.robotId).toBe('r1'); // highest trust score
|
|
43
|
+
expect(board[0]!.rank).toBe(1);
|
|
44
|
+
expect(board[1]!.rank).toBe(2);
|
|
45
|
+
expect(board[2]!.rank).toBe(3);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('entries are HMAC-signed', () => {
|
|
49
|
+
const lb = new FleetLeaderboard(SECRET);
|
|
50
|
+
lb.updateStats(makeStats({ robotId: 'r1' }));
|
|
51
|
+
const board = lb.getFleetLeaderboard('fleet-A');
|
|
52
|
+
expect(verifyLeaderboardEntry(board[0]!, SECRET)).toBe(true);
|
|
53
|
+
expect(verifyLeaderboardEntry(board[0]!, 'b'.repeat(32))).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('filters by fleet', () => {
|
|
57
|
+
const lb = new FleetLeaderboard(SECRET);
|
|
58
|
+
lb.updateStats(makeStats({ robotId: 'r1', fleetId: 'fleet-A' }));
|
|
59
|
+
lb.updateStats(makeStats({ robotId: 'r2', fleetId: 'fleet-B' }));
|
|
60
|
+
const boardA = lb.getFleetLeaderboard('fleet-A');
|
|
61
|
+
expect(boardA).toHaveLength(1);
|
|
62
|
+
expect(boardA[0]!.robotId).toBe('r1');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('global leaderboard includes all fleets', () => {
|
|
66
|
+
const lb = new FleetLeaderboard(SECRET);
|
|
67
|
+
lb.updateStats(makeStats({ robotId: 'r1', fleetId: 'fleet-A', points: 1000 }));
|
|
68
|
+
lb.updateStats(makeStats({ robotId: 'r2', fleetId: 'fleet-B', points: 2000 }));
|
|
69
|
+
const board = lb.getGlobalLeaderboard(LeaderboardMetric.TRUST_SCORE);
|
|
70
|
+
expect(board).toHaveLength(2);
|
|
71
|
+
expect(board[0]!.robotId).toBe('r2'); // highest points
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('respects limit parameter', () => {
|
|
75
|
+
const lb = new FleetLeaderboard(SECRET);
|
|
76
|
+
for (let i = 0; i < 10; i++) {
|
|
77
|
+
lb.updateStats(makeStats({ robotId: `r${i}`, points: i * 100 }));
|
|
78
|
+
}
|
|
79
|
+
const board = lb.getFleetLeaderboard('fleet-A', LeaderboardMetric.COMPOSITE, 3);
|
|
80
|
+
expect(board).toHaveLength(3);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('rejects invalid limit', () => {
|
|
84
|
+
const lb = new FleetLeaderboard(SECRET);
|
|
85
|
+
expect(() => lb.getFleetLeaderboard('fleet-A', LeaderboardMetric.COMPOSITE, 0)).toThrow('positive');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('composite score weights correctly', () => {
|
|
89
|
+
const lb = new FleetLeaderboard(SECRET);
|
|
90
|
+
// Robot with high trust but low deliveries
|
|
91
|
+
lb.updateStats(makeStats({
|
|
92
|
+
robotId: 'high-trust',
|
|
93
|
+
trustTier: TrustTier.ELITE,
|
|
94
|
+
totalVerifications: 10,
|
|
95
|
+
successfulVerifications: 10,
|
|
96
|
+
points: 5000,
|
|
97
|
+
}));
|
|
98
|
+
// Robot with many deliveries but lower trust
|
|
99
|
+
lb.updateStats(makeStats({
|
|
100
|
+
robotId: 'high-volume',
|
|
101
|
+
trustTier: TrustTier.PROBATION,
|
|
102
|
+
totalVerifications: 200,
|
|
103
|
+
successfulVerifications: 180,
|
|
104
|
+
points: 200,
|
|
105
|
+
}));
|
|
106
|
+
const board = lb.getFleetLeaderboard('fleet-A', LeaderboardMetric.COMPOSITE);
|
|
107
|
+
expect(board).toHaveLength(2);
|
|
108
|
+
// Both should have non-zero scores
|
|
109
|
+
expect(board[0]!.score).toBeGreaterThan(0);
|
|
110
|
+
expect(board[1]!.score).toBeGreaterThan(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('safety metric penalizes spoofing', () => {
|
|
114
|
+
const lb = new FleetLeaderboard(SECRET);
|
|
115
|
+
lb.updateStats(makeStats({ robotId: 'clean', spoofingIncidents: 0 }));
|
|
116
|
+
lb.updateStats(makeStats({ robotId: 'dirty', spoofingIncidents: 5 }));
|
|
117
|
+
const board = lb.getFleetLeaderboard('fleet-A', LeaderboardMetric.SAFETY_RECORD);
|
|
118
|
+
expect(board[0]!.robotId).toBe('clean');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('getFleetSummary aggregates correctly', () => {
|
|
122
|
+
const lb = new FleetLeaderboard(SECRET);
|
|
123
|
+
lb.updateStats(makeStats({ robotId: 'r1', points: 1000, totalVerifications: 100, successfulVerifications: 90, zonesExplored: 5 }));
|
|
124
|
+
lb.updateStats(makeStats({ robotId: 'r2', points: 500, totalVerifications: 50, successfulVerifications: 45, zonesExplored: 3 }));
|
|
125
|
+
|
|
126
|
+
const summary = lb.getFleetSummary('fleet-A');
|
|
127
|
+
expect(summary.robotCount).toBe(2);
|
|
128
|
+
expect(summary.averageTrustScore).toBe(750); // (1000+500)/2
|
|
129
|
+
expect(summary.totalDeliveries).toBe(135); // 90+45
|
|
130
|
+
expect(summary.averageSuccessRate).toBe(135 / 150); // 135/150
|
|
131
|
+
expect(summary.topRobotId).toBeTruthy();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('getFleetSummary handles empty fleet', () => {
|
|
135
|
+
const lb = new FleetLeaderboard(SECRET);
|
|
136
|
+
const summary = lb.getFleetSummary('empty-fleet');
|
|
137
|
+
expect(summary.robotCount).toBe(0);
|
|
138
|
+
expect(summary.topRobotId).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('getRobotRank returns correct rank', () => {
|
|
142
|
+
const lb = new FleetLeaderboard(SECRET);
|
|
143
|
+
lb.updateStats(makeStats({ robotId: 'r1', points: 1000 }));
|
|
144
|
+
lb.updateStats(makeStats({ robotId: 'r2', points: 500 }));
|
|
145
|
+
lb.updateStats(makeStats({ robotId: 'r3', points: 750 }));
|
|
146
|
+
expect(lb.getRobotRank('r1', LeaderboardMetric.TRUST_SCORE)).toBe(1);
|
|
147
|
+
expect(lb.getRobotRank('r3', LeaderboardMetric.TRUST_SCORE)).toBe(2);
|
|
148
|
+
expect(lb.getRobotRank('r2', LeaderboardMetric.TRUST_SCORE)).toBe(3);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('getRobotRank returns null for unknown robot', () => {
|
|
152
|
+
const lb = new FleetLeaderboard(SECRET);
|
|
153
|
+
expect(lb.getRobotRank('unknown')).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('tracks total robots', () => {
|
|
157
|
+
const lb = new FleetLeaderboard(SECRET);
|
|
158
|
+
expect(lb.totalRobots).toBe(0);
|
|
159
|
+
lb.updateStats(makeStats({ robotId: 'r1' }));
|
|
160
|
+
lb.updateStats(makeStats({ robotId: 'r2' }));
|
|
161
|
+
expect(lb.totalRobots).toBe(2);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('rejects empty fleetId', () => {
|
|
165
|
+
const lb = new FleetLeaderboard(SECRET);
|
|
166
|
+
expect(() => lb.getFleetLeaderboard('')).toThrow('fleetId is required');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('rejects empty robotId in stats', () => {
|
|
170
|
+
const lb = new FleetLeaderboard(SECRET);
|
|
171
|
+
expect(() => lb.updateStats(makeStats({ robotId: '' }))).toThrow('robotId is required');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('zone coverage metric sorts by exploration', () => {
|
|
175
|
+
const lb = new FleetLeaderboard(SECRET);
|
|
176
|
+
lb.updateStats(makeStats({ robotId: 'explorer', zonesExplored: 50, maxZoneMastery: 0.9 }));
|
|
177
|
+
lb.updateStats(makeStats({ robotId: 'homebody', zonesExplored: 2, maxZoneMastery: 0.1 }));
|
|
178
|
+
const board = lb.getFleetLeaderboard('fleet-A', LeaderboardMetric.ZONE_COVERAGE);
|
|
179
|
+
expect(board[0]!.robotId).toBe('explorer');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { StreakSystem } from '../../src/gamification/streaks.js';
|
|
3
|
+
|
|
4
|
+
const SECRET = 'a'.repeat(32);
|
|
5
|
+
|
|
6
|
+
// Helper to create timestamps for specific days
|
|
7
|
+
function dayTimestamp(daysFromNow: number): number {
|
|
8
|
+
const d = new Date();
|
|
9
|
+
d.setUTCHours(12, 0, 0, 0); // noon UTC
|
|
10
|
+
d.setUTCDate(d.getUTCDate() + daysFromNow);
|
|
11
|
+
return d.getTime();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('StreakSystem', () => {
|
|
15
|
+
it('requires 32-char secret', () => {
|
|
16
|
+
expect(() => new StreakSystem('short')).toThrow('32 chars');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('registers a robot', () => {
|
|
20
|
+
const system = new StreakSystem(SECRET);
|
|
21
|
+
const record = system.register('robot-1');
|
|
22
|
+
expect(record.currentStreak).toBe(0);
|
|
23
|
+
expect(record.robotId).toBe('robot-1');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('rejects duplicate registration', () => {
|
|
27
|
+
const system = new StreakSystem(SECRET);
|
|
28
|
+
system.register('robot-1');
|
|
29
|
+
expect(() => system.register('robot-1')).toThrow('already registered');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('starts streak on first activity', () => {
|
|
33
|
+
const system = new StreakSystem(SECRET);
|
|
34
|
+
system.register('robot-1');
|
|
35
|
+
const result = system.recordActivity('robot-1', 100, dayTimestamp(0));
|
|
36
|
+
expect(result.streakDays).toBe(1);
|
|
37
|
+
expect(result.multiplier).toBeCloseTo(1.1); // base 1.0 + 1 * 0.1
|
|
38
|
+
expect(result.bonusPoints).toBe(10); // 100 * 0.1
|
|
39
|
+
expect(result.totalPoints).toBe(110);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('extends streak on consecutive days', () => {
|
|
43
|
+
const system = new StreakSystem(SECRET);
|
|
44
|
+
system.register('robot-1');
|
|
45
|
+
system.recordActivity('robot-1', 100, dayTimestamp(0));
|
|
46
|
+
const day2 = system.recordActivity('robot-1', 100, dayTimestamp(1));
|
|
47
|
+
expect(day2.streakDays).toBe(2);
|
|
48
|
+
expect(day2.multiplier).toBeCloseTo(1.2);
|
|
49
|
+
const day3 = system.recordActivity('robot-1', 100, dayTimestamp(2));
|
|
50
|
+
expect(day3.streakDays).toBe(3);
|
|
51
|
+
expect(day3.multiplier).toBeCloseTo(1.3);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('caps multiplier at max', () => {
|
|
55
|
+
const system = new StreakSystem(SECRET, { maxMultiplier: 1.5 });
|
|
56
|
+
system.register('robot-1');
|
|
57
|
+
for (let i = 0; i < 20; i++) {
|
|
58
|
+
system.recordActivity('robot-1', 100, dayTimestamp(i));
|
|
59
|
+
}
|
|
60
|
+
const record = system.getRecord('robot-1')!;
|
|
61
|
+
const multiplier = system.calculateMultiplier(record.currentStreak);
|
|
62
|
+
expect(multiplier).toBeLessThanOrEqual(1.5);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('default cap is 2.0x', () => {
|
|
66
|
+
const system = new StreakSystem(SECRET);
|
|
67
|
+
expect(system.calculateMultiplier(100)).toBe(2.0);
|
|
68
|
+
expect(system.calculateMultiplier(0)).toBe(1.0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('breaks streak on gap day', () => {
|
|
72
|
+
const system = new StreakSystem(SECRET);
|
|
73
|
+
system.register('robot-1');
|
|
74
|
+
system.recordActivity('robot-1', 100, dayTimestamp(0));
|
|
75
|
+
system.recordActivity('robot-1', 100, dayTimestamp(1));
|
|
76
|
+
// Skip day 2, record on day 3
|
|
77
|
+
const result = system.recordActivity('robot-1', 100, dayTimestamp(3));
|
|
78
|
+
expect(result.streakDays).toBe(1); // reset
|
|
79
|
+
expect(result.multiplier).toBeCloseTo(1.1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('same-day activity does not change streak', () => {
|
|
83
|
+
const system = new StreakSystem(SECRET);
|
|
84
|
+
system.register('robot-1');
|
|
85
|
+
const first = system.recordActivity('robot-1', 100, dayTimestamp(0));
|
|
86
|
+
const second = system.recordActivity('robot-1', 100, dayTimestamp(0));
|
|
87
|
+
expect(second.streakDays).toBe(first.streakDays);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('tracks longest streak', () => {
|
|
91
|
+
const system = new StreakSystem(SECRET);
|
|
92
|
+
system.register('robot-1');
|
|
93
|
+
system.recordActivity('robot-1', 100, dayTimestamp(0));
|
|
94
|
+
system.recordActivity('robot-1', 100, dayTimestamp(1));
|
|
95
|
+
system.recordActivity('robot-1', 100, dayTimestamp(2)); // streak = 3
|
|
96
|
+
system.recordActivity('robot-1', 100, dayTimestamp(5)); // break, streak = 1
|
|
97
|
+
const record = system.getRecord('robot-1')!;
|
|
98
|
+
expect(record.longestStreak).toBe(3);
|
|
99
|
+
expect(record.currentStreak).toBe(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('streak freeze preserves streak', () => {
|
|
103
|
+
const system = new StreakSystem(SECRET, { freezeCostPoints: 100 });
|
|
104
|
+
system.register('robot-1');
|
|
105
|
+
const result = system.useFreeze('robot-1', 500);
|
|
106
|
+
expect(result.applied).toBe(true);
|
|
107
|
+
expect(result.pointsDeducted).toBe(100);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('streak freeze denied when out of budget', () => {
|
|
111
|
+
const system = new StreakSystem(SECRET, { freezeCostPoints: 500 });
|
|
112
|
+
system.register('robot-1');
|
|
113
|
+
const result = system.useFreeze('robot-1', 100);
|
|
114
|
+
expect(result.applied).toBe(false);
|
|
115
|
+
expect(result.pointsDeducted).toBe(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('streak freeze limited per month', () => {
|
|
119
|
+
const system = new StreakSystem(SECRET, { maxFreezesPerMonth: 2, freezeCostPoints: 10 });
|
|
120
|
+
system.register('robot-1');
|
|
121
|
+
expect(system.useFreeze('robot-1', 1000).applied).toBe(true);
|
|
122
|
+
expect(system.useFreeze('robot-1', 1000).applied).toBe(true);
|
|
123
|
+
expect(system.useFreeze('robot-1', 1000).applied).toBe(false); // 3rd denied
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('breakStreak resets to 0', () => {
|
|
127
|
+
const system = new StreakSystem(SECRET);
|
|
128
|
+
system.register('robot-1');
|
|
129
|
+
system.recordActivity('robot-1', 100, dayTimestamp(0));
|
|
130
|
+
system.recordActivity('robot-1', 100, dayTimestamp(1));
|
|
131
|
+
const penalty = system.breakStreak('robot-1');
|
|
132
|
+
expect(penalty.penaltyPercent).toBe(5);
|
|
133
|
+
const record = system.getRecord('robot-1')!;
|
|
134
|
+
expect(record.currentStreak).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('rejects negative basePoints', () => {
|
|
138
|
+
const system = new StreakSystem(SECRET);
|
|
139
|
+
system.register('robot-1');
|
|
140
|
+
expect(() => system.recordActivity('robot-1', -10)).toThrow('non-negative');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('verifyRecord validates HMAC', () => {
|
|
144
|
+
const system = new StreakSystem(SECRET);
|
|
145
|
+
system.register('robot-1');
|
|
146
|
+
system.recordActivity('robot-1', 100);
|
|
147
|
+
expect(system.verifyRecord('robot-1')).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('totalPointsFromStreaks accumulates', () => {
|
|
151
|
+
const system = new StreakSystem(SECRET);
|
|
152
|
+
system.register('robot-1');
|
|
153
|
+
system.recordActivity('robot-1', 100, dayTimestamp(0));
|
|
154
|
+
system.recordActivity('robot-1', 100, dayTimestamp(1));
|
|
155
|
+
const record = system.getRecord('robot-1')!;
|
|
156
|
+
expect(record.totalPointsFromStreaks).toBeGreaterThan(0);
|
|
157
|
+
});
|
|
158
|
+
});
|