forge-remote 0.1.22 → 0.1.23
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/firestore.rules +9 -0
- package/package.json +1 -1
- package/src/session-manager.js +136 -0
package/firestore.rules
CHANGED
|
@@ -91,6 +91,15 @@ service cloud.firestore {
|
|
|
91
91
|
&& request.resource.data.claimedBy == request.auth.uid;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// ---- Characters (RPG character system) ----
|
|
95
|
+
// BYOF: single-player, so 'player' is the only document.
|
|
96
|
+
// The relay writes XP awards; the mobile app reads + creates.
|
|
97
|
+
match /characters/{characterId} {
|
|
98
|
+
allow read: if isSignedIn();
|
|
99
|
+
allow create: if isSignedIn();
|
|
100
|
+
allow update: if isSignedIn() && isValidSize();
|
|
101
|
+
}
|
|
102
|
+
|
|
94
103
|
// ---- User profiles (for preferences) ----
|
|
95
104
|
match /users/{userId} {
|
|
96
105
|
allow read, write: if request.auth != null && request.auth.uid == userId;
|
package/package.json
CHANGED
package/src/session-manager.js
CHANGED
|
@@ -1162,6 +1162,17 @@ async function stopSession(sessionId) {
|
|
|
1162
1162
|
: 0;
|
|
1163
1163
|
const durationStr = formatDuration(duration);
|
|
1164
1164
|
|
|
1165
|
+
// Award XP before deleting session from the map.
|
|
1166
|
+
if (session) {
|
|
1167
|
+
awardSessionXp(sessionId, {
|
|
1168
|
+
toolCalls: session.toolCallCount || 0,
|
|
1169
|
+
tokensUsed:
|
|
1170
|
+
(session.tokenUsage?.input || 0) + (session.tokenUsage?.output || 0),
|
|
1171
|
+
}).catch((e) =>
|
|
1172
|
+
log.warn(`Failed to award XP for ${sessionId.slice(0, 8)}: ${e.message}`),
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1165
1176
|
activeSessions.delete(sessionId);
|
|
1166
1177
|
|
|
1167
1178
|
await sessionRef.update({
|
|
@@ -1238,6 +1249,17 @@ async function killSession(sessionId) {
|
|
|
1238
1249
|
: 0;
|
|
1239
1250
|
const durationStr = formatDuration(duration);
|
|
1240
1251
|
|
|
1252
|
+
// Award XP before deleting session from the map.
|
|
1253
|
+
if (session) {
|
|
1254
|
+
awardSessionXp(sessionId, {
|
|
1255
|
+
toolCalls: session.toolCallCount || 0,
|
|
1256
|
+
tokensUsed:
|
|
1257
|
+
(session.tokenUsage?.input || 0) + (session.tokenUsage?.output || 0),
|
|
1258
|
+
}).catch((e) =>
|
|
1259
|
+
log.warn(`Failed to award XP for ${sessionId.slice(0, 8)}: ${e.message}`),
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1241
1263
|
activeSessions.delete(sessionId);
|
|
1242
1264
|
|
|
1243
1265
|
await sessionRef.update({
|
|
@@ -1803,6 +1825,17 @@ export async function shutdownAllSessions() {
|
|
|
1803
1825
|
|
|
1804
1826
|
const duration = Math.round((Date.now() - session.startTime) / 1000);
|
|
1805
1827
|
|
|
1828
|
+
// Award XP before deleting session from the map.
|
|
1829
|
+
try {
|
|
1830
|
+
await awardSessionXp(sessionId, {
|
|
1831
|
+
toolCalls: session.toolCallCount || 0,
|
|
1832
|
+
tokensUsed:
|
|
1833
|
+
(session.tokenUsage?.input || 0) + (session.tokenUsage?.output || 0),
|
|
1834
|
+
});
|
|
1835
|
+
} catch (e) {
|
|
1836
|
+
log.warn(`Failed to award XP for ${sessionId.slice(0, 8)}: ${e.message}`);
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1806
1839
|
try {
|
|
1807
1840
|
await db.collection("sessions").doc(sessionId).update({
|
|
1808
1841
|
status: "completed",
|
|
@@ -2108,6 +2141,109 @@ async function handlePermissionDenied(sessionId) {
|
|
|
2108
2141
|
// Helpers
|
|
2109
2142
|
// ---------------------------------------------------------------------------
|
|
2110
2143
|
|
|
2144
|
+
// ---------------------------------------------------------------------------
|
|
2145
|
+
// Character XP — award XP when a session completes
|
|
2146
|
+
// ---------------------------------------------------------------------------
|
|
2147
|
+
|
|
2148
|
+
const XP_SESSION_COMPLETED = 50;
|
|
2149
|
+
const XP_PER_TOOL_CALL = 2;
|
|
2150
|
+
const XP_PER_1K_TOKENS = 3;
|
|
2151
|
+
const XP_DAILY_STREAK = 25;
|
|
2152
|
+
const XP_FIRST_SESSION = 15;
|
|
2153
|
+
|
|
2154
|
+
function xpForLevel(level) {
|
|
2155
|
+
if (level <= 1) return 0;
|
|
2156
|
+
return Math.round(100 * Math.pow(level, 1.5));
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
function levelForXp(totalXp) {
|
|
2160
|
+
if (totalXp <= 0) return 1;
|
|
2161
|
+
let level = 1;
|
|
2162
|
+
while (xpForLevel(level + 1) <= totalXp && level < 99) {
|
|
2163
|
+
level++;
|
|
2164
|
+
}
|
|
2165
|
+
return level;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
/**
|
|
2169
|
+
* Award XP for a completed session.
|
|
2170
|
+
*
|
|
2171
|
+
* @param {string} sessionId — for logging only
|
|
2172
|
+
* @param {{ toolCalls: number, tokensUsed: number }} stats — session stats
|
|
2173
|
+
*/
|
|
2174
|
+
async function awardSessionXp(sessionId, { toolCalls = 0, tokensUsed = 0 }) {
|
|
2175
|
+
const db = getDb();
|
|
2176
|
+
const charRef = db.collection("characters").doc("player");
|
|
2177
|
+
|
|
2178
|
+
await db.runTransaction(async (txn) => {
|
|
2179
|
+
const charDoc = await txn.get(charRef);
|
|
2180
|
+
if (!charDoc.exists) {
|
|
2181
|
+
log.warn(
|
|
2182
|
+
`[${sessionId.slice(0, 8)}] No character doc — skipping XP award`,
|
|
2183
|
+
);
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
const data = charDoc.data();
|
|
2188
|
+
const currentXp = data.totalXp || 0;
|
|
2189
|
+
const sessions = data.sessionsCompleted || 0;
|
|
2190
|
+
const totalTools = data.totalToolCalls || 0;
|
|
2191
|
+
const totalTokens = data.totalTokensUsed || 0;
|
|
2192
|
+
const currentStreak = data.currentStreak || 0;
|
|
2193
|
+
const longestStreak = data.longestStreak || 0;
|
|
2194
|
+
const lastSessionDate = data.lastSessionDate?.toDate?.() || null;
|
|
2195
|
+
|
|
2196
|
+
// Calculate XP.
|
|
2197
|
+
let xpEarned = XP_SESSION_COMPLETED;
|
|
2198
|
+
xpEarned += toolCalls * XP_PER_TOOL_CALL;
|
|
2199
|
+
xpEarned += Math.round((tokensUsed / 1000) * XP_PER_1K_TOKENS);
|
|
2200
|
+
|
|
2201
|
+
// Streak calculation.
|
|
2202
|
+
const now = new Date();
|
|
2203
|
+
let newStreak = currentStreak;
|
|
2204
|
+
if (lastSessionDate) {
|
|
2205
|
+
const lastDay = new Date(
|
|
2206
|
+
lastSessionDate.getFullYear(),
|
|
2207
|
+
lastSessionDate.getMonth(),
|
|
2208
|
+
lastSessionDate.getDate(),
|
|
2209
|
+
);
|
|
2210
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
2211
|
+
const daysDiff = Math.round((today - lastDay) / 86400000);
|
|
2212
|
+
|
|
2213
|
+
if (daysDiff === 1) {
|
|
2214
|
+
newStreak = currentStreak + 1;
|
|
2215
|
+
xpEarned += XP_DAILY_STREAK;
|
|
2216
|
+
} else if (daysDiff > 1) {
|
|
2217
|
+
newStreak = 1;
|
|
2218
|
+
}
|
|
2219
|
+
} else {
|
|
2220
|
+
newStreak = 1;
|
|
2221
|
+
xpEarned += XP_FIRST_SESSION;
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
const newXp = currentXp + xpEarned;
|
|
2225
|
+
const newLevel = levelForXp(newXp);
|
|
2226
|
+
const newLongest = Math.max(newStreak, longestStreak);
|
|
2227
|
+
|
|
2228
|
+
txn.update(charRef, {
|
|
2229
|
+
totalXp: newXp,
|
|
2230
|
+
level: newLevel,
|
|
2231
|
+
sessionsCompleted: sessions + 1,
|
|
2232
|
+
totalToolCalls: totalTools + toolCalls,
|
|
2233
|
+
totalTokensUsed: totalTokens + tokensUsed,
|
|
2234
|
+
currentStreak: newStreak,
|
|
2235
|
+
longestStreak: newLongest,
|
|
2236
|
+
lastSessionDate: FieldValue.serverTimestamp(),
|
|
2237
|
+
});
|
|
2238
|
+
|
|
2239
|
+
log.info(
|
|
2240
|
+
`[${sessionId.slice(0, 8)}] Awarded ${xpEarned} XP — ` +
|
|
2241
|
+
`tools: ${toolCalls}, tokens: ${tokensUsed}, ` +
|
|
2242
|
+
`level: ${newLevel}, streak: ${newStreak}`,
|
|
2243
|
+
);
|
|
2244
|
+
});
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2111
2247
|
function formatDuration(seconds) {
|
|
2112
2248
|
if (seconds < 60) return `${seconds}s`;
|
|
2113
2249
|
const mins = Math.floor(seconds / 60);
|