Skip to content
Draft
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
50 changes: 50 additions & 0 deletions docs/plans/remote-config-tuning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Plan — Remote Config for game balance and copy

- **Status:** Proposed
- **Effort:** Small (client) + tiny (backend)
- **Firebase services:** Remote Config, Remote Config server-side SDK (Node)

## Overview

Centralise tunable values so game balance, reminder copy, and James cadence can change without a client release. Both the Flutter client and Cloud Functions read Remote Config.

## Parameters (initial set)

| Key | Default | Notes |
|-----|---------|-------|
| `claim_radius_meters` | `30` | Used by `startScoring` geohash + distance check. |
| `points_by_monarch` | JSON `{EIIR:2,GR:4,...}` | Overrides hard-coded values in `_getPoints.ts`. |
| `james_idle_min_seconds` | `120` | Idle non-sequitur lower bound. |
| `james_idle_max_seconds` | `300` | Upper bound. |
| `daily_reminder_hour_local` | `18` | Push time. |
| `daily_reminder_copy` | James-voice string | Localisable per monarch era. |
| `nearby_radius_meters` | `540` | Compass / heatmap range. |
| `quiz_required_streak` | `7` | Quiz gates post-streak day. |

## Client

- Add `firebase_remote_config` to `pubspec.yaml`.
- `lib/services/remote_config_service.dart` — fetch-and-activate on app start (min interval 1 h in prod, 0 in debug), exposes typed getters.
- Replace magic numbers in `home.dart`, `claim.dart`, `james_strip.dart`, `fuzzy_compass.dart`.

## Backend

- Add `firebase-admin` Remote Config fetch in `_config.ts` with 5 min in-memory cache.
- `_getPoints.ts` reads `points_by_monarch` with fallback to current hard-coded table.
- `startScoring` uses `claim_radius_meters`.

## Rollout

- Phase 1: ship client reading only; values identical to current defaults.
- Phase 2: backend reading; verify via integration test with Remote Config emulator.
- Phase 3: tune via console, monitor Analytics for regressions.

## Risks

- Stale cache in the client masking a bad value — force a refetch hook at login.
- Cost drift if `points_by_monarch` is set incorrectly; add a sanity-check Cloud Function that rejects values outside `[1, 50]`.

## Testing

- Unit-test `RemoteConfigService` typed getters.
- Add emulator-backed test in `functions/src/test` that asserts `_getPoints` honours override.
11 changes: 7 additions & 4 deletions lib/claim.dart
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,14 @@ class _ClaimState extends State<Claim> with TickerProviderStateMixin {
postboxes[entry.key as String] =
Map<String, dynamic>.from(entry.value as Map);
}
// Cloud Functions serialise JS numbers as either int or double;
// assigning a double to a typed int field throws, so normalise via num.
int asInt(dynamic v) => (v as num?)?.toInt() ?? 0;
setState(() {
_count = counts['total'] ?? 0;
_maxPoints = points['max'] ?? 0;
_minPoints = points['min'] ?? 0;
_claimedToday = counts['claimedToday'] ?? 0;
_count = asInt(counts['total']);
_maxPoints = asInt(points['max']);
_minPoints = asInt(points['min']);
_claimedToday = asInt(counts['claimedToday']);
_postboxes = postboxes;
currentStage = _count > 0 ? ClaimStage.results : ClaimStage.empty;
});
Expand Down
19 changes: 11 additions & 8 deletions lib/nearby.dart
Original file line number Diff line number Diff line change
Expand Up @@ -113,23 +113,26 @@ class _NearbyState extends State<Nearby> {
final claimedCompass = data['claimedCompass'] != null
? Map<String, dynamic>.from(data['claimedCompass'] as Map)
: <String, dynamic>{};
// Cloud Functions serialise JS numbers as either int or double; assigning
// a double to a typed int field throws, so normalise every count via num.
int asInt(dynamic v) => (v as num?)?.toInt() ?? 0;
setState(() {
_count = counts['total'] ?? 0;
_maxPoints = pts['max'] ?? 0;
_minPoints = pts['min'] ?? 0;
_count = asInt(counts['total']);
_maxPoints = asInt(pts['max']);
_minPoints = asInt(pts['min']);
currentStage = NearbyStage.results;
for (final dir in const [
'N', 'NNE', 'NE', 'ENE', 'E', 'ESE',
'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW',
'W', 'WNW', 'NW', 'NNW',
]) {
_compassCounts[dir] = compass[dir] ?? 0;
_claimedCompassCounts[dir] = claimedCompass[dir] ?? 0;
_compassCounts[dir] = asInt(compass[dir]);
_claimedCompassCounts[dir] = asInt(claimedCompass[dir]);
}
_claimedToday = counts['claimedToday'] ?? 0;
_claimedToday = asInt(counts['claimedToday']);
for (final cipher in MonarchInfo.all) {
_cipherTotals[cipher] = counts[cipher] ?? 0;
_cipherClaimed[cipher] = counts['${cipher}_claimed'] ?? 0;
_cipherTotals[cipher] = asInt(counts[cipher]);
_cipherClaimed[cipher] = asInt(counts['${cipher}_claimed']);
}
_lastScanned = DateTime.now();
});
Expand Down
6 changes: 4 additions & 2 deletions lib/wear/wear_claim_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,10 @@ class _WearClaimPageState extends State<WearClaimPage> {
});
if (!mounted) return;
final counts = result.data['counts'] ?? {};
final total = (counts['total'] as int?) ?? 0;
final claimed = (counts['claimedToday'] as int?) ?? 0;
// Cloud Functions serialise JS numbers as either int or double; `as int?`
// would throw on a double, so normalise via num.
final total = (counts['total'] as num?)?.toInt() ?? 0;
final claimed = (counts['claimedToday'] as num?)?.toInt() ?? 0;
_postboxes = Map<String, dynamic>.from(result.data['postboxes'] ?? {});
setState(() {
_count = total;
Expand Down
17 changes: 14 additions & 3 deletions lib/wear/wear_compass_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,13 @@ class _WearCompassPageState extends State<WearCompassPage> {
final counts = result.data['counts'] ?? {};
final compassRaw = result.data['compass'] ?? {};
setState(() {
_totalCount = (counts['total'] as int?) ?? 0;
_compassCounts = Map<String, int>.from(compassRaw);
// Cloud Functions serialise JS numbers as either int or double;
// `as int?` would throw on a double, so normalise via num.
_totalCount = (counts['total'] as num?)?.toInt() ?? 0;
_compassCounts = {
for (final e in (compassRaw as Map).entries)
e.key as String: (e.value as num).toInt(),
};
_stage = _CompassStage.results;
});
// Haptic pulse to signal scan complete.
Expand Down Expand Up @@ -206,7 +211,13 @@ class _WearCompassPageState extends State<WearCompassPage> {
angle: -rotation,
child: CustomPaint(
size: Size(size, size),
painter: FuzzyCompassPainter(sectors: sectorValues),
// Pass rotation so the painter counter-rotates the 'N' label;
// otherwise it spins with the canvas and is unreadable whenever
// the watch is not pointing north.
painter: FuzzyCompassPainter(
sectors: sectorValues,
rotation: rotation,
),
),
),
// Centre count overlay (doesn't rotate)
Expand Down
8 changes: 6 additions & 2 deletions lib/wear/wear_status_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@ class WearStatusPage extends StatefulWidget {
class _WearStatusPageState extends State<WearStatusPage> {
final StreakService _streakService = StreakService();
late final Stream<int?> _streakStream = _streakService.streakStream();
// Cache so the StreamBuilder doesn't re-subscribe (and briefly show empty
// stats) on every rebuild. Computed once in initState from the uid at mount.
late final Stream<DocumentSnapshot<Map<String, dynamic>>>? _userDocStream =
_buildUserDocStream();

String? get _displayName => FirebaseAuth.instance.currentUser?.displayName;

Stream<DocumentSnapshot<Map<String, dynamic>>>? _userDocStream() {
Stream<DocumentSnapshot<Map<String, dynamic>>>? _buildUserDocStream() {
final uid = FirebaseAuth.instance.currentUser?.uid;
if (uid == null) return null;
return FirebaseFirestore.instance.collection('users').doc(uid).snapshots();
Expand Down Expand Up @@ -71,7 +75,7 @@ class _WearStatusPageState extends State<WearStatusPage> {

// Lifetime points
StreamBuilder<DocumentSnapshot<Map<String, dynamic>>>(
stream: _userDocStream(),
stream: _userDocStream,
builder: (context, snap) {
final data = snap.data?.data();
final lifetimePoints =
Expand Down
Loading