From 5642669a2f15bca1987579d094dfd65059e9fe7d Mon Sep 17 00:00:00 2001 From: ercmine Date: Fri, 27 Mar 2026 18:51:43 -0500 Subject: [PATCH] Add Perbug Logic Locks puzzle framework and node integration --- .../features/home/perbug_game_controller.dart | 155 ++++++++ app/lib/features/home/perbug_game_models.dart | 33 ++ app/lib/features/home/perbug_game_page.dart | 168 +++++++- .../home/puzzles/logic_locks_puzzle.dart | 364 ++++++++++++++++++ .../home/puzzles/puzzle_framework.dart | 162 ++++++++ app/test/logic_locks_puzzle_test.dart | 79 ++++ 6 files changed, 947 insertions(+), 14 deletions(-) create mode 100644 app/lib/features/home/puzzles/logic_locks_puzzle.dart create mode 100644 app/lib/features/home/puzzles/puzzle_framework.dart create mode 100644 app/test/logic_locks_puzzle_test.dart diff --git a/app/lib/features/home/perbug_game_controller.dart b/app/lib/features/home/perbug_game_controller.dart index 61751472..4139224a 100644 --- a/app/lib/features/home/perbug_game_controller.dart +++ b/app/lib/features/home/perbug_game_controller.dart @@ -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/logic_locks_puzzle.dart'; +import 'puzzles/puzzle_framework.dart'; final perbugGameControllerProvider = StateNotifierProvider((ref) { return PerbugGameController(ref); @@ -14,6 +16,8 @@ class PerbugGameController extends StateNotifier { PerbugGameController(this._ref) : super(PerbugGameState.initial()); final Ref _ref; + final _logicLocksGenerator = LogicLocksGenerator(); + final _logicLocksValidator = LogicLocksValidator(); static const MapViewport _fixedGameplayViewport = MapViewport(centerLat: 30.2672, centerLng: -97.7431, zoom: 13); @@ -73,6 +77,7 @@ class PerbugGameController extends StateNotifier { 'Jumped ${_formatDistance(move.node.distanceFromCurrentMeters ?? 0)} to ${move.node.label} (-$spend, +$gained energy)', ...state.history, ], + clearPuzzleSession: true, ); return true; } @@ -84,6 +89,156 @@ class PerbugGameController extends StateNotifier { ); } + void generateLogicLocksForCurrentNode({LogicLocksDifficultyConfig config = const LogicLocksDifficultyConfig()}) { + final node = state.currentNode; + if (node == null) return; + + final input = PuzzleSeedInput( + nodeId: node.id, + latitude: node.latitude, + longitude: node.longitude, + difficultySalt: '${config.variableCount}:${config.clueCount}:${config.gridSize}', + ); + final instance = _logicLocksGenerator.generate(seedInput: input, config: config); + final session = PuzzleSession( + instance: instance, + status: PuzzleSessionStatus.generated, + playerState: LogicLocksPlayerState.empty(instance.data.solution.length), + attempts: 0, + createdAt: DateTime.now().toUtc(), + analyticsEvents: [_event('puzzle_generated', node, instance)], + ); + state = state.copyWith( + activePuzzleSession: PerbugPuzzleSessionData(nodeId: node.id, logicLocksSession: session), + puzzleHistory: [...state.puzzleHistory, ...session.analyticsEvents], + ); + } + + void startActivePuzzle() { + final container = state.activePuzzleSession; + final node = state.currentNode; + if (container == null || node == null) return; + final started = container.logicLocksSession.copyWith( + status: PuzzleSessionStatus.started, + startedAt: DateTime.now().toUtc(), + analyticsEvents: [...container.logicLocksSession.analyticsEvents, _event('puzzle_started', node, container.logicLocksSession.instance)], + ); + state = state.copyWith( + activePuzzleSession: container.copyWith(logicLocksSession: started), + puzzleHistory: [...state.puzzleHistory, started.analyticsEvents.last], + ); + } + + void logCluePanelViewed() { + final container = state.activePuzzleSession; + final node = state.currentNode; + if (container == null || node == null) return; + final updated = container.logicLocksSession.copyWith( + analyticsEvents: [...container.logicLocksSession.analyticsEvents, _event('clue_panel_entered', node, container.logicLocksSession.instance)], + ); + state = state.copyWith(activePuzzleSession: container.copyWith(logicLocksSession: updated), puzzleHistory: [...state.puzzleHistory, updated.analyticsEvents.last]); + } + + void assignPuzzleSlot(int slot, String? entity) { + final container = state.activePuzzleSession; + if (container == null) return; + final nextState = container.logicLocksSession.playerState.assign(slot, entity); + final updated = container.logicLocksSession.copyWith(playerState: nextState); + state = state.copyWith(activePuzzleSession: container.copyWith(logicLocksSession: updated)); + } + + void undoPuzzleMove() { + final container = state.activePuzzleSession; + if (container == null) return; + final updated = container.logicLocksSession.copyWith(playerState: container.logicLocksSession.playerState.undo()); + state = state.copyWith(activePuzzleSession: container.copyWith(logicLocksSession: updated)); + } + + void resetPuzzle() { + final container = state.activePuzzleSession; + if (container == null) return; + final updated = container.logicLocksSession.copyWith(playerState: container.logicLocksSession.playerState.reset()); + state = state.copyWith(activePuzzleSession: container.copyWith(logicLocksSession: updated)); + } + + PuzzleResult submitPuzzleAttempt() { + final container = state.activePuzzleSession; + final node = state.currentNode; + if (container == null || node == null) { + return const PuzzleResult(success: false, timeToSolve: Duration.zero, attempts: 0, reason: 'No active puzzle'); + } + + final session = container.logicLocksSession; + final startedAt = session.startedAt ?? session.createdAt; + final endedAt = DateTime.now().toUtc(); + final attempts = session.attempts + 1; + final result = _logicLocksValidator.validate( + instance: session.instance, + playerState: session.playerState, + attempts: attempts, + startedAt: startedAt, + endedAt: endedAt, + ); + + final status = result.success ? PuzzleSessionStatus.succeeded : PuzzleSessionStatus.failed; + final eventName = result.success ? 'puzzle_succeeded' : 'puzzle_failed'; + final updated = session.copyWith( + attempts: attempts, + status: status, + endedAt: endedAt, + analyticsEvents: [ + ...session.analyticsEvents, + _event(eventName, node, session.instance, extras: {'attempts': attempts, 'solve_ms': result.timeToSolve.inMilliseconds}), + ], + ); + + final energy = result.rewardEnergyHook ? (state.energy + 2).clamp(0, state.maxEnergy) : state.energy; + state = state.copyWith( + energy: energy, + activePuzzleSession: container.copyWith(logicLocksSession: updated), + puzzleHistory: [...state.puzzleHistory, updated.analyticsEvents.last], + history: [ + result.success ? 'Solved Logic Locks at ${node.label} (+2 energy hook)' : 'Logic Locks failed at ${node.label}', + ...state.history, + ], + ); + return result; + } + + void abandonActivePuzzle() { + final container = state.activePuzzleSession; + final node = state.currentNode; + if (container == null || node == null) return; + final updated = container.logicLocksSession.copyWith( + status: PuzzleSessionStatus.abandoned, + endedAt: DateTime.now().toUtc(), + analyticsEvents: [...container.logicLocksSession.analyticsEvents, _event('puzzle_abandoned', node, container.logicLocksSession.instance)], + ); + state = state.copyWith( + activePuzzleSession: container.copyWith(logicLocksSession: updated), + puzzleHistory: [...state.puzzleHistory, updated.analyticsEvents.last], + ); + } + + PuzzleAnalyticsEvent _event(String name, PerbugNode node, PuzzleInstance instance, {Map extras = const {}}) { + return PuzzleAnalyticsEvent( + name: name, + timestamp: DateTime.now().toUtc(), + payload: { + 'nodeId': node.id, + 'seed': instance.seed, + 'difficultyTier': instance.difficulty.tier, + 'difficultyScore': instance.difficulty.score, + 'variableCount': instance.data.entities.length, + 'clueCount': instance.data.clues.length, + 'contradictionComplexity': instance.data.contradictionComplexityEstimate, + 'ambiguityLevel': instance.data.ambiguityEstimate, + 'deductionDepth': instance.data.deductionDepthEstimate, + ...extras, + }, + ); + } + 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..618f6762 100644 --- a/app/lib/features/home/perbug_game_models.dart +++ b/app/lib/features/home/perbug_game_models.dart @@ -1,6 +1,8 @@ import 'dart:math' as math; import 'map_discovery_models.dart'; +import 'puzzles/logic_locks_puzzle.dart'; +import 'puzzles/puzzle_framework.dart'; enum PerbugNodeState { available, completed, locked, exhausted, special, futureChallengeReady } @@ -42,6 +44,28 @@ class PerbugNode { } } + + +class PerbugPuzzleSessionData { + const PerbugPuzzleSessionData({ + required this.nodeId, + required this.logicLocksSession, + }); + + final String nodeId; + final PuzzleSession logicLocksSession; + + PerbugPuzzleSessionData copyWith({ + String? nodeId, + PuzzleSession? logicLocksSession, + }) { + return PerbugPuzzleSessionData( + nodeId: nodeId ?? this.nodeId, + logicLocksSession: logicLocksSession ?? this.logicLocksSession, + ); + } +} + class PerbugMoveCandidate { const PerbugMoveCandidate({ required this.node, @@ -67,6 +91,8 @@ class PerbugGameState { required this.visitedNodeIds, required this.history, this.error, + this.activePuzzleSession, + this.puzzleHistory = const [], }); factory PerbugGameState.initial() => const PerbugGameState( @@ -89,6 +115,8 @@ class PerbugGameState { final Set visitedNodeIds; final List history; final String? error; + final PerbugPuzzleSessionData? activePuzzleSession; + final List puzzleHistory; PerbugNode? get currentNode { final id = currentNodeId; @@ -111,6 +139,9 @@ class PerbugGameState { List? history, String? error, bool clearError = false, + PerbugPuzzleSessionData? activePuzzleSession, + bool clearPuzzleSession = false, + List? puzzleHistory, }) { return PerbugGameState( nodes: nodes ?? this.nodes, @@ -122,6 +153,8 @@ class PerbugGameState { visitedNodeIds: visitedNodeIds ?? this.visitedNodeIds, history: history ?? this.history, error: clearError ? null : (error ?? this.error), + activePuzzleSession: clearPuzzleSession ? null : (activePuzzleSession ?? this.activePuzzleSession), + puzzleHistory: puzzleHistory ?? this.puzzleHistory, ); } diff --git a/app/lib/features/home/perbug_game_page.dart b/app/lib/features/home/perbug_game_page.dart index caa6b754..16141b02 100644 --- a/app/lib/features/home/perbug_game_page.dart +++ b/app/lib/features/home/perbug_game_page.dart @@ -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/logic_locks_puzzle.dart'; +import 'puzzles/puzzle_framework.dart'; class PerbugGamePage extends ConsumerStatefulWidget { const PerbugGamePage({super.key}); @@ -26,6 +28,7 @@ class _PerbugGamePageState extends ConsumerState { final state = ref.watch(perbugGameControllerProvider); final controller = ref.read(perbugGameControllerProvider.notifier); final moves = state.reachableMoves().take(8).toList(growable: false); + final puzzleSession = state.activePuzzleSession?.logicLocksSession; return RefreshIndicator( onRefresh: controller.initialize, @@ -79,6 +82,46 @@ class _PerbugGamePageState extends ConsumerState { const SizedBox(height: 12), if (state.loading) const AppCard(child: LinearProgressIndicator()), if (state.error != null) AppCard(child: Text(state.error!)), + _Section( + title: 'Node puzzle challenge', + subtitle: 'Perbug Logic Locks is deterministic from this node lat/lng and generated before play.', + children: [ + if (puzzleSession == null) + FilledButton.icon( + onPressed: controller.generateLogicLocksForCurrentNode, + icon: const Icon(Icons.extension_outlined), + label: const Text('Generate Logic Locks (#3)'), + ) + else ...[ + Text('Difficulty: ${puzzleSession.instance.difficulty.tier} (${(puzzleSession.instance.difficulty.score * 100).round()}%)'), + const SizedBox(height: 4), + Text('Knobs • vars ${puzzleSession.instance.data.entities.length} • clues ${puzzleSession.instance.data.clues.length} • depth ${puzzleSession.instance.data.deductionDepthEstimate.toStringAsFixed(2)}'), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + FilledButton( + onPressed: () async { + controller.startActivePuzzle(); + controller.logCluePanelViewed(); + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => const _LogicLocksSheet(), + ); + }, + child: const Text('Start puzzle'), + ), + OutlinedButton( + onPressed: controller.generateLogicLocksForCurrentNode, + child: const Text('Regenerate'), + ), + ], + ), + ], + ], + ), _Section( title: 'Reachable jumps', subtitle: 'Movement is restricted by distance and energy. No unrestricted teleporting.', @@ -113,20 +156,12 @@ class _PerbugGamePageState extends ConsumerState { .toList(growable: false), ), _Section( - title: 'Upcoming node challenge slots', - subtitle: 'Puzzle systems are not enabled yet, but node states and rewards are puzzle-ready.', - children: [ - Wrap( - spacing: 8, - runSpacing: 8, - children: const [ - AppPill(label: 'available', icon: Icons.check_circle_outline), - AppPill(label: 'completed', icon: Icons.task_alt), - AppPill(label: 'locked', icon: Icons.lock_outline), - AppPill(label: 'future-challenge-ready', icon: Icons.extension_outlined), - ], - ), - ], + title: 'Puzzle lifecycle hooks', + subtitle: 'Events captured for balancing and future rewards/energy systems.', + children: state.puzzleHistory + .take(6) + .map((event) => Text('• ${event.name} @ ${event.timestamp.toIso8601String().substring(11, 19)}')) + .toList(growable: false), ), ], ), @@ -134,6 +169,111 @@ class _PerbugGamePageState extends ConsumerState { } } +class _LogicLocksSheet extends ConsumerWidget { + const _LogicLocksSheet(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(perbugGameControllerProvider); + final controller = ref.read(perbugGameControllerProvider.notifier); + final session = state.activePuzzleSession?.logicLocksSession; + if (session == null) { + return const SizedBox(height: 220, child: Center(child: Text('No active puzzle.'))); + } + + final data = session.instance.data; + final player = session.playerState; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.lock_outline), + const SizedBox(width: 8), + Text('Perbug Logic Locks', style: Theme.of(context).textTheme.titleLarge), + ], + ), + Text('Difficulty ${session.instance.difficulty.tier} • seed ${session.instance.seed}'), + const SizedBox(height: 12), + AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Clues', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 6), + ...data.clues.map((c) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text('• ${c.text}'), + )), + ], + ), + ), + const SizedBox(height: 12), + AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Arrange entities into slots', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 6), + for (var i = 0; i < data.slotLabels.length; i++) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + SizedBox(width: 70, child: Text(data.slotLabels[i])), + const SizedBox(width: 8), + Expanded( + child: DropdownButtonFormField( + value: player.slotAssignments[i], + items: [ + const DropdownMenuItem(value: null, child: Text('—')), + ...data.solution.map((e) => DropdownMenuItem(value: e, child: Text(e))), + ], + onChanged: (v) => controller.assignPuzzleSlot(i, v), + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton( + onPressed: () { + final result = controller.submitPuzzleAttempt(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(result.success ? 'Solved! +energy hook applied.' : 'Not correct yet.')), + ); + }, + child: const Text('Submit'), + ), + OutlinedButton(onPressed: controller.undoPuzzleMove, child: const Text('Undo')), + OutlinedButton(onPressed: controller.resetPuzzle, child: const Text('Reset')), + TextButton( + onPressed: () { + controller.abandonActivePuzzle(); + Navigator.of(context).pop(); + }, + child: const Text('Abandon'), + ), + ], + ), + ], + ), + ), + ); + } +} + class _EnergyMeter extends StatelessWidget { const _EnergyMeter({required this.current, required this.max}); diff --git a/app/lib/features/home/puzzles/logic_locks_puzzle.dart b/app/lib/features/home/puzzles/logic_locks_puzzle.dart new file mode 100644 index 00000000..a9242fc9 --- /dev/null +++ b/app/lib/features/home/puzzles/logic_locks_puzzle.dart @@ -0,0 +1,364 @@ +import 'dart:math' as math; + +import 'puzzle_framework.dart'; + +enum LogicLocksClueType { exactPosition, notPosition, before, adjacent, eitherOr } + +class LogicLocksDifficultyConfig { + const LogicLocksDifficultyConfig({ + this.variableCount = 4, + this.clueCount = 6, + this.contradictionComplexity = 0.35, + this.ambiguityLevel = 0.15, + this.deductionDepth = 0.45, + this.gridSize = 4, + this.requireUniqueSolution = true, + }); + + final int variableCount; + final int clueCount; + final double contradictionComplexity; + final double ambiguityLevel; + final double deductionDepth; + final int gridSize; + final bool requireUniqueSolution; +} + +class LogicLocksClue { + const LogicLocksClue({ + required this.type, + required this.entityA, + this.entityB, + this.slot, + this.slotB, + required this.text, + }); + + final LogicLocksClueType type; + final String entityA; + final String? entityB; + final int? slot; + final int? slotB; + final String text; +} + +class LogicLocksPuzzleData { + const LogicLocksPuzzleData({ + required this.entities, + required this.slotLabels, + required this.solution, + required this.clues, + required this.allowedSolutions, + required this.deductionDepthEstimate, + required this.contradictionComplexityEstimate, + required this.ambiguityEstimate, + }); + + final List entities; + final List slotLabels; + final List solution; + final List clues; + final int allowedSolutions; + final double deductionDepthEstimate; + final double contradictionComplexityEstimate; + final double ambiguityEstimate; +} + +class LogicLocksPlayerState { + const LogicLocksPlayerState({ + required this.slotAssignments, + required this.undoStack, + }); + + factory LogicLocksPlayerState.empty(int slots) => LogicLocksPlayerState( + slotAssignments: List.filled(slots, null), + undoStack: const [], + ); + + final List slotAssignments; + final List> undoStack; + + LogicLocksPlayerState assign(int slot, String? entity) { + final next = List.from(slotAssignments); + final previousEntitySlot = next.indexOf(entity); + if (previousEntitySlot >= 0) { + next[previousEntitySlot] = null; + } + next[slot] = entity; + return LogicLocksPlayerState(slotAssignments: next, undoStack: [slotAssignments, ...undoStack]); + } + + LogicLocksPlayerState reset() => LogicLocksPlayerState.empty(slotAssignments.length); + + LogicLocksPlayerState undo() { + if (undoStack.isEmpty) return this; + return LogicLocksPlayerState(slotAssignments: undoStack.first, undoStack: undoStack.sublist(1)); + } +} + +class LogicLocksGenerator implements PuzzleGenerator { + @override + PuzzleInstance generate({ + required PuzzleSeedInput seedInput, + required LogicLocksDifficultyConfig config, + }) { + final rng = seededRandom(seedInput); + final variableCount = math.max(3, config.variableCount); + final gridSize = math.min(config.gridSize, variableCount); + final entities = List.generate(variableCount, (i) => 'Perbug ${String.fromCharCode(65 + i)}'); + final slotLabels = List.generate(gridSize, (i) => 'Slot ${i + 1}'); + + final solution = _deterministicSolution(entities, gridSize, rng); + final cluePool = _buildCluePool(solution, rng); + + final selected = _selectClues( + cluePool: cluePool, + solution: solution, + config: config, + rng: rng, + ); + + final activeEntities = solution.toList(growable: false); + final validSolutions = _solveAll(activeEntities, solution.length, selected); + final contradictionEstimate = _contradictionComplexityEstimate(selected); + final ambiguityEstimate = validSolutions.length / math.max(1, _factorial(solution.length)); + final depthEstimate = _deductionDepthEstimate(selected, solution.length); + + final difficulty = _computeDifficulty(config, contradictionEstimate, ambiguityEstimate, depthEstimate); + + return PuzzleInstance( + type: PuzzleType.logicLocks, + seed: seedInput.toDeterministicSeed(), + difficulty: difficulty, + data: LogicLocksPuzzleData( + entities: entities, + slotLabels: slotLabels, + solution: solution, + clues: selected, + allowedSolutions: validSolutions.length, + deductionDepthEstimate: depthEstimate, + contradictionComplexityEstimate: contradictionEstimate, + ambiguityEstimate: ambiguityEstimate, + ), + generatedAt: DateTime.now().toUtc(), + debug: { + 'allValidSolutionCount': validSolutions.length, + 'targetClues': config.clueCount, + 'activeGridSize': gridSize, + 'seedLatLng': '${seedInput.latitude},${seedInput.longitude}', + }, + ); + } + + List _deterministicSolution(List entities, int gridSize, math.Random rng) { + final available = List.from(entities)..shuffle(rng); + return available.take(gridSize).toList(growable: false); + } + + List _buildCluePool(List solution, math.Random rng) { + final clues = []; + for (var slot = 0; slot < solution.length; slot++) { + final e = solution[slot]; + clues.add(LogicLocksClue(type: LogicLocksClueType.exactPosition, entityA: e, slot: slot, text: '$e is in position ${slot + 1}.')); + clues.add(LogicLocksClue(type: LogicLocksClueType.notPosition, entityA: e, slot: (slot + 1) % solution.length, text: '$e is not in position ${((slot + 1) % solution.length) + 1}.')); + } + + for (var i = 0; i < solution.length; i++) { + for (var j = i + 1; j < solution.length; j++) { + final a = solution[i]; + final b = solution[j]; + clues.add(LogicLocksClue(type: LogicLocksClueType.before, entityA: a, entityB: b, text: '$a appears before $b.')); + if ((j - i).abs() == 1) { + clues.add(LogicLocksClue(type: LogicLocksClueType.adjacent, entityA: a, entityB: b, text: '$a is adjacent to $b.')); + } + } + } + + for (var i = 0; i < solution.length - 1; i++) { + final a = solution[i]; + final b = solution[i + 1]; + final aSlot = solution.indexOf(a); + final bSlot = solution.indexOf(b); + clues.add( + LogicLocksClue( + type: LogicLocksClueType.eitherOr, + entityA: a, + entityB: b, + slot: aSlot, + slotB: bSlot, + text: 'Either $a is in position ${aSlot + 1} or $b is in position ${bSlot + 1}.', + ), + ); + } + + clues.shuffle(rng); + return clues; + } + + List _selectClues({ + required List cluePool, + required List solution, + required LogicLocksDifficultyConfig config, + required math.Random rng, + }) { + final selected = []; + final entities = List.from(solution); + + for (final clue in cluePool) { + if (selected.length >= config.clueCount) break; + + final shouldPreferComplex = clue.type == LogicLocksClueType.eitherOr || clue.type == LogicLocksClueType.before; + if (shouldPreferComplex && rng.nextDouble() > config.contradictionComplexity + 0.15) { + continue; + } + + selected.add(clue); + final candidates = _solveAll(entities, solution.length, selected); + final ambiguityRatio = candidates.length / math.max(1, _factorial(solution.length)); + final withinAmbiguity = ambiguityRatio <= (config.ambiguityLevel + 0.2); + if (!withinAmbiguity && selected.length > 2) { + selected.removeLast(); + } + } + + if (config.requireUniqueSolution) { + for (final clue in cluePool) { + final candidates = _solveAll(entities, solution.length, selected); + if (candidates.length <= 1) break; + if (selected.contains(clue)) continue; + selected.add(clue); + } + } + + return selected; + } + + List> _solveAll(List entities, int size, List clues) { + final all = >[]; + void permute(List arr, int l) { + if (l == arr.length) { + final candidate = List.from(arr); + if (_satisfiesAll(candidate, clues)) { + all.add(candidate); + } + return; + } + for (var i = l; i < arr.length; i++) { + final next = List.from(arr); + final t = next[l]; + next[l] = next[i]; + next[i] = t; + permute(next, l + 1); + } + } + + permute(List.from(entities.take(size)), 0); + return all; + } + + bool _satisfiesAll(List arrangement, List clues) { + for (final clue in clues) { + final aSlot = arrangement.indexOf(clue.entityA); + final bSlot = clue.entityB == null ? null : arrangement.indexOf(clue.entityB!); + switch (clue.type) { + case LogicLocksClueType.exactPosition: + if (aSlot != clue.slot) return false; + break; + case LogicLocksClueType.notPosition: + if (aSlot == clue.slot) return false; + break; + case LogicLocksClueType.before: + if (bSlot == null || aSlot >= bSlot) return false; + break; + case LogicLocksClueType.adjacent: + if (bSlot == null || (aSlot - bSlot).abs() != 1) return false; + break; + case LogicLocksClueType.eitherOr: + final condA = aSlot == clue.slot; + final condB = bSlot == clue.slotB; + if (!(condA || condB)) return false; + break; + } + } + return true; + } + + PuzzleDifficulty _computeDifficulty( + LogicLocksDifficultyConfig config, + double contradictionEstimate, + double ambiguityEstimate, + double depthEstimate, + ) { + final contributions = { + 'variables': config.variableCount / 7, + 'clues': (1 - (config.clueCount / 12)).clamp(0, 1).toDouble(), + 'contradictionComplexity': contradictionEstimate, + 'ambiguity': ambiguityEstimate, + 'deductionDepth': depthEstimate, + 'gridSize': config.gridSize / 7, + }; + + final score = contributions.values.reduce((a, b) => a + b) / contributions.length; + final tier = score < 0.35 + ? 'Easy' + : score < 0.58 + ? 'Moderate' + : score < 0.78 + ? 'Hard' + : 'Expert'; + + return PuzzleDifficulty( + score: score, + tier: tier, + contributions: contributions, + debug: { + 'targetDepth': config.deductionDepth, + 'targetAmbiguity': config.ambiguityLevel, + 'targetContradictionComplexity': config.contradictionComplexity, + }, + ); + } + + double _contradictionComplexityEstimate(List clues) { + if (clues.isEmpty) return 0; + final complex = clues.where((c) => c.type == LogicLocksClueType.eitherOr || c.type == LogicLocksClueType.before).length; + return complex / clues.length; + } + + double _deductionDepthEstimate(List clues, int variables) { + if (clues.isEmpty) return 0; + final nonDirect = clues.where((c) => c.type != LogicLocksClueType.exactPosition).length; + return (nonDirect / clues.length) * (clues.length / math.max(variables, 1)); + } + + int _factorial(int n) { + var value = 1; + for (var i = 2; i <= n; i++) { + value *= i; + } + return value; + } +} + +class LogicLocksValidator implements PuzzleValidator { + @override + PuzzleResult validate({ + required PuzzleInstance instance, + required LogicLocksPlayerState playerState, + required int attempts, + required DateTime startedAt, + required DateTime endedAt, + }) { + final expected = instance.data.solution; + final success = playerState.slotAssignments.length == expected.length && + playerState.slotAssignments.asMap().entries.every((entry) => entry.value == expected[entry.key]); + + return PuzzleResult( + success: success, + timeToSolve: endedAt.difference(startedAt), + attempts: attempts, + reason: success ? 'Solved' : 'Incorrect arrangement', + rewardEnergyHook: success, + ); + } +} diff --git a/app/lib/features/home/puzzles/puzzle_framework.dart b/app/lib/features/home/puzzles/puzzle_framework.dart new file mode 100644 index 00000000..7b59dc4c --- /dev/null +++ b/app/lib/features/home/puzzles/puzzle_framework.dart @@ -0,0 +1,162 @@ +import 'dart:math' as math; + +enum PuzzleType { gridPath, patternRecall, logicLocks } + +class PuzzleSeedInput { + const PuzzleSeedInput({ + required this.nodeId, + required this.latitude, + required this.longitude, + this.difficultySalt = 'normal', + this.extraSalt, + }); + + final String nodeId; + final double latitude; + final double longitude; + final String difficultySalt; + final String? extraSalt; + + /// Deterministic seed derivation from node coordinates + stable salts. + /// + /// Coordinates are normalized to fixed precision to avoid floating drift, + /// then mixed with node id and optional salts. + int toDeterministicSeed() { + final lat = (latitude * 1e6).round(); + final lng = (longitude * 1e6).round(); + final base = '$nodeId|$lat|$lng|$difficultySalt|${extraSalt ?? ''}'; + var hash = 2166136261; + for (final code in base.codeUnits) { + hash ^= code; + hash = (hash * 16777619) & 0x7fffffff; + } + return hash; + } +} + +class PuzzleDifficulty { + const PuzzleDifficulty({ + required this.score, + required this.tier, + required this.contributions, + this.debug = const {}, + }); + + final double score; + final String tier; + final Map contributions; + final Map debug; +} + +class PuzzleInstance { + const PuzzleInstance({ + required this.type, + required this.seed, + required this.difficulty, + required this.data, + required this.generatedAt, + this.debug = const {}, + }); + + final PuzzleType type; + final int seed; + final PuzzleDifficulty difficulty; + final TPuzzleData data; + final DateTime generatedAt; + final Map debug; +} + +enum PuzzleSessionStatus { generated, started, succeeded, failed, abandoned } + +class PuzzleSession { + const PuzzleSession({ + required this.instance, + required this.status, + required this.playerState, + required this.attempts, + required this.createdAt, + this.startedAt, + this.endedAt, + this.lastError, + this.analyticsEvents = const [], + }); + + final PuzzleInstance instance; + final PuzzleSessionStatus status; + final TPlayerState playerState; + final int attempts; + final DateTime createdAt; + final DateTime? startedAt; + final DateTime? endedAt; + final String? lastError; + final List analyticsEvents; + + PuzzleSession copyWith({ + PuzzleSessionStatus? status, + TPlayerState? playerState, + int? attempts, + DateTime? startedAt, + DateTime? endedAt, + String? lastError, + List? analyticsEvents, + }) { + return PuzzleSession( + instance: instance, + status: status ?? this.status, + playerState: playerState ?? this.playerState, + attempts: attempts ?? this.attempts, + createdAt: createdAt, + startedAt: startedAt ?? this.startedAt, + endedAt: endedAt ?? this.endedAt, + lastError: lastError ?? this.lastError, + analyticsEvents: analyticsEvents ?? this.analyticsEvents, + ); + } +} + +class PuzzleResult { + const PuzzleResult({ + required this.success, + required this.timeToSolve, + required this.attempts, + this.reason, + this.rewardEnergyHook = false, + }); + + final bool success; + final Duration timeToSolve; + final int attempts; + final String? reason; + final bool rewardEnergyHook; +} + +class PuzzleAnalyticsEvent { + const PuzzleAnalyticsEvent({ + required this.name, + required this.timestamp, + this.payload = const {}, + }); + + final String name; + final DateTime timestamp; + final Map payload; +} + +abstract class PuzzleGenerator { + PuzzleInstance generate({ + required PuzzleSeedInput seedInput, + required TConfig config, + }); +} + +abstract class PuzzleValidator { + PuzzleResult validate({ + required PuzzleInstance instance, + required TPlayerState playerState, + required int attempts, + required DateTime startedAt, + required DateTime endedAt, + }); +} + +math.Random seededRandom(PuzzleSeedInput seedInput) => math.Random(seedInput.toDeterministicSeed()); diff --git a/app/test/logic_locks_puzzle_test.dart b/app/test/logic_locks_puzzle_test.dart new file mode 100644 index 00000000..fe312cbb --- /dev/null +++ b/app/test/logic_locks_puzzle_test.dart @@ -0,0 +1,79 @@ +import 'package:dryad/features/home/puzzles/logic_locks_puzzle.dart'; +import 'package:dryad/features/home/puzzles/puzzle_framework.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final generator = LogicLocksGenerator(); + + test('deterministic generation from same lat/lng + node', () { + const seed = PuzzleSeedInput(nodeId: 'node-1', latitude: 30.2672, longitude: -97.7431, difficultySalt: '4:6:4'); + const config = LogicLocksDifficultyConfig(variableCount: 4, clueCount: 6, gridSize: 4); + + final a = generator.generate(seedInput: seed, config: config); + final b = generator.generate(seedInput: seed, config: config); + + expect(a.seed, b.seed); + expect(a.data.solution, b.data.solution); + expect(a.data.clues.map((c) => c.text), b.data.clues.map((c) => c.text)); + }); + + test('different nodes generate different puzzles', () { + const config = LogicLocksDifficultyConfig(variableCount: 4, clueCount: 6, gridSize: 4); + const seedA = PuzzleSeedInput(nodeId: 'node-1', latitude: 30.2672, longitude: -97.7431); + const seedB = PuzzleSeedInput(nodeId: 'node-2', latitude: 30.2800, longitude: -97.7600); + + final a = generator.generate(seedInput: seedA, config: config); + final b = generator.generate(seedInput: seedB, config: config); + + expect(a.seed == b.seed && a.data.solution.join(',') == b.data.solution.join(','), isFalse); + }); + + test('generated puzzle is solvable and unique when required', () { + const config = LogicLocksDifficultyConfig(variableCount: 5, clueCount: 7, gridSize: 5, requireUniqueSolution: true); + const seed = PuzzleSeedInput(nodeId: 'node-u', latitude: 37.7749, longitude: -122.4194); + + final instance = generator.generate(seedInput: seed, config: config); + + expect(instance.data.allowedSolutions, 1); + expect(instance.data.clues, isNotEmpty); + }); + + test('difficulty knobs increase difficulty score at higher settings', () { + const seed = PuzzleSeedInput(nodeId: 'node-d', latitude: 40.7128, longitude: -74.0060); + const easy = LogicLocksDifficultyConfig(variableCount: 3, clueCount: 8, contradictionComplexity: 0.1, ambiguityLevel: 0.1, deductionDepth: 0.2, gridSize: 3); + const hard = LogicLocksDifficultyConfig(variableCount: 6, clueCount: 4, contradictionComplexity: 0.7, ambiguityLevel: 0.45, deductionDepth: 0.8, gridSize: 6); + + final easyInstance = generator.generate(seedInput: seed, config: easy); + final hardInstance = generator.generate(seedInput: seed, config: hard); + + expect(hardInstance.difficulty.score, greaterThan(easyInstance.difficulty.score)); + }); + + test('validator accepts correct arrangement and rejects incorrect one', () { + const config = LogicLocksDifficultyConfig(variableCount: 4, clueCount: 7, gridSize: 4); + const seed = PuzzleSeedInput(nodeId: 'node-v', latitude: 48.8566, longitude: 2.3522); + final instance = generator.generate(seedInput: seed, config: config); + final validator = LogicLocksValidator(); + + final correct = LogicLocksPlayerState(slotAssignments: List.from(instance.data.solution), undoStack: const []); + final wrong = LogicLocksPlayerState(slotAssignments: List.from(instance.data.solution.reversed), undoStack: const []); + + final success = validator.validate( + instance: instance, + playerState: correct, + attempts: 1, + startedAt: DateTime.utc(2026, 3, 27, 10), + endedAt: DateTime.utc(2026, 3, 27, 10, 1), + ); + final failure = validator.validate( + instance: instance, + playerState: wrong, + attempts: 2, + startedAt: DateTime.utc(2026, 3, 27, 10), + endedAt: DateTime.utc(2026, 3, 27, 10, 2), + ); + + expect(success.success, isTrue); + expect(failure.success, isFalse); + }); +}