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.
Files changed (42) hide show
  1. package/.cursorrules +74 -0
  2. package/CLAUDE.md +61 -0
  3. package/LICENSE +190 -0
  4. package/README.md +107 -0
  5. package/dist/index.js +194 -0
  6. package/package.json +84 -0
  7. package/src/antispoofing/detector.ts +509 -0
  8. package/src/antispoofing/index.ts +7 -0
  9. package/src/gamification/badges.ts +429 -0
  10. package/src/gamification/fleet-leaderboard.ts +293 -0
  11. package/src/gamification/index.ts +44 -0
  12. package/src/gamification/streaks.ts +243 -0
  13. package/src/gamification/trust-tiers.ts +393 -0
  14. package/src/gamification/zone-mastery.ts +256 -0
  15. package/src/index.ts +341 -0
  16. package/src/memory/index.ts +9 -0
  17. package/src/memory/place-cells.ts +279 -0
  18. package/src/memory/spatial-memory.ts +375 -0
  19. package/src/navigation/index.ts +1 -0
  20. package/src/navigation/pathfinding.ts +403 -0
  21. package/src/perception/camera.ts +249 -0
  22. package/src/perception/index.ts +2 -0
  23. package/src/types/index.ts +416 -0
  24. package/src/utils/crypto.ts +94 -0
  25. package/src/utils/index.ts +2 -0
  26. package/src/utils/math.ts +204 -0
  27. package/src/verification/index.ts +9 -0
  28. package/src/verification/spatial-proof.ts +442 -0
  29. package/tests/antispoofing/detector.test.ts +196 -0
  30. package/tests/gamification/badges.test.ts +163 -0
  31. package/tests/gamification/fleet-leaderboard.test.ts +181 -0
  32. package/tests/gamification/streaks.test.ts +158 -0
  33. package/tests/gamification/trust-tiers.test.ts +165 -0
  34. package/tests/gamification/zone-mastery.test.ts +143 -0
  35. package/tests/memory/place-cells.test.ts +128 -0
  36. package/tests/stress/load.test.ts +499 -0
  37. package/tests/stress/security.test.ts +378 -0
  38. package/tests/stress/simulation.test.ts +361 -0
  39. package/tests/utils/crypto.test.ts +115 -0
  40. package/tests/utils/math.test.ts +195 -0
  41. package/tests/verification/spatial-proof.test.ts +299 -0
  42. package/tsconfig.json +26 -0
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Fleet Leaderboard — Competitive ranking across robot fleets
3
+ *
4
+ * Maps WeMeetWeMet's city/state/national leaderboard to fleet management.
5
+ * Fleet operators compare robots by: trust, deliveries, zones, safety.
6
+ * Time periods: daily, weekly, monthly, all-time.
7
+ * Entries are HMAC-signed to prevent tampering.
8
+ */
9
+
10
+ import { hmacSign, hmacVerify } from '../utils/crypto.js';
11
+ import { TrustTier } from './trust-tiers.js';
12
+
13
+ export enum LeaderboardPeriod {
14
+ DAILY = 'daily',
15
+ WEEKLY = 'weekly',
16
+ MONTHLY = 'monthly',
17
+ ALL_TIME = 'all-time',
18
+ }
19
+
20
+ export enum LeaderboardMetric {
21
+ TRUST_SCORE = 'trust_score',
22
+ DELIVERIES = 'deliveries',
23
+ ZONE_COVERAGE = 'zone_coverage',
24
+ SAFETY_RECORD = 'safety_record',
25
+ COMPOSITE = 'composite',
26
+ }
27
+
28
+ export interface LeaderboardEntry {
29
+ readonly robotId: string;
30
+ readonly fleetId: string;
31
+ readonly rank: number;
32
+ readonly score: number;
33
+ readonly trustTier: TrustTier;
34
+ readonly totalVerifications: number;
35
+ readonly successRate: number;
36
+ readonly zonesExplored: number;
37
+ readonly badgeCount: number;
38
+ readonly streakDays: number;
39
+ readonly maxZoneMastery: number;
40
+ readonly spoofingIncidents: number;
41
+ readonly timestamp: number;
42
+ readonly signature: string;
43
+ }
44
+
45
+ export interface RobotStats {
46
+ readonly robotId: string;
47
+ readonly fleetId: string;
48
+ readonly trustTier: TrustTier;
49
+ readonly points: number;
50
+ readonly totalVerifications: number;
51
+ readonly successfulVerifications: number;
52
+ readonly zonesExplored: number;
53
+ readonly badgeCount: number;
54
+ readonly streakDays: number;
55
+ readonly maxZoneMastery: number;
56
+ readonly spoofingIncidents: number;
57
+ }
58
+
59
+ export interface FleetSummary {
60
+ readonly fleetId: string;
61
+ readonly robotCount: number;
62
+ readonly averageTrustScore: number;
63
+ readonly totalDeliveries: number;
64
+ readonly averageSuccessRate: number;
65
+ readonly totalZonesExplored: number;
66
+ readonly topRobotId: string | null;
67
+ }
68
+
69
+ function signEntry(entry: Omit<LeaderboardEntry, 'signature'>, secret: string): string {
70
+ const payload = `lb:${entry.robotId}:${entry.fleetId}:${entry.rank}:${entry.score}:${entry.timestamp}`;
71
+ return hmacSign(Buffer.from(payload), secret);
72
+ }
73
+
74
+ export function verifyLeaderboardEntry(entry: LeaderboardEntry, secret: string): boolean {
75
+ const payload = `lb:${entry.robotId}:${entry.fleetId}:${entry.rank}:${entry.score}:${entry.timestamp}`;
76
+ return hmacVerify(Buffer.from(payload), entry.signature, secret);
77
+ }
78
+
79
+ function computeCompositeScore(stats: RobotStats): number {
80
+ const successRate = stats.totalVerifications > 0
81
+ ? stats.successfulVerifications / stats.totalVerifications
82
+ : 0;
83
+
84
+ // Weighted composite:
85
+ // Trust tier contribution: 25%
86
+ const trustScore = (stats.trustTier / TrustTier.AUTONOMOUS) * 100;
87
+ // Delivery volume: 25%
88
+ const deliveryScore = Math.min(100, stats.totalVerifications / 100 * 100);
89
+ // Success rate: 25%
90
+ const rateScore = successRate * 100;
91
+ // Safety: 15% (penalize spoofing)
92
+ const safetyScore = Math.max(0, 100 - stats.spoofingIncidents * 20);
93
+ // Zone mastery: 10%
94
+ const zoneScore = stats.maxZoneMastery * 100;
95
+
96
+ return trustScore * 0.25 + deliveryScore * 0.25 + rateScore * 0.25 + safetyScore * 0.15 + zoneScore * 0.10;
97
+ }
98
+
99
+ function computeMetricScore(stats: RobotStats, metric: LeaderboardMetric): number {
100
+ switch (metric) {
101
+ case LeaderboardMetric.TRUST_SCORE:
102
+ return stats.points;
103
+ case LeaderboardMetric.DELIVERIES:
104
+ return stats.successfulVerifications;
105
+ case LeaderboardMetric.ZONE_COVERAGE:
106
+ return stats.zonesExplored + stats.maxZoneMastery * 100;
107
+ case LeaderboardMetric.SAFETY_RECORD: {
108
+ const successRate = stats.totalVerifications > 0
109
+ ? stats.successfulVerifications / stats.totalVerifications
110
+ : 0;
111
+ return successRate * 100 - stats.spoofingIncidents * 10;
112
+ }
113
+ case LeaderboardMetric.COMPOSITE:
114
+ return computeCompositeScore(stats);
115
+ }
116
+ }
117
+
118
+ export class FleetLeaderboard {
119
+ private readonly robotStats = new Map<string, RobotStats>();
120
+ private readonly secret: string;
121
+
122
+ constructor(secret: string) {
123
+ if (!secret || secret.length < 32) {
124
+ throw new Error('FleetLeaderboard requires a secret of at least 32 chars');
125
+ }
126
+ this.secret = secret;
127
+ }
128
+
129
+ /**
130
+ * Update stats for a robot
131
+ */
132
+ updateStats(stats: RobotStats): void {
133
+ if (!stats.robotId) throw new Error('robotId is required');
134
+ if (!stats.fleetId) throw new Error('fleetId is required');
135
+ this.robotStats.set(stats.robotId, stats);
136
+ }
137
+
138
+ /**
139
+ * Generate ranked leaderboard for a fleet
140
+ */
141
+ getFleetLeaderboard(
142
+ fleetId: string,
143
+ metric: LeaderboardMetric = LeaderboardMetric.COMPOSITE,
144
+ limit: number = 50,
145
+ ): readonly LeaderboardEntry[] {
146
+ if (!fleetId) throw new Error('fleetId is required');
147
+ if (limit <= 0) throw new Error('limit must be positive');
148
+
149
+ const fleetRobots: RobotStats[] = [];
150
+ for (const stats of this.robotStats.values()) {
151
+ if (stats.fleetId === fleetId) {
152
+ fleetRobots.push(stats);
153
+ }
154
+ }
155
+
156
+ // Sort by metric score descending
157
+ const scored = fleetRobots
158
+ .map(stats => ({ stats, score: computeMetricScore(stats, metric) }))
159
+ .sort((a, b) => b.score - a.score)
160
+ .slice(0, limit);
161
+
162
+ const now = Date.now();
163
+ return scored.map(({ stats, score }, index) => {
164
+ const successRate = stats.totalVerifications > 0
165
+ ? stats.successfulVerifications / stats.totalVerifications
166
+ : 0;
167
+
168
+ const entry: Omit<LeaderboardEntry, 'signature'> = {
169
+ robotId: stats.robotId,
170
+ fleetId: stats.fleetId,
171
+ rank: index + 1,
172
+ score,
173
+ trustTier: stats.trustTier,
174
+ totalVerifications: stats.totalVerifications,
175
+ successRate,
176
+ zonesExplored: stats.zonesExplored,
177
+ badgeCount: stats.badgeCount,
178
+ streakDays: stats.streakDays,
179
+ maxZoneMastery: stats.maxZoneMastery,
180
+ spoofingIncidents: stats.spoofingIncidents,
181
+ timestamp: now,
182
+ };
183
+ return { ...entry, signature: signEntry(entry, this.secret) };
184
+ });
185
+ }
186
+
187
+ /**
188
+ * Generate global leaderboard across all fleets
189
+ */
190
+ getGlobalLeaderboard(
191
+ metric: LeaderboardMetric = LeaderboardMetric.COMPOSITE,
192
+ limit: number = 100,
193
+ ): readonly LeaderboardEntry[] {
194
+ if (limit <= 0) throw new Error('limit must be positive');
195
+
196
+ const all = Array.from(this.robotStats.values());
197
+ const scored = all
198
+ .map(stats => ({ stats, score: computeMetricScore(stats, metric) }))
199
+ .sort((a, b) => b.score - a.score)
200
+ .slice(0, limit);
201
+
202
+ const now = Date.now();
203
+ return scored.map(({ stats, score }, index) => {
204
+ const successRate = stats.totalVerifications > 0
205
+ ? stats.successfulVerifications / stats.totalVerifications
206
+ : 0;
207
+
208
+ const entry: Omit<LeaderboardEntry, 'signature'> = {
209
+ robotId: stats.robotId,
210
+ fleetId: stats.fleetId,
211
+ rank: index + 1,
212
+ score,
213
+ trustTier: stats.trustTier,
214
+ totalVerifications: stats.totalVerifications,
215
+ successRate,
216
+ zonesExplored: stats.zonesExplored,
217
+ badgeCount: stats.badgeCount,
218
+ streakDays: stats.streakDays,
219
+ maxZoneMastery: stats.maxZoneMastery,
220
+ spoofingIncidents: stats.spoofingIncidents,
221
+ timestamp: now,
222
+ };
223
+ return { ...entry, signature: signEntry(entry, this.secret) };
224
+ });
225
+ }
226
+
227
+ /**
228
+ * Get fleet summary
229
+ */
230
+ getFleetSummary(fleetId: string): FleetSummary {
231
+ if (!fleetId) throw new Error('fleetId is required');
232
+
233
+ const fleetRobots: RobotStats[] = [];
234
+ for (const stats of this.robotStats.values()) {
235
+ if (stats.fleetId === fleetId) fleetRobots.push(stats);
236
+ }
237
+
238
+ if (fleetRobots.length === 0) {
239
+ return {
240
+ fleetId,
241
+ robotCount: 0,
242
+ averageTrustScore: 0,
243
+ totalDeliveries: 0,
244
+ averageSuccessRate: 0,
245
+ totalZonesExplored: 0,
246
+ topRobotId: null,
247
+ };
248
+ }
249
+
250
+ const totalPoints = fleetRobots.reduce((s, r) => s + r.points, 0);
251
+ const totalVerifications = fleetRobots.reduce((s, r) => s + r.totalVerifications, 0);
252
+ const totalSuccesses = fleetRobots.reduce((s, r) => s + r.successfulVerifications, 0);
253
+ const allZones = new Set<number>();
254
+ fleetRobots.forEach(r => { for (let i = 0; i < r.zonesExplored; i++) allZones.add(i); });
255
+
256
+ // Find top robot by composite score
257
+ let topRobot = fleetRobots[0]!;
258
+ let topScore = computeCompositeScore(topRobot);
259
+ for (const robot of fleetRobots) {
260
+ const score = computeCompositeScore(robot);
261
+ if (score > topScore) {
262
+ topRobot = robot;
263
+ topScore = score;
264
+ }
265
+ }
266
+
267
+ return {
268
+ fleetId,
269
+ robotCount: fleetRobots.length,
270
+ averageTrustScore: totalPoints / fleetRobots.length,
271
+ totalDeliveries: totalSuccesses,
272
+ averageSuccessRate: totalVerifications > 0 ? totalSuccesses / totalVerifications : 0,
273
+ totalZonesExplored: fleetRobots.reduce((s, r) => s + r.zonesExplored, 0),
274
+ topRobotId: topRobot.robotId,
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Get rank of a specific robot
280
+ */
281
+ getRobotRank(robotId: string, metric: LeaderboardMetric = LeaderboardMetric.COMPOSITE): number | null {
282
+ const stats = this.robotStats.get(robotId);
283
+ if (!stats) return null;
284
+
285
+ const leaderboard = this.getFleetLeaderboard(stats.fleetId, metric, 1000);
286
+ const entry = leaderboard.find(e => e.robotId === robotId);
287
+ return entry?.rank ?? null;
288
+ }
289
+
290
+ get totalRobots(): number {
291
+ return this.robotStats.size;
292
+ }
293
+ }
@@ -0,0 +1,44 @@
1
+ export {
2
+ TrustTier,
3
+ TrustTierSystem,
4
+ verifyTierChange,
5
+ TIER_CONFIGS,
6
+ type TierConfig,
7
+ type TierChangeEvent,
8
+ type RobotTrustProfile,
9
+ } from './trust-tiers.js';
10
+
11
+ export {
12
+ BadgeCategory,
13
+ BadgeRarity,
14
+ BadgeSystem,
15
+ verifyBadgeAward,
16
+ BADGE_CATALOG,
17
+ type BadgeDefinition,
18
+ type BadgeCriteria,
19
+ type EarnedBadge,
20
+ type RobotMetrics,
21
+ } from './badges.js';
22
+
23
+ export {
24
+ StreakSystem,
25
+ type StreakRecord,
26
+ type StreakConfig,
27
+ } from './streaks.js';
28
+
29
+ export {
30
+ ZoneMasterySystem,
31
+ type Zone,
32
+ type ZoneMasteryRecord,
33
+ type ZoneMasteryScore,
34
+ } from './zone-mastery.js';
35
+
36
+ export {
37
+ FleetLeaderboard,
38
+ LeaderboardPeriod,
39
+ LeaderboardMetric,
40
+ verifyLeaderboardEntry,
41
+ type LeaderboardEntry,
42
+ type RobotStats,
43
+ type FleetSummary,
44
+ } from './fleet-leaderboard.js';
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Streak Multiplier System — Reward consistent daily operation
3
+ *
4
+ * Maps WeMeetWeMet's streak system:
5
+ * Consecutive verified days → multiplier bonus → trust acceleration
6
+ * Streak freeze available at a point cost (max 2/month)
7
+ * Break = reset to 0, trust penalty applied
8
+ */
9
+
10
+ import { hmacSign } from '../utils/crypto.js';
11
+
12
+ export interface StreakRecord {
13
+ readonly robotId: string;
14
+ readonly currentStreak: number; // consecutive days
15
+ readonly longestStreak: number;
16
+ readonly lastActivityDate: string; // YYYY-MM-DD (UTC)
17
+ readonly freezesUsedThisMonth: number;
18
+ readonly freezeMonth: string; // YYYY-MM for freeze tracking
19
+ readonly totalPointsFromStreaks: number;
20
+ readonly signature: string; // HMAC of current state
21
+ }
22
+
23
+ export interface StreakConfig {
24
+ readonly maxMultiplier: number; // cap (default 2.0)
25
+ readonly multiplierStep: number; // per day (default 0.1)
26
+ readonly baseMultiplier: number; // starting (default 1.0)
27
+ readonly maxFreezesPerMonth: number; // default 2
28
+ readonly freezeCostPoints: number; // default 500
29
+ readonly breakPenaltyPercent: number; // % of points lost on break (default 5)
30
+ }
31
+
32
+ const DEFAULT_CONFIG: StreakConfig = {
33
+ maxMultiplier: 2.0,
34
+ multiplierStep: 0.1,
35
+ baseMultiplier: 1.0,
36
+ maxFreezesPerMonth: 2,
37
+ freezeCostPoints: 500,
38
+ breakPenaltyPercent: 5,
39
+ };
40
+
41
+ function getUTCDateString(timestamp?: number): string {
42
+ const d = timestamp ? new Date(timestamp) : new Date();
43
+ return d.toISOString().split('T')[0]!;
44
+ }
45
+
46
+ function getUTCMonthString(timestamp?: number): string {
47
+ const d = timestamp ? new Date(timestamp) : new Date();
48
+ return d.toISOString().slice(0, 7); // YYYY-MM
49
+ }
50
+
51
+ function daysBetween(dateA: string, dateB: string): number {
52
+ const a = new Date(dateA + 'T00:00:00Z').getTime();
53
+ const b = new Date(dateB + 'T00:00:00Z').getTime();
54
+ return Math.abs(Math.round((b - a) / (24 * 60 * 60 * 1000)));
55
+ }
56
+
57
+ function signStreak(record: Omit<StreakRecord, 'signature'>, secret: string): string {
58
+ const payload = `streak:${record.robotId}:${record.currentStreak}:${record.longestStreak}:${record.lastActivityDate}:${record.totalPointsFromStreaks}`;
59
+ return hmacSign(Buffer.from(payload), secret);
60
+ }
61
+
62
+ export class StreakSystem {
63
+ private readonly records = new Map<string, StreakRecord>();
64
+ private readonly secret: string;
65
+ private readonly config: StreakConfig;
66
+
67
+ constructor(secret: string, config?: Partial<StreakConfig>) {
68
+ if (!secret || secret.length < 32) {
69
+ throw new Error('StreakSystem requires a secret of at least 32 chars');
70
+ }
71
+ this.secret = secret;
72
+ this.config = { ...DEFAULT_CONFIG, ...config };
73
+ }
74
+
75
+ /**
76
+ * Register a robot for streak tracking
77
+ */
78
+ register(robotId: string): StreakRecord {
79
+ if (!robotId) throw new Error('robotId is required');
80
+ if (this.records.has(robotId)) throw new Error(`Robot ${robotId} already registered`);
81
+
82
+ const record: Omit<StreakRecord, 'signature'> = {
83
+ robotId,
84
+ currentStreak: 0,
85
+ longestStreak: 0,
86
+ lastActivityDate: '',
87
+ freezesUsedThisMonth: 0,
88
+ freezeMonth: getUTCMonthString(),
89
+ totalPointsFromStreaks: 0,
90
+ };
91
+ const signed: StreakRecord = { ...record, signature: signStreak(record, this.secret) };
92
+ this.records.set(robotId, signed);
93
+ return signed;
94
+ }
95
+
96
+ /**
97
+ * Record daily activity — extends or starts streak
98
+ * Returns: { streakDays, multiplier, bonusPoints }
99
+ */
100
+ recordActivity(
101
+ robotId: string,
102
+ basePoints: number,
103
+ now?: number,
104
+ ): { streakDays: number; multiplier: number; bonusPoints: number; totalPoints: number } {
105
+ const record = this.records.get(robotId);
106
+ if (!record) throw new Error(`Robot ${robotId} not registered`);
107
+ if (basePoints < 0) throw new Error('basePoints must be non-negative');
108
+
109
+ const today = getUTCDateString(now);
110
+ const currentMonth = getUTCMonthString(now);
111
+
112
+ // Reset freeze counter if new month
113
+ let freezesUsed = record.freezesUsedThisMonth;
114
+ let freezeMonth = record.freezeMonth;
115
+ if (currentMonth !== record.freezeMonth) {
116
+ freezesUsed = 0;
117
+ freezeMonth = currentMonth;
118
+ }
119
+
120
+ let newStreak: number;
121
+
122
+ if (record.lastActivityDate === '') {
123
+ // First activity ever
124
+ newStreak = 1;
125
+ } else if (record.lastActivityDate === today) {
126
+ // Already recorded today — no streak change, just return current
127
+ newStreak = record.currentStreak;
128
+ } else {
129
+ const gap = daysBetween(record.lastActivityDate, today);
130
+ if (gap === 1) {
131
+ // Consecutive day — extend streak
132
+ newStreak = record.currentStreak + 1;
133
+ } else if (gap === 0) {
134
+ // Same day
135
+ newStreak = record.currentStreak;
136
+ } else {
137
+ // Gap detected — streak broken
138
+ newStreak = 1; // restart
139
+ }
140
+ }
141
+
142
+ const multiplier = this.calculateMultiplier(newStreak);
143
+ const bonusPoints = Math.round(basePoints * (multiplier - 1));
144
+ const totalPoints = basePoints + bonusPoints;
145
+
146
+ const updated: Omit<StreakRecord, 'signature'> = {
147
+ robotId,
148
+ currentStreak: newStreak,
149
+ longestStreak: Math.max(record.longestStreak, newStreak),
150
+ lastActivityDate: today,
151
+ freezesUsedThisMonth: freezesUsed,
152
+ freezeMonth,
153
+ totalPointsFromStreaks: record.totalPointsFromStreaks + bonusPoints,
154
+ };
155
+ this.records.set(robotId, { ...updated, signature: signStreak(updated, this.secret) });
156
+
157
+ return { streakDays: newStreak, multiplier, bonusPoints, totalPoints };
158
+ }
159
+
160
+ /**
161
+ * Use a streak freeze to preserve streak during a missed day
162
+ * Returns true if freeze applied, false if not available
163
+ */
164
+ useFreeze(robotId: string, currentPoints: number, now?: number): { applied: boolean; pointsDeducted: number } {
165
+ const record = this.records.get(robotId);
166
+ if (!record) throw new Error(`Robot ${robotId} not registered`);
167
+
168
+ const currentMonth = getUTCMonthString(now);
169
+ let freezesUsed = record.freezesUsedThisMonth;
170
+ let freezeMonth = record.freezeMonth;
171
+
172
+ // Reset if new month
173
+ if (currentMonth !== record.freezeMonth) {
174
+ freezesUsed = 0;
175
+ freezeMonth = currentMonth;
176
+ }
177
+
178
+ // Check if freeze is available
179
+ if (freezesUsed >= this.config.maxFreezesPerMonth) {
180
+ return { applied: false, pointsDeducted: 0 };
181
+ }
182
+
183
+ // Check if robot can afford the freeze
184
+ if (currentPoints < this.config.freezeCostPoints) {
185
+ return { applied: false, pointsDeducted: 0 };
186
+ }
187
+
188
+ // Apply freeze — streak is preserved, points deducted
189
+ const updated: Omit<StreakRecord, 'signature'> = {
190
+ ...record,
191
+ freezesUsedThisMonth: freezesUsed + 1,
192
+ freezeMonth,
193
+ };
194
+ this.records.set(robotId, { ...updated, signature: signStreak(updated, this.secret) });
195
+
196
+ return { applied: true, pointsDeducted: this.config.freezeCostPoints };
197
+ }
198
+
199
+ /**
200
+ * Force break a streak (called on verification failure or spoofing)
201
+ */
202
+ breakStreak(robotId: string): { penaltyPercent: number } {
203
+ const record = this.records.get(robotId);
204
+ if (!record) throw new Error(`Robot ${robotId} not registered`);
205
+
206
+ const updated: Omit<StreakRecord, 'signature'> = {
207
+ ...record,
208
+ currentStreak: 0,
209
+ };
210
+ this.records.set(robotId, { ...updated, signature: signStreak(updated, this.secret) });
211
+
212
+ return { penaltyPercent: this.config.breakPenaltyPercent };
213
+ }
214
+
215
+ /**
216
+ * Calculate multiplier for a given streak length
217
+ */
218
+ calculateMultiplier(streakDays: number): number {
219
+ const raw = this.config.baseMultiplier + (streakDays * this.config.multiplierStep);
220
+ return Math.min(raw, this.config.maxMultiplier);
221
+ }
222
+
223
+ /**
224
+ * Get current streak record
225
+ */
226
+ getRecord(robotId: string): StreakRecord | undefined {
227
+ return this.records.get(robotId);
228
+ }
229
+
230
+ /**
231
+ * Verify integrity of a streak record
232
+ */
233
+ verifyRecord(robotId: string): boolean {
234
+ const record = this.records.get(robotId);
235
+ if (!record) return false;
236
+ const { signature, ...rest } = record;
237
+ return signStreak(rest, this.secret) === signature;
238
+ }
239
+
240
+ get robotCount(): number {
241
+ return this.records.size;
242
+ }
243
+ }