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,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
|
+
}
|