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
215 changes: 215 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,10 @@ import 'map_discovery_clients.dart';
import 'map_discovery_models.dart';
import 'map_discovery_tab.dart' show mapGeoClientProvider;
import 'perbug_game_models.dart';
import 'puzzles/pattern_recall/pattern_recall_generator.dart';
import 'puzzles/pattern_recall/pattern_recall_models.dart';
import 'puzzles/pattern_recall/pattern_recall_validator.dart';
import 'puzzles/perbug_puzzle_framework.dart';

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

final Ref _ref;
final PatternRecallGenerator _patternGenerator = const PatternRecallGenerator();
final PatternRecallValidator _patternValidator = const PatternRecallValidator();

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

Expand Down Expand Up @@ -84,6 +90,215 @@ class PerbugGameController extends StateNotifier<PerbugGameState> {
);
}

PatternRecallSession? launchPatternRecallForCurrentNode({Map<String, Object> tuning = const {}}) {
final active = state.activePatternRecall;
if (active != null && !active.phase.isTerminal) {
return active;
}
final node = state.currentNode;
if (node == null) return null;
final seedInput = PuzzleSeedInput(nodeId: node.id, latitude: node.latitude, longitude: node.longitude);
final instance = _patternGenerator.generate(
node: PuzzleNodeContext(
nodeId: node.id,
latitude: node.latitude,
longitude: node.longitude,
region: node.region,
),
seedInput: seedInput,
tuning: tuning,
);
final now = DateTime.now().toUtc();
final session = PatternRecallSession(
instance: instance,
phase: PatternRecallPhase.briefing,
currentPreviewStep: -1,
input: const [],
startedAt: now,
retries: 0,
mistakes: 0,
lifecycle: [
PuzzleLifecycleEvent(
name: 'puzzle_generated',
timestamp: now,
payload: {
'node_id': node.id,
'type': 'pattern_recall',
'difficulty_score': instance.difficulty.score,
'difficulty_tier': instance.difficulty.tier,
'sequence_length': instance.knobs.sequenceLength,
'symbol_variety': instance.knobs.symbolVariety,
'preview_duration_ms': instance.knobs.previewDurationMs,
'distraction_count': instance.knobs.distractionCount,
'mirrored': instance.isMirrored,
'reversed': instance.isReversed,
'tolerance': instance.knobs.errorTolerance,
...instance.debugMetadata(),
},
),
],
);
state = state.copyWith(
activePatternRecall: session,
puzzleEvents: [...state.puzzleEvents, {'name': 'puzzle_generated', 'node_id': node.id}],
);
return session;
}

void startPatternPreview() {
final active = state.activePatternRecall;
if (active == null) return;
final now = DateTime.now().toUtc();
state = state.copyWith(
activePatternRecall: active.copyWith(
phase: PatternRecallPhase.preview,
currentPreviewStep: 0,
lifecycle: [
...active.lifecycle,
PuzzleLifecycleEvent(name: 'puzzle_started', timestamp: now, payload: {'node_id': active.instance.seedInput.nodeId}),
],
),
);
}

void setPatternPreviewStep(int step) {
final active = state.activePatternRecall;
if (active == null || active.phase != PatternRecallPhase.preview) return;
final inBounds = step >= 0 && step < active.instance.generatedSequence.length;
if (!inBounds) return;
state = state.copyWith(activePatternRecall: active.copyWith(currentPreviewStep: step));
}

void completePatternPreview() {
final active = state.activePatternRecall;
if (active == null) return;
final now = DateTime.now().toUtc();
state = state.copyWith(
activePatternRecall: active.copyWith(
phase: PatternRecallPhase.recall,
currentPreviewStep: -1,
lifecycle: [
...active.lifecycle,
PuzzleLifecycleEvent(
name: 'preview_completed',
timestamp: now,
payload: {'node_id': active.instance.seedInput.nodeId, 'steps': active.instance.generatedSequence.length},
),
],
),
puzzleEvents: [...state.puzzleEvents, {'name': 'preview_completed', 'node_id': active.instance.seedInput.nodeId}],
);
}

void inputPatternSymbol(int symbolIndex) {
final active = state.activePatternRecall;
if (active == null || active.phase != PatternRecallPhase.recall) return;
final updatedInput = [...active.input, symbolIndex];
final expectedAt = active.instance.expectedAnswer[updatedInput.length - 1];
final mistakes = active.mistakes + (expectedAt == symbolIndex ? 0 : 1);
final updated = active.copyWith(input: updatedInput, mistakes: mistakes);

if (updatedInput.length < active.instance.expectedAnswer.length) {
state = state.copyWith(activePatternRecall: updated);
return;
}

final result = _patternValidator.validate(
instance: active.instance,
input: updatedInput,
elapsed: DateTime.now().toUtc().difference(active.startedAt),
);
final isSuccess = result.success;
final now = DateTime.now().toUtc();
final finalSession = updated.copyWith(
phase: isSuccess ? PatternRecallPhase.success : PatternRecallPhase.failure,
completedAt: now,
lifecycle: [
...updated.lifecycle,
PuzzleLifecycleEvent(
name: isSuccess ? 'puzzle_succeeded' : 'puzzle_failed',
timestamp: now,
payload: {
'node_id': active.instance.seedInput.nodeId,
'mistakes': result.mistakes,
'elapsed_ms': result.elapsed.inMilliseconds,
...result.analytics,
},
),
],
);

final gained = isSuccess ? 2 : 0;
state = state.copyWith(
activePatternRecall: finalSession,
energy: (state.energy + gained).clamp(0, state.maxEnergy),
history: [
isSuccess
? 'Solved Pattern Recall at ${state.currentNode?.label ?? 'node'} (+$gained energy)'
: 'Failed Pattern Recall at ${state.currentNode?.label ?? 'node'}',
...state.history,
],
puzzleEvents: [
...state.puzzleEvents,
{
'name': isSuccess ? 'puzzle_succeeded' : 'puzzle_failed',
'node_id': active.instance.seedInput.nodeId,
'mistakes': result.mistakes,
'elapsed_ms': result.elapsed.inMilliseconds,
'difficulty_score': active.instance.difficulty.score,
},
],
);
}

void clearPatternInput() {
final active = state.activePatternRecall;
if (active == null || active.phase != PatternRecallPhase.recall) return;
state = state.copyWith(activePatternRecall: active.copyWith(input: const []));
}

void retryPatternRecall() {
final active = state.activePatternRecall;
if (active == null) return;
final now = DateTime.now().toUtc();
state = state.copyWith(
activePatternRecall: active.copyWith(
phase: PatternRecallPhase.briefing,
currentPreviewStep: -1,
input: const [],
mistakes: 0,
clearCompletedAt: true,
retries: active.retries + 1,
startedAt: now,
lifecycle: [
...active.lifecycle,
PuzzleLifecycleEvent(name: 'puzzle_retry', timestamp: now, payload: {'retry': active.retries + 1}),
],
),
);
}

void abandonPatternRecall() {
final active = state.activePatternRecall;
if (active == null) return;
final now = DateTime.now().toUtc();
state = state.copyWith(
activePatternRecall: active.copyWith(
phase: PatternRecallPhase.abandoned,
completedAt: now,
lifecycle: [
...active.lifecycle,
PuzzleLifecycleEvent(name: 'puzzle_abandoned', timestamp: now, payload: {'node_id': active.instance.seedInput.nodeId}),
],
),
puzzleEvents: [...state.puzzleEvents, {'name': 'puzzle_abandoned', 'node_id': active.instance.seedInput.nodeId}],
);
}

void closePatternRecall() {
state = state.copyWith(clearActivePatternRecall: true);
}

PerbugNode _mapPinToNode(MapPin pin) {
return PerbugNode(
id: pin.canonicalPlaceId,
Expand Down
11 changes: 11 additions & 0 deletions app/lib/features/home/perbug_game_models.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:math' as math;

import 'map_discovery_models.dart';
import 'puzzles/pattern_recall/pattern_recall_models.dart';

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

Expand Down Expand Up @@ -66,6 +67,8 @@ class PerbugGameState {
required this.loading,
required this.visitedNodeIds,
required this.history,
this.puzzleEvents = const [],
this.activePatternRecall,
this.error,
});

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

final List<PerbugNode> nodes;
Expand All @@ -88,6 +92,8 @@ class PerbugGameState {
final bool loading;
final Set<String> visitedNodeIds;
final List<String> history;
final PatternRecallSession? activePatternRecall;
final List<Map<String, Object>> puzzleEvents;
final String? error;

PerbugNode? get currentNode {
Expand All @@ -109,6 +115,9 @@ class PerbugGameState {
bool? loading,
Set<String>? visitedNodeIds,
List<String>? history,
PatternRecallSession? activePatternRecall,
bool clearActivePatternRecall = false,
List<Map<String, Object>>? puzzleEvents,
String? error,
bool clearError = false,
}) {
Expand All @@ -121,6 +130,8 @@ class PerbugGameState {
loading: loading ?? this.loading,
visitedNodeIds: visitedNodeIds ?? this.visitedNodeIds,
history: history ?? this.history,
activePatternRecall: clearActivePatternRecall ? null : (activePatternRecall ?? this.activePatternRecall),
puzzleEvents: puzzleEvents ?? this.puzzleEvents,
error: clearError ? null : (error ?? this.error),
);
}
Expand Down
40 changes: 39 additions & 1 deletion app/lib/features/home/perbug_game_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/theme/widgets.dart';
import 'perbug_game_controller.dart';
import 'perbug_game_models.dart';
import 'puzzles/pattern_recall/pattern_recall_puzzle_sheet.dart';

class PerbugGamePage extends ConsumerStatefulWidget {
const PerbugGamePage({super.key});
Expand Down Expand Up @@ -114,7 +115,7 @@ class _PerbugGamePageState extends ConsumerState<PerbugGamePage> {
),
_Section(
title: 'Upcoming node challenge slots',
subtitle: 'Puzzle systems are not enabled yet, but node states and rewards are puzzle-ready.',
subtitle: 'Launch deterministic node puzzles tied to latitude/longitude seed.',
children: [
Wrap(
spacing: 8,
Expand All @@ -126,6 +127,43 @@ class _PerbugGamePageState extends ConsumerState<PerbugGamePage> {
AppPill(label: 'future-challenge-ready', icon: Icons.extension_outlined),
],
),
const SizedBox(height: 8),
Row(
children: [
PrimaryButton(
label: 'Launch Pattern Recall',
onPressed: state.currentNode == null
? null
: () {
final session = controller.launchPatternRecallForCurrentNode();
if (session == null) return;
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: const Color(0xFF111827),
builder: (context) => Consumer(
builder: (context, ref, _) {
final latest = ref.watch(perbugGameControllerProvider);
return PatternRecallPuzzleSheet(
controller: ref.read(perbugGameControllerProvider.notifier),
state: latest,
);
},
),
);
},
),
const SizedBox(width: 8),
Expanded(
child: Text(
state.activePatternRecall == null
? 'Difficulty is generated before preview. Rewards/energy hooks are active.'
: 'Active difficulty: ${state.activePatternRecall!.instance.difficulty.tier} '
'(${state.activePatternRecall!.instance.difficulty.score.toStringAsFixed(0)})',
),
),
],
),
],
),
],
Expand Down
Loading