From b5835f755a3b0fb8d641897fabe512d0e0b3e34f Mon Sep 17 00:00:00 2001 From: ercmine Date: Fri, 27 Mar 2026 19:02:51 -0500 Subject: [PATCH] Resolve puzzle model conflicts and remove dead abstractions --- .../features/home/perbug_game_controller.dart | 215 ++++++++++++++++++ app/lib/features/home/perbug_game_models.dart | 11 + app/lib/features/home/perbug_game_page.dart | 40 +++- .../pattern_recall_generator.dart | 134 +++++++++++ .../pattern_recall/pattern_recall_models.dart | 122 ++++++++++ .../pattern_recall_puzzle_sheet.dart | 166 ++++++++++++++ .../pattern_recall_validator.dart | 37 +++ .../home/puzzles/perbug_puzzle_framework.dart | 123 ++++++++++ app/test/perbug_pattern_recall_test.dart | 153 +++++++++++++ 9 files changed, 1000 insertions(+), 1 deletion(-) create mode 100644 app/lib/features/home/puzzles/pattern_recall/pattern_recall_generator.dart create mode 100644 app/lib/features/home/puzzles/pattern_recall/pattern_recall_models.dart create mode 100644 app/lib/features/home/puzzles/pattern_recall/pattern_recall_puzzle_sheet.dart create mode 100644 app/lib/features/home/puzzles/pattern_recall/pattern_recall_validator.dart create mode 100644 app/lib/features/home/puzzles/perbug_puzzle_framework.dart create mode 100644 app/test/perbug_pattern_recall_test.dart diff --git a/app/lib/features/home/perbug_game_controller.dart b/app/lib/features/home/perbug_game_controller.dart index 61751472..a1bed3cf 100644 --- a/app/lib/features/home/perbug_game_controller.dart +++ b/app/lib/features/home/perbug_game_controller.dart @@ -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((ref) { return PerbugGameController(ref); @@ -14,6 +18,8 @@ class PerbugGameController extends StateNotifier { 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); @@ -84,6 +90,215 @@ class PerbugGameController extends StateNotifier { ); } + PatternRecallSession? launchPatternRecallForCurrentNode({Map 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, diff --git a/app/lib/features/home/perbug_game_models.dart b/app/lib/features/home/perbug_game_models.dart index 696c6f46..8a3fe278 100644 --- a/app/lib/features/home/perbug_game_models.dart +++ b/app/lib/features/home/perbug_game_models.dart @@ -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 } @@ -66,6 +67,8 @@ class PerbugGameState { required this.loading, required this.visitedNodeIds, required this.history, + this.puzzleEvents = const [], + this.activePatternRecall, this.error, }); @@ -78,6 +81,7 @@ class PerbugGameState { loading: false, visitedNodeIds: {}, history: [], + puzzleEvents: [], ); final List nodes; @@ -88,6 +92,8 @@ class PerbugGameState { final bool loading; final Set visitedNodeIds; final List history; + final PatternRecallSession? activePatternRecall; + final List> puzzleEvents; final String? error; PerbugNode? get currentNode { @@ -109,6 +115,9 @@ class PerbugGameState { bool? loading, Set? visitedNodeIds, List? history, + PatternRecallSession? activePatternRecall, + bool clearActivePatternRecall = false, + List>? puzzleEvents, String? error, bool clearError = false, }) { @@ -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), ); } diff --git a/app/lib/features/home/perbug_game_page.dart b/app/lib/features/home/perbug_game_page.dart index caa6b754..c9475f32 100644 --- a/app/lib/features/home/perbug_game_page.dart +++ b/app/lib/features/home/perbug_game_page.dart @@ -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}); @@ -114,7 +115,7 @@ class _PerbugGamePageState extends ConsumerState { ), _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, @@ -126,6 +127,43 @@ class _PerbugGamePageState extends ConsumerState { 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( + 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)})', + ), + ), + ], + ), ], ), ], diff --git a/app/lib/features/home/puzzles/pattern_recall/pattern_recall_generator.dart b/app/lib/features/home/puzzles/pattern_recall/pattern_recall_generator.dart new file mode 100644 index 00000000..8976ea5b --- /dev/null +++ b/app/lib/features/home/puzzles/pattern_recall/pattern_recall_generator.dart @@ -0,0 +1,134 @@ +import 'dart:math' as math; + +import '../perbug_puzzle_framework.dart'; +import 'pattern_recall_models.dart'; + +class PatternRecallGenerator implements PuzzleGenerator { + const PatternRecallGenerator(); + + static const _symbols = ['◆', '●', '▲', '■', '✦', '⬟', '✚', '⬢', '★', '⬣']; + + @override + PatternRecallInstance generate({ + required PuzzleNodeContext node, + required PuzzleSeedInput seedInput, + required Map tuning, + }) { + final rng = seededRandom(seedInput); + final knobs = _knobsFor(node: node, tuning: tuning, rng: rng); + final symbolSet = _symbols.take(knobs.symbolVariety.clamp(3, _symbols.length)).toList(growable: false); + final sequence = _buildSequence(rng: rng, length: knobs.sequenceLength, symbolVariety: symbolSet.length); + + final isMirrored = rng.nextDouble() < knobs.mirroredChance; + final isReversed = isMirrored && rng.nextBool(); + final expected = isReversed ? sequence.reversed.toList(growable: false) : sequence; + + final distractions = List.generate(knobs.distractionCount, (index) { + final step = rng.nextInt(sequence.length); + var decoy = rng.nextInt(symbolSet.length); + if (decoy == sequence[step]) { + decoy = (decoy + 1) % symbolSet.length; + } + return PatternRecallDistraction(step: step, symbolIndex: decoy); + }, growable: false); + + final difficulty = _computeDifficulty(knobs: knobs, mirrored: isMirrored, reversed: isReversed); + + final previewStepMs = math.max(300, (knobs.previewDurationMs / knobs.sequenceLength).floor()); + return PatternRecallInstance( + seedInput: seedInput, + difficulty: difficulty, + knobs: knobs, + symbolSet: symbolSet, + generatedSequence: sequence, + expectedAnswer: expected, + previewStepDuration: Duration(milliseconds: previewStepMs), + isMirrored: isMirrored, + isReversed: isReversed, + distractions: distractions, + debug: { + 'seed_key': seedInput.stableSeedKey(), + 'sequence': sequence.join(','), + 'expected_answer': expected.join(','), + 'distraction_steps': distractions.map((d) => '${d.step}:${d.symbolIndex}').join('|'), + }, + ); + } + + PatternRecallDifficultyKnobs _knobsFor({ + required PuzzleNodeContext node, + required Map tuning, + required math.Random rng, + }) { + final progressionStage = (tuning['progressionStage'] as num?)?.toDouble() ?? 0.0; + final rarityBoost = (tuning['rarityBoost'] as num?)?.toDouble() ?? 0.0; + final regionBoost = (node.region.toLowerCase().contains('downtown') ? 0.25 : 0.0); + final combined = progressionStage + rarityBoost + regionBoost + (rng.nextDouble() * 0.2); + + final sequenceLength = (4 + (combined * 4).round()).clamp(4, 9); + final symbolVariety = (4 + (combined * 3).round()).clamp(4, 8); + final previewDurationMs = (4500 - (combined * 1400).round()).clamp(1800, 4800); + final distractionCount = (combined * 3).round().clamp(0, 5); + final mirroredChance = (0.08 + combined * 0.22).clamp(0.05, 0.65); + final errorTolerance = combined < 0.6 ? 1 : 0; + + return PatternRecallDifficultyKnobs( + sequenceLength: sequenceLength, + symbolVariety: symbolVariety, + previewDurationMs: previewDurationMs, + distractionCount: distractionCount, + mirroredChance: mirroredChance, + errorTolerance: errorTolerance, + ); + } + + List _buildSequence({ + required math.Random rng, + required int length, + required int symbolVariety, + }) { + final output = []; + while (output.length < length) { + final next = rng.nextInt(symbolVariety); + final canRepeat = output.length < 2; + if (!canRepeat && output[output.length - 1] == next && output[output.length - 2] == next) { + continue; + } + output.add(next); + } + return output; + } + + PuzzleDifficulty _computeDifficulty({ + required PatternRecallDifficultyKnobs knobs, + required bool mirrored, + required bool reversed, + }) { + final sequenceWeight = (knobs.sequenceLength - 4) / 5; + final varietyWeight = (knobs.symbolVariety - 4) / 4; + final previewWeight = (4800 - knobs.previewDurationMs) / 3000; + final distractionWeight = knobs.distractionCount / 5; + final mirrorWeight = mirrored ? (reversed ? 1.0 : 0.75) : 0.0; + final toleranceWeight = knobs.errorTolerance == 0 ? 1.0 : 0.35; + + final weighted = { + 'sequence_length': sequenceWeight * 0.28, + 'symbol_variety': varietyWeight * 0.18, + 'preview_duration': previewWeight * 0.2, + 'distractions': distractionWeight * 0.14, + 'mirrored_reversed': mirrorWeight * 0.12, + 'tolerance': toleranceWeight * 0.08, + }; + + final score = (weighted.values.fold(0, (sum, e) => sum + e) * 100).clamp(0, 100); + final tier = score < 35 + ? 'Easy' + : score < 60 + ? 'Medium' + : score < 80 + ? 'Hard' + : 'Extreme'; + + return PuzzleDifficulty(score: score.toDouble(), tier: tier, explainer: weighted); + } +} diff --git a/app/lib/features/home/puzzles/pattern_recall/pattern_recall_models.dart b/app/lib/features/home/puzzles/pattern_recall/pattern_recall_models.dart new file mode 100644 index 00000000..dc964058 --- /dev/null +++ b/app/lib/features/home/puzzles/pattern_recall/pattern_recall_models.dart @@ -0,0 +1,122 @@ +import '../perbug_puzzle_framework.dart'; + +enum PatternRecallPhase { briefing, preview, recall, success, failure, abandoned } + +class PatternRecallDifficultyKnobs { + const PatternRecallDifficultyKnobs({ + required this.sequenceLength, + required this.symbolVariety, + required this.previewDurationMs, + required this.distractionCount, + required this.mirroredChance, + required this.errorTolerance, + }); + + final int sequenceLength; + final int symbolVariety; + final int previewDurationMs; + final int distractionCount; + final double mirroredChance; + final int errorTolerance; +} + +class PatternRecallDistraction { + const PatternRecallDistraction({ + required this.step, + required this.symbolIndex, + }); + + final int step; + final int symbolIndex; +} + +class PatternRecallInstance extends PuzzleInstance { + PatternRecallInstance({ + required this.seedInput, + required this.difficulty, + required this.knobs, + required this.symbolSet, + required this.generatedSequence, + required this.expectedAnswer, + required this.previewStepDuration, + required this.isMirrored, + required this.isReversed, + required this.distractions, + required this.debug, + }); + + @override + final PuzzleSeedInput seedInput; + + @override + final PuzzleDifficulty difficulty; + + final PatternRecallDifficultyKnobs knobs; + final List symbolSet; + final List generatedSequence; + final List expectedAnswer; + final Duration previewStepDuration; + final bool isMirrored; + final bool isReversed; + final List distractions; + final Map debug; + + @override + PuzzleType get type => PuzzleType.patternRecall; + + @override + Map debugMetadata() => debug; +} + +class PatternRecallSession { + const PatternRecallSession({ + required this.instance, + required this.phase, + required this.currentPreviewStep, + required this.input, + required this.startedAt, + required this.retries, + required this.mistakes, + required this.lifecycle, + this.completedAt, + }); + + final PatternRecallInstance instance; + final PatternRecallPhase phase; + final int currentPreviewStep; + final List input; + final DateTime startedAt; + final DateTime? completedAt; + final int retries; + final int mistakes; + final List lifecycle; + + PatternRecallSession copyWith({ + PatternRecallPhase? phase, + int? currentPreviewStep, + List? input, + DateTime? startedAt, + DateTime? completedAt, + bool clearCompletedAt = false, + int? retries, + int? mistakes, + List? lifecycle, + }) { + return PatternRecallSession( + instance: instance, + phase: phase ?? this.phase, + currentPreviewStep: currentPreviewStep ?? this.currentPreviewStep, + input: input ?? this.input, + startedAt: startedAt ?? this.startedAt, + completedAt: clearCompletedAt ? null : (completedAt ?? this.completedAt), + retries: retries ?? this.retries, + mistakes: mistakes ?? this.mistakes, + lifecycle: lifecycle ?? this.lifecycle, + ); + } +} + +extension PatternRecallPhaseX on PatternRecallPhase { + bool get isTerminal => + this == PatternRecallPhase.success || this == PatternRecallPhase.failure || this == PatternRecallPhase.abandoned; +} diff --git a/app/lib/features/home/puzzles/pattern_recall/pattern_recall_puzzle_sheet.dart b/app/lib/features/home/puzzles/pattern_recall/pattern_recall_puzzle_sheet.dart new file mode 100644 index 00000000..10749668 --- /dev/null +++ b/app/lib/features/home/puzzles/pattern_recall/pattern_recall_puzzle_sheet.dart @@ -0,0 +1,166 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../perbug_game_controller.dart'; +import '../../perbug_game_models.dart'; +import 'pattern_recall_models.dart'; + +class PatternRecallPuzzleSheet extends StatefulWidget { + const PatternRecallPuzzleSheet({ + super.key, + required this.controller, + required this.state, + }); + + final PerbugGameController controller; + final PerbugGameState state; + + @override + State createState() => _PatternRecallPuzzleSheetState(); +} + +class _PatternRecallPuzzleSheetState extends State { + Timer? _previewTimer; + + @override + void dispose() { + _previewTimer?.cancel(); + super.dispose(); + } + + void _runPreview(PatternRecallSession session) { + _previewTimer?.cancel(); + widget.controller.startPatternPreview(); + if (session.instance.generatedSequence.isEmpty) { + widget.controller.completePatternPreview(); + return; + } + widget.controller.setPatternPreviewStep(0); + var step = 1; + final total = session.instance.generatedSequence.length; + _previewTimer = Timer.periodic(session.instance.previewStepDuration, (timer) { + widget.controller.setPatternPreviewStep(step); + step += 1; + if (step >= total) { + timer.cancel(); + widget.controller.completePatternPreview(); + } + }); + } + + @override + Widget build(BuildContext context) { + final session = widget.state.activePatternRecall; + if (session == null) { + return const SizedBox.shrink(); + } + final instance = session.instance; + final activeSymbol = session.currentPreviewStep >= 0 && session.currentPreviewStep < instance.generatedSequence.length + ? instance.generatedSequence[session.currentPreviewStep] + : null; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded( + child: Text('Perbug Pattern Recall', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + ), + IconButton( + onPressed: () { + widget.controller.abandonPatternRecall(); + widget.controller.closePatternRecall(); + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Difficulty ${instance.difficulty.tier} (${instance.difficulty.score.toStringAsFixed(0)}) · ' + 'Length ${instance.knobs.sequenceLength} · Symbols ${instance.knobs.symbolVariety} · ' + 'Tolerance ${instance.knobs.errorTolerance}', + ), + if (instance.isMirrored) + Text('Rule: Recall the ${instance.isReversed ? 'reversed' : 'mirrored'} pattern as previewed.'), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: List.generate(instance.symbolSet.length, (index) { + final symbol = instance.symbolSet[index]; + final isPreview = activeSymbol == index; + final isDistraction = session.phase == PatternRecallPhase.preview && + instance.distractions.any((d) => d.step == session.currentPreviewStep && d.symbolIndex == index); + final selectedCount = session.input.where((i) => i == index).length; + return GestureDetector( + onTap: session.phase == PatternRecallPhase.recall ? () => widget.controller.inputPatternSymbol(index) : null, + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + width: 56, + height: 56, + alignment: Alignment.center, + decoration: BoxDecoration( + color: isPreview + ? Colors.lightBlueAccent + : isDistraction + ? Colors.deepOrangeAccent + : Colors.blueGrey.shade900, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white24), + ), + child: Text('$symbol${selectedCount > 0 ? ' $selectedCount' : ''}', style: const TextStyle(fontSize: 22)), + ), + ); + }), + ), + const SizedBox(height: 12), + if (session.phase == PatternRecallPhase.briefing) + Row( + children: [ + ElevatedButton(onPressed: () => _runPreview(session), child: const Text('Start preview')), + const SizedBox(width: 8), + Text('Preview ${instance.knobs.previewDurationMs}ms total'), + ], + ), + if (session.phase == PatternRecallPhase.preview) + Text('Previewing step ${session.currentPreviewStep + 1} / ${instance.generatedSequence.length}'), + if (session.phase == PatternRecallPhase.recall) + Row( + children: [ + Text('Recreate sequence: ${session.input.length}/${instance.expectedAnswer.length}'), + const SizedBox(width: 12), + TextButton(onPressed: widget.controller.clearPatternInput, child: const Text('Reset input')), + ], + ), + if (session.phase == PatternRecallPhase.success) + const Text('Success! Node memory stabilized.', style: TextStyle(color: Colors.greenAccent)), + if (session.phase == PatternRecallPhase.failure) + Text('Failed. Mistakes ${session.mistakes} (tolerance ${instance.knobs.errorTolerance}).', style: const TextStyle(color: Colors.orangeAccent)), + if (session.phase == PatternRecallPhase.success || session.phase == PatternRecallPhase.failure) + Row( + children: [ + ElevatedButton(onPressed: widget.controller.retryPatternRecall, child: const Text('Retry')), + const SizedBox(width: 8), + OutlinedButton( + onPressed: () { + widget.controller.closePatternRecall(); + Navigator.of(context).pop(); + }, + child: const Text('Close'), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/home/puzzles/pattern_recall/pattern_recall_validator.dart b/app/lib/features/home/puzzles/pattern_recall/pattern_recall_validator.dart new file mode 100644 index 00000000..9cbf2ab8 --- /dev/null +++ b/app/lib/features/home/puzzles/pattern_recall/pattern_recall_validator.dart @@ -0,0 +1,37 @@ +import '../perbug_puzzle_framework.dart'; +import 'pattern_recall_models.dart'; + +class PatternRecallValidator implements PuzzleValidator { + const PatternRecallValidator(); + + @override + PuzzleResult validate({ + required PatternRecallInstance instance, + required List input, + required Duration elapsed, + }) { + final expected = instance.expectedAnswer; + var mistakes = 0; + for (var i = 0; i < expected.length && i < input.length; i += 1) { + if (expected[i] != input[i]) mistakes += 1; + } + + if (input.length < expected.length) { + mistakes += expected.length - input.length; + } + + final success = mistakes <= instance.knobs.errorTolerance && input.length >= expected.length; + return PuzzleResult( + success: success, + mistakes: mistakes, + elapsed: elapsed, + analytics: { + 'expected_length': expected.length, + 'input_length': input.length, + 'tolerance': instance.knobs.errorTolerance, + 'mirrored': instance.isMirrored, + 'reversed': instance.isReversed, + }, + ); + } +} diff --git a/app/lib/features/home/puzzles/perbug_puzzle_framework.dart b/app/lib/features/home/puzzles/perbug_puzzle_framework.dart new file mode 100644 index 00000000..f9ba89eb --- /dev/null +++ b/app/lib/features/home/puzzles/perbug_puzzle_framework.dart @@ -0,0 +1,123 @@ +import 'dart:math' as math; + +enum PuzzleType { gridPath, patternRecall } + +class PuzzleSeedInput { + const PuzzleSeedInput({ + required this.nodeId, + required this.latitude, + required this.longitude, + this.salt = '', + this.modifier = 0, + }); + + final String nodeId; + final double latitude; + final double longitude; + final String salt; + final int modifier; + + String stableSeedKey() { + final lat = latitude.toStringAsFixed(6); + final lng = longitude.toStringAsFixed(6); + return '$nodeId|$lat|$lng|$salt|$modifier'; + } +} + +class PuzzleDifficulty { + const PuzzleDifficulty({ + required this.score, + required this.tier, + required this.explainer, + }); + + final double score; + final String tier; + final Map explainer; +} + +class PuzzleResult { + const PuzzleResult({ + required this.success, + required this.mistakes, + required this.elapsed, + required this.analytics, + }); + + final bool success; + final int mistakes; + final Duration elapsed; + final Map analytics; +} + +class PuzzleLifecycleEvent { + const PuzzleLifecycleEvent({ + required this.name, + required this.timestamp, + required this.payload, + }); + + final String name; + final DateTime timestamp; + final Map payload; +} + +abstract class PuzzleInstance { + PuzzleType get type; + + PuzzleDifficulty get difficulty; + + PuzzleSeedInput get seedInput; + + Map debugMetadata(); +} + +abstract class PuzzleGenerator { + T generate({ + required PuzzleNodeContext node, + required PuzzleSeedInput seedInput, + required Map tuning, + }); +} + +class PuzzleNodeContext { + const PuzzleNodeContext({ + required this.nodeId, + required this.latitude, + required this.longitude, + required this.region, + }); + + final String nodeId; + final double latitude; + final double longitude; + final String region; +} + +abstract class PuzzleValidator { + PuzzleResult validate({ + required T instance, + required List input, + required Duration elapsed, + }); +} + +/// Deterministic RNG using FNV-1a hash over node-derived seed data. +/// +/// This ensures the same node lat/lng and salt/modifier produce the same +/// pseudo-random sequence across launches and sessions. +math.Random seededRandom(PuzzleSeedInput input) { + final hash = _fnv1a32(input.stableSeedKey()); + return math.Random(hash); +} + +int _fnv1a32(String value) { + const offsetBasis = 0x811c9dc5; + const fnvPrime = 0x01000193; + var hash = offsetBasis; + for (final codeUnit in value.codeUnits) { + hash ^= codeUnit; + hash = (hash * fnvPrime) & 0xffffffff; + } + return hash & 0x7fffffff; +} diff --git a/app/test/perbug_pattern_recall_test.dart b/app/test/perbug_pattern_recall_test.dart new file mode 100644 index 00000000..7642431d --- /dev/null +++ b/app/test/perbug_pattern_recall_test.dart @@ -0,0 +1,153 @@ +import 'package:dryad/features/home/perbug_game_controller.dart'; +import 'package:dryad/features/home/perbug_game_models.dart'; +import 'package:dryad/features/home/puzzles/pattern_recall/pattern_recall_generator.dart'; +import 'package:dryad/features/home/puzzles/pattern_recall/pattern_recall_models.dart'; +import 'package:dryad/features/home/puzzles/pattern_recall/pattern_recall_validator.dart'; +import 'package:dryad/features/home/puzzles/perbug_puzzle_framework.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const nodeA = PerbugNode( + id: 'node-a', + label: 'Node A', + latitude: 30.2672, + longitude: -97.7431, + region: 'Downtown', + state: PerbugNodeState.available, + energyReward: 2, + ); + const nodeB = PerbugNode( + id: 'node-b', + label: 'Node B', + latitude: 30.271, + longitude: -97.75, + region: 'North', + state: PerbugNodeState.available, + energyReward: 2, + ); + + test('pattern generation is deterministic for same node lat/lng seed', () { + const generator = PatternRecallGenerator(); + const seed = PuzzleSeedInput(nodeId: 'node-a', latitude: 30.2672, longitude: -97.7431); + + const context = PuzzleNodeContext(nodeId: 'node-a', latitude: 30.2672, longitude: -97.7431, region: 'Downtown'); + final first = generator.generate(node: context, seedInput: seed, tuning: const {}); + final second = generator.generate(node: context, seedInput: seed, tuning: const {}); + + expect(first.generatedSequence, second.generatedSequence); + expect(first.expectedAnswer, second.expectedAnswer); + expect(first.distractions.map((d) => '${d.step}:${d.symbolIndex}').toList(), + second.distractions.map((d) => '${d.step}:${d.symbolIndex}').toList()); + }); + + test('different nodes generate different sequence patterns', () { + const generator = PatternRecallGenerator(); + const seedA = PuzzleSeedInput(nodeId: 'node-a', latitude: 30.2672, longitude: -97.7431); + const seedB = PuzzleSeedInput(nodeId: 'node-b', latitude: 30.271, longitude: -97.75); + + const contextA = PuzzleNodeContext(nodeId: 'node-a', latitude: 30.2672, longitude: -97.7431, region: 'Downtown'); + const contextB = PuzzleNodeContext(nodeId: 'node-b', latitude: 30.271, longitude: -97.75, region: 'North'); + final first = generator.generate(node: contextA, seedInput: seedA, tuning: const {}); + final second = generator.generate(node: contextB, seedInput: seedB, tuning: const {}); + + expect(first.generatedSequence.join(','), isNot(second.generatedSequence.join(','))); + }); + + test('difficulty responds to tuning knobs/progression boosts', () { + const generator = PatternRecallGenerator(); + const seed = PuzzleSeedInput(nodeId: 'node-a', latitude: 30.2672, longitude: -97.7431); + + const context = PuzzleNodeContext(nodeId: 'node-a', latitude: 30.2672, longitude: -97.7431, region: 'Downtown'); + final easy = generator.generate(node: context, seedInput: seed, tuning: const {'progressionStage': 0.0, 'rarityBoost': 0.0}); + final hard = generator.generate(node: context, seedInput: seed, tuning: const {'progressionStage': 1.6, 'rarityBoost': 0.8}); + + expect(hard.difficulty.score, greaterThan(easy.difficulty.score)); + expect(hard.knobs.sequenceLength, greaterThanOrEqualTo(easy.knobs.sequenceLength)); + expect(hard.knobs.previewDurationMs, lessThanOrEqualTo(easy.knobs.previewDurationMs)); + }); + + test('validator respects tolerance mode', () { + const validator = PatternRecallValidator(); + final instance = PatternRecallInstance( + seedInput: const PuzzleSeedInput(nodeId: 'n', latitude: 1, longitude: 1), + difficulty: const PuzzleDifficulty(score: 10, tier: 'Easy', explainer: {}), + knobs: const PatternRecallDifficultyKnobs( + sequenceLength: 4, + symbolVariety: 4, + previewDurationMs: 3000, + distractionCount: 0, + mirroredChance: 0.0, + errorTolerance: 1, + ), + symbolSet: const ['A', 'B', 'C', 'D'], + generatedSequence: const [0, 1, 2, 3], + expectedAnswer: const [0, 1, 2, 3], + previewStepDuration: const Duration(milliseconds: 800), + isMirrored: false, + isReversed: false, + distractions: const [], + debug: const {}, + ); + + final pass = validator.validate(instance: instance, input: const [0, 1, 3, 3], elapsed: const Duration(seconds: 3)); + final fail = validator.validate(instance: instance, input: const [2, 1, 3, 0], elapsed: const Duration(seconds: 3)); + + expect(pass.success, isTrue); + expect(fail.success, isFalse); + }); + + test('controller blocks input before preview completion and records lifecycle', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(perbugGameControllerProvider.notifier); + notifier.state = const PerbugGameState( + nodes: [nodeA], + currentNodeId: 'node-a', + energy: 10, + maxEnergy: 20, + maxJumpMeters: 2000, + loading: false, + visitedNodeIds: {'node-a'}, + history: [], + puzzleEvents: [], + ); + + notifier.launchPatternRecallForCurrentNode(); + final before = notifier.state.activePatternRecall!; + notifier.inputPatternSymbol(0); + expect(notifier.state.activePatternRecall!.input, before.input); + + notifier.startPatternPreview(); + notifier.completePatternPreview(); + final expected = notifier.state.activePatternRecall!.instance.expectedAnswer; + for (final value in expected) { + notifier.inputPatternSymbol(value); + } + + expect(notifier.state.activePatternRecall!.phase, PatternRecallPhase.success); + expect(notifier.state.puzzleEvents.where((e) => e['name'] == 'preview_completed').isNotEmpty, isTrue); + expect(notifier.state.puzzleEvents.where((e) => e['name'] == 'puzzle_succeeded').isNotEmpty, isTrue); + }); + + test('controller reuses active in-progress pattern session', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(perbugGameControllerProvider.notifier); + notifier.state = const PerbugGameState( + nodes: [nodeA], + currentNodeId: 'node-a', + energy: 10, + maxEnergy: 20, + maxJumpMeters: 2000, + loading: false, + visitedNodeIds: {'node-a'}, + history: [], + puzzleEvents: [], + ); + + final first = notifier.launchPatternRecallForCurrentNode(); + final second = notifier.launchPatternRecallForCurrentNode(); + expect(identical(first, second), isTrue); + }); +}