Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,29 @@ service cloud.firestore {
// directly. The friends array is the only field clients may modify.
match /users/{uid} {
allow read: if request.auth != null;
// The friends array and notificationPrefs map are the only fields clients
// may modify on their own document. All other fields (displayName, streak,
// lastClaimDate, createdAt, points) are written by Cloud Functions using
// the Admin SDK, which bypasses these rules. FCM tokens live in the
// separate fcmTokens/{uid} collection to avoid exposure here.
// friends is capped at 200 entries to prevent O(n) leaderboard read abuse.
// notificationPrefs must be a map (type-validated to prevent garbage writes).
// The friends array, notificationPrefs map, and avatar map are the only
// fields clients may modify on their own document. All other fields
// (displayName, streak, lastClaimDate, createdAt, points) are written by
// Cloud Functions using the Admin SDK, which bypasses these rules. FCM
// tokens live in the separate fcmTokens/{uid} collection to avoid
// exposure here. friends is capped at 200 entries to prevent O(n)
// leaderboard read abuse. notificationPrefs and avatar must be maps
// (type-validated to prevent garbage writes); avatar is additionally
// size-capped to prevent payload abuse.
allow update: if request.auth != null && request.auth.uid == uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['friends', 'notificationPrefs'])
.hasOnly(['friends', 'notificationPrefs', 'avatar'])
&& (!request.resource.data.diff(resource.data).affectedKeys()
.hasAny(['friends'])
|| (request.resource.data.friends is list
&& request.resource.data.friends.size() <= 200))
&& (!request.resource.data.diff(resource.data).affectedKeys()
.hasAny(['notificationPrefs'])
|| request.resource.data.notificationPrefs is map);
|| request.resource.data.notificationPrefs is map)
&& (!request.resource.data.diff(resource.data).affectedKeys()
.hasAny(['avatar'])
|| (request.resource.data.avatar is map
&& request.resource.data.avatar.size() <= 20));
// Create and all other writes are handled by Cloud Functions.
allow create: if false;
}
Expand Down
24 changes: 18 additions & 6 deletions functions/src/_leaderboardUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,16 @@ export function getPeriodKey(name: string, startDate: string): string {
return `month:${startDate.slice(0, 7)}`;
}

/** A user's avatar config — small map of {slotKey: optionIndex}. Embedded in
* leaderboard entries so the global tab can render player avatars without an
* N+1 read of users/{uid}. Schema mirrors `lib/avatar/avatar_config.dart`. */
export type AvatarMap = Record<string, number>;

export interface LeaderboardEntry {
uid: string;
displayName: string;
points: number;
avatar?: AvatarMap;
}

/**
Expand All @@ -51,12 +57,15 @@ export function mergePeriodEntries(
uid: string,
displayName: string,
userPoints: number,
limit = 100
limit = 100,
avatar?: AvatarMap
): LeaderboardEntry[] {
const others = existing.filter((e) => e.uid !== uid);
return [
...others,
...(userPoints > 0 ? [{ uid, displayName, points: userPoints }] : []),
...(userPoints > 0
? [{ uid, displayName, points: userPoints, ...(avatar ? { avatar } : {}) }]
: []),
]
.sort((a, b) => b.points - a.points)
.slice(0, limit);
Expand All @@ -82,7 +91,8 @@ export async function updateUserLeaderboards(
uid: string,
displayName: string,
today: string,
db: Firestore
db: Firestore,
avatar?: AvatarMap
): Promise<PeriodSums> {
const weekStart = getWeekStart(today);
const monthStart = getMonthStart(today);
Expand Down Expand Up @@ -131,7 +141,7 @@ export async function updateUserLeaderboards(

// Upsert this user's entry, or remove it if they have 0 points (e.g.
// updateDisplayName called before any claim in this period).
const updatedEntries = mergePeriodEntries(existing, uid, displayName, userPoints);
const updatedEntries = mergePeriodEntries(existing, uid, displayName, userPoints, 100, avatar);
tx.set(leaderboardRef, { periodKey: currentPeriodKey, entries: updatedEntries }, { merge: false });
});
} catch (err) {
Expand Down Expand Up @@ -161,6 +171,7 @@ export interface LifetimeLeaderboardEntry {
displayName: string;
uniquePostboxesClaimed: number;
totalPoints: number;
avatar?: AvatarMap;
}

/**
Expand All @@ -178,13 +189,14 @@ export function mergeLifetimeEntries(
displayName: string,
uniquePostboxesClaimed: number,
totalPoints: number,
limit = 100
limit = 100,
avatar?: AvatarMap
): LifetimeLeaderboardEntry[] {
const others = existing.filter((e) => e.uid !== uid);
return [
...others,
...(uniquePostboxesClaimed > 0 || totalPoints > 0
? [{ uid, displayName, uniquePostboxesClaimed, totalPoints }]
? [{ uid, displayName, uniquePostboxesClaimed, totalPoints, ...(avatar ? { avatar } : {}) }]
: []),
]
.sort((a, b) =>
Expand Down
6 changes: 4 additions & 2 deletions functions/src/newDayScoreboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,12 @@ export async function rebuildPeriodLeaderboard(
const uid = uids[i];
const pts = userPoints.get(uid)!;
if (pts <= 0) continue;
const data = userDocs[i].data();
const displayName =
(userDocs[i].data()?.displayName as string | undefined) ??
(data?.displayName as string | undefined) ??
`Player_${uid.slice(0, 6)}`;
entries.push({ uid, displayName, points: pts });
const avatar = data?.avatar as Record<string, number> | undefined;
entries.push({ uid, displayName, points: pts, ...(avatar ? { avatar } : {}) });
}

entries.sort((a, b) => b.points - a.points);
Expand Down
7 changes: 5 additions & 2 deletions functions/src/startScoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,13 +174,14 @@ export const startScoring = functions.https.onCall(async (request) => {
const displayName =
(userDoc.data()?.displayName as string | undefined) ||
`Player_${userid.slice(0, 6)}`;
const userAvatar = userDoc.data()?.avatar as Record<string, number> | undefined;

// Run the period leaderboard update and the uniqueness checks in parallel.
// updateUserLeaderboards uses Promise.allSettled internally and never
// throws; uniqueness checks use allSettled so individual read failures
// only affect that postbox's unique count without aborting the rest.
const [periodSums, uniqueCheckResults] = await Promise.all([
updateUserLeaderboards(userid, displayName, todayLondon, database),
updateUserLeaderboards(userid, displayName, todayLondon, database, userAvatar),
Promise.allSettled(
// For each postbox claimed this session, check whether the user has any
// prior claim on a different day. Empty result → first-ever claim for
Expand Down Expand Up @@ -247,6 +248,8 @@ export const startScoring = functions.https.onCall(async (request) => {
// stale pre-fetched name when we write the lifetime entry below.
const freshDisplayName =
(d.displayName as string | undefined) || displayName;
const freshAvatar =
(d.avatar as Record<string, number> | undefined) ?? userAvatar;

tx.set(
userRef,
Expand All @@ -265,7 +268,7 @@ export const startScoring = functions.https.onCall(async (request) => {
);

const existingEntries = (lifetimeSnap.data()?.entries ?? []) as LifetimeLeaderboardEntry[];
const updatedEntries = mergeLifetimeEntries(existingEntries, userid, freshDisplayName, newUnique, newLifetimePoints);
const updatedEntries = mergeLifetimeEntries(existingEntries, userid, freshDisplayName, newUnique, newLifetimePoints, 100, freshAvatar);
tx.set(lifetimeRef, { periodKey: "lifetime", entries: updatedEntries }, { merge: false });
});

Expand Down
14 changes: 12 additions & 2 deletions functions/src/updateDisplayName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,16 @@ export const updateDisplayName = functions.https.onCall(async (request) => {
// updateUserLeaderboards uses allSettled and never throws; period
// failures are logged inside it.
const today = getTodayLondon();
await updateUserLeaderboards(uid, name, today, admin.firestore());
// Look up the user's avatar so the leaderboard refresh carries it through
// alongside the new display name; otherwise a name change would strip avatar
// from period entries until the user's next claim rewrote them.
const userSnapForAvatar = await admin.firestore()
.collection("users")
.doc(uid)
.get();
const avatarForLeaderboard =
userSnapForAvatar.data()?.avatar as Record<string, number> | undefined;
await updateUserLeaderboards(uid, name, today, admin.firestore(), avatarForLeaderboard);

// Read user doc and update lifetime leaderboard atomically in one transaction
// so a concurrent startScoring claim cannot race between our read of
Expand All @@ -91,7 +100,8 @@ export const updateDisplayName = functions.https.onCall(async (request) => {
const uniquePostboxesClaimed = ((d.uniquePostboxesClaimed as number | undefined) ?? 0);
const lifetimePoints = ((d.lifetimePoints as number | undefined) ?? 0);
const existing = (lifetimeSnap.data()?.entries ?? []) as LifetimeLeaderboardEntry[];
const updated = mergeLifetimeEntries(existing, uid, name, uniquePostboxesClaimed, lifetimePoints);
const avatarInTx = (d.avatar as Record<string, number> | undefined) ?? avatarForLeaderboard;
const updated = mergeLifetimeEntries(existing, uid, name, uniquePostboxesClaimed, lifetimePoints, 100, avatarInTx);
tx.set(lifetimeRef, { periodKey: "lifetime", entries: updated }, { merge: false });
});
} catch (lifetimeErr) {
Expand Down
148 changes: 148 additions & 0 deletions lib/avatar/avatar_config.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import 'dart:math';

import 'avatar_parts.dart';

/// One slot the user can pick from in the avatar creator.
enum AvatarSlot {
background('bg', 'Background'),
skin('skin', 'Skin Tone'),
head('head', 'Head Shape'),
hair('hair', 'Hair Style'),
hairColor('hairColor', 'Hair Colour'),
eyes('eyes', 'Eyes'),
nose('nose', 'Nose'),
facial('facial', 'Facial Hair'),
glasses('glasses', 'Glasses'),
hat('hat', 'Hat');

final String key;
final String label;
const AvatarSlot(this.key, this.label);

int length() => switch (this) {
AvatarSlot.background => avatarBackgrounds.length,
AvatarSlot.skin => avatarSkin.length,
AvatarSlot.head => avatarHeads.length,
AvatarSlot.hair => avatarHair.length,
AvatarSlot.hairColor => avatarHairColors.length,
AvatarSlot.eyes => avatarEyes.length,
AvatarSlot.nose => avatarNoses.length,
AvatarSlot.facial => avatarFacial.length,
AvatarSlot.glasses => avatarGlasses.length,
AvatarSlot.hat => avatarHats.length,
};

String optionName(int index) {
final i = index.clamp(0, length() - 1);
return switch (this) {
AvatarSlot.background => avatarBackgrounds[i].name,
AvatarSlot.skin => avatarSkin[i].name,
AvatarSlot.head => avatarHeads[i].name,
AvatarSlot.hair => avatarHair[i].name,
AvatarSlot.hairColor => avatarHairColors[i].name,
AvatarSlot.eyes => avatarEyes[i].name,
AvatarSlot.nose => avatarNoses[i].name,
AvatarSlot.facial => avatarFacial[i].name,
AvatarSlot.glasses => avatarGlasses[i].name,
AvatarSlot.hat => avatarHats[i].name,
};
}

/// Hex string for slot options that have a swatch (skin, hair, bg).
String? swatchColor(int index) {
final i = index.clamp(0, length() - 1);
return switch (this) {
AvatarSlot.background => avatarBackgrounds[i].fill,
AvatarSlot.skin => avatarSkin[i].fill,
AvatarSlot.hairColor => avatarHairColors[i].fill,
_ => null,
};
}
}

/// User's avatar configuration — a small map of slot indices.
class AvatarConfig {
final Map<AvatarSlot, int> indices;

const AvatarConfig._(this.indices);

factory AvatarConfig.fromIndices(Map<AvatarSlot, int> indices) {
final clamped = <AvatarSlot, int>{};
for (final slot in AvatarSlot.values) {
final raw = indices[slot] ?? 0;
clamped[slot] = raw.clamp(0, slot.length() - 1);
}
return AvatarConfig._(clamped);
}

/// Default Postman James-ish avatar: peach skin, side-parting hair, brown
/// hair, happy eyes, button nose, postman cap, navy background.
factory AvatarConfig.defaultPostie() => AvatarConfig.fromIndices({
AvatarSlot.background: 1, // navy
AvatarSlot.skin: 1, // peach
AvatarSlot.head: 0, // oval
AvatarSlot.hair: 2, // side parting
AvatarSlot.hairColor: 1, // brown
AvatarSlot.eyes: 2, // happy
AvatarSlot.nose: 0, // button
AvatarSlot.facial: 0, // clean shaven
AvatarSlot.glasses: 0, // none
AvatarSlot.hat: 1, // postman cap
});

factory AvatarConfig.random([Random? rng]) {
final r = rng ?? Random();
return AvatarConfig.fromIndices({
for (final slot in AvatarSlot.values)
slot: slot == AvatarSlot.hat
// Hats appear roughly half the time so randomise doesn't always crown the user.
? (r.nextDouble() < 0.5 ? 0 : r.nextInt(slot.length()))
: r.nextInt(slot.length()),
});
}

/// Parse from Firestore `users/{uid}.avatar` map. Returns null for missing /
/// unrecognised structure so the caller can fall back to the initials avatar.
static AvatarConfig? tryFromMap(Object? raw) {
if (raw is! Map) return null;
final indices = <AvatarSlot, int>{};
for (final slot in AvatarSlot.values) {
final v = raw[slot.key];
if (v is num) indices[slot] = v.toInt();
}
if (indices.isEmpty) return null;
return AvatarConfig.fromIndices(indices);
}

Map<String, int> toMap() =>
{for (final slot in AvatarSlot.values) slot.key: indices[slot] ?? 0};

int operator [](AvatarSlot slot) => indices[slot] ?? 0;

AvatarConfig copyWith(AvatarSlot slot, int value) {
final next = Map<AvatarSlot, int>.from(indices);
next[slot] = value.clamp(0, slot.length() - 1);
return AvatarConfig._(next);
}

AvatarConfig cycle(AvatarSlot slot, int direction) {
final len = slot.length();
final cur = this[slot];
final next = (cur + direction + len) % len;
return copyWith(slot, next);
}

@override
bool operator ==(Object other) {
if (other is! AvatarConfig) return false;
for (final slot in AvatarSlot.values) {
if (other[slot] != this[slot]) return false;
}
return true;
}

@override
int get hashCode => Object.hashAll([
for (final slot in AvatarSlot.values) indices[slot] ?? 0,
]);
}
Loading
Loading