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
63 changes: 63 additions & 0 deletions app/lib/features/home/perbug_game_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import 'map_discovery_clients.dart';
import 'map_discovery_models.dart';
import 'map_discovery_tab.dart' show mapGeoClientProvider;
import 'perbug_game_models.dart';
import 'puzzles/perbug_puzzle_framework.dart';
import 'puzzles/perbug_symbol_match.dart';

final perbugGameControllerProvider = StateNotifierProvider<PerbugGameController, PerbugGameState>((ref) {
return PerbugGameController(ref);
Expand All @@ -14,6 +16,7 @@ class PerbugGameController extends StateNotifier<PerbugGameState> {
PerbugGameController(this._ref) : super(PerbugGameState.initial());

final Ref _ref;
final SymbolMatchGenerator _symbolMatchGenerator = const SymbolMatchGenerator();

static const MapViewport _fixedGameplayViewport = MapViewport(centerLat: 30.2672, centerLng: -97.7431, zoom: 13);

Expand Down Expand Up @@ -77,6 +80,66 @@ class PerbugGameController extends StateNotifier<PerbugGameState> {
return true;
}

SymbolMatchPuzzleInstance buildSymbolMatchPuzzleForNode(
PerbugNode node, {
SymbolMatchDifficultyKnobs? knobs,
int salt = 0,
}) {
final generatedKnobs = knobs ?? defaultSymbolMatchKnobsForNode(node);
final seed = PuzzleSeedInput(
nodeId: node.id,
latitude: node.latitude,
longitude: node.longitude,
salt: salt,
);

final puzzle = _symbolMatchGenerator.generate(seedInput: seed, knobs: generatedKnobs);
final session = PuzzleSession(
puzzleId: puzzle.id,
puzzleType: puzzle.type,
nodeId: node.id,
startedAt: DateTime.now(),
currentRound: 0,
mistakes: 0,
retries: 0,
);

state = state.copyWith(activePuzzleSession: session);
return puzzle;
}

void recordPuzzleEvent({
required String type,
required String nodeId,
required Map<String, Object?> payload,
}) {
state = state.copyWith(
puzzleEvents: [
PerbugPuzzleEvent(type: type, timestamp: DateTime.now(), nodeId: nodeId, payload: payload),
...state.puzzleEvents,
],
);
}

void finalizePuzzleResult({
required PerbugNode node,
required PuzzleResult result,
}) {
recordPuzzleEvent(
type: result.success ? 'puzzle_succeeded' : 'puzzle_failed',
nodeId: node.id,
payload: {
'durationMs': result.duration.inMilliseconds,
'mistakes': result.mistakes,
'completedRounds': result.completedRounds,
'totalRounds': result.totalRounds,
'failureReason': result.failureReason,
},
);

state = state.copyWith(clearActivePuzzleSession: true);
}

void claimPassiveEnergy() {
state = state.copyWith(
energy: (state.energy + 3).clamp(0, state.maxEnergy),
Expand Down
41 changes: 41 additions & 0 deletions app/lib/features/home/perbug_game_models.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import 'dart:math' as math;

import 'map_discovery_models.dart';
import 'puzzles/perbug_puzzle_framework.dart';
import 'puzzles/perbug_symbol_match.dart';

enum PerbugNodeState { available, completed, locked, exhausted, special, futureChallengeReady }

Expand Down Expand Up @@ -66,6 +68,8 @@ class PerbugGameState {
required this.loading,
required this.visitedNodeIds,
required this.history,
required this.puzzleEvents,
this.activePuzzleSession,
this.error,
});

Expand All @@ -78,6 +82,7 @@ class PerbugGameState {
loading: false,
visitedNodeIds: {},
history: [],
puzzleEvents: [],
);

final List<PerbugNode> nodes;
Expand All @@ -88,6 +93,8 @@ class PerbugGameState {
final bool loading;
final Set<String> visitedNodeIds;
final List<String> history;
final List<PerbugPuzzleEvent> puzzleEvents;
final PuzzleSession? activePuzzleSession;
final String? error;

PerbugNode? get currentNode {
Expand All @@ -109,6 +116,9 @@ class PerbugGameState {
bool? loading,
Set<String>? visitedNodeIds,
List<String>? history,
List<PerbugPuzzleEvent>? puzzleEvents,
PuzzleSession? activePuzzleSession,
bool clearActivePuzzleSession = false,
String? error,
bool clearError = false,
}) {
Expand All @@ -121,6 +131,8 @@ class PerbugGameState {
loading: loading ?? this.loading,
visitedNodeIds: visitedNodeIds ?? this.visitedNodeIds,
history: history ?? this.history,
puzzleEvents: puzzleEvents ?? this.puzzleEvents,
activePuzzleSession: clearActivePuzzleSession ? null : (activePuzzleSession ?? this.activePuzzleSession),
error: clearError ? null : (error ?? this.error),
);
}
Expand Down Expand Up @@ -178,3 +190,32 @@ PerbugNodeState deriveNodeStateFromPin(MapPin pin) {
if (pin.hasReviews) return PerbugNodeState.futureChallengeReady;
return PerbugNodeState.available;
}

class PerbugPuzzleEvent {
const PerbugPuzzleEvent({
required this.type,
required this.timestamp,
required this.nodeId,
required this.payload,
});

final String type;
final DateTime timestamp;
final String nodeId;
final Map<String, Object?> payload;
}

SymbolMatchDifficultyKnobs defaultSymbolMatchKnobsForNode(PerbugNode node) {
final absLat = node.latitude.abs();
final absLng = node.longitude.abs();
final densityRoll = ((absLat * 10 + absLng * 3).round()) % 4;
final timer = ((absLat + absLng).round()) % 5;
return SymbolMatchDifficultyKnobs(
symbolPoolSize: 12 + densityRoll * 2,
ruleComplexity: 2 + (densityRoll % 3),
decoyCount: 3 + (densityRoll % 2),
rounds: 3 + (timer % 2),
overlapSimilarity: 0.45 + (densityRoll * 0.12).clamp(0.0, 0.4),
timerPressure: timer,
);
}
58 changes: 50 additions & 8 deletions app/lib/features/home/perbug_game_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/theme/widgets.dart';
import 'perbug_game_controller.dart';
import 'perbug_game_models.dart';
import 'puzzles/perbug_puzzle_framework.dart';
import 'puzzles/perbug_symbol_match_screen.dart';

class PerbugGamePage extends ConsumerStatefulWidget {
const PerbugGamePage({super.key});
Expand All @@ -15,6 +17,40 @@ class PerbugGamePage extends ConsumerStatefulWidget {
}

class _PerbugGamePageState extends ConsumerState<PerbugGamePage> {
Future<void> _openMovePuzzle({
required BuildContext context,
required PerbugMoveCandidate move,
required PerbugGameController controller,
}) async {
final puzzle = controller.buildSymbolMatchPuzzleForNode(move.node);
final result = await showModalBottomSheet<PuzzleResult>(
context: context,
isScrollControlled: true,
builder: (_) => SymbolMatchPuzzleSheet(
node: move.node,
puzzle: puzzle,
onEvent: (event, data) => controller.recordPuzzleEvent(type: event, nodeId: move.node.id, payload: data),
),
);

if (!mounted || result == null) return;
controller.finalizePuzzleResult(node: move.node, result: result);
if (result.success) {
final ok = await controller.jumpTo(move);
if (!mounted) return;
if (ok) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Puzzle cleared. Jumped to ${move.node.label}.')),
);
}
return;
}

ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result.failureReason ?? 'Puzzle failed. Jump cancelled.')),
);
}

@override
void initState() {
super.initState();
Expand Down Expand Up @@ -97,16 +133,10 @@ class _PerbugGamePageState extends ConsumerState<PerbugGamePage> {
trailing: TextButton(
onPressed: move.isReachable
? () async {
final ok = await controller.jumpTo(move);
if (!context.mounted) return;
if (ok) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Jumped to ${move.node.label}')),
);
}
await _openMovePuzzle(context: context, move: move, controller: controller);
}
: null,
child: Text('Jump (${move.energyCost})'),
child: Text('Challenge (${move.energyCost})'),
),
),
)
Expand All @@ -128,6 +158,18 @@ class _PerbugGamePageState extends ConsumerState<PerbugGamePage> {
),
],
),
_Section(
title: 'Puzzle telemetry (debug)',
subtitle: 'Lifecycle hooks and balancing data emitted from Symbol Match sessions.',
children: state.puzzleEvents.take(6).map((event) {
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text('${event.type} • ${event.nodeId}'),
subtitle: Text(event.timestamp.toIso8601String()),
);
}).toList(growable: false),
),
],
),
);
Expand Down
Loading