diff --git a/app/lib/features/home/perbug_game_controller.dart b/app/lib/features/home/perbug_game_controller.dart index 61751472..0144e075 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/puzzle_framework.dart'; +import 'puzzles/sequence_forge_puzzle.dart'; final perbugGameControllerProvider = StateNotifierProvider((ref) { return PerbugGameController(ref); @@ -14,6 +16,7 @@ class PerbugGameController extends StateNotifier { PerbugGameController(this._ref) : super(PerbugGameState.initial()); final Ref _ref; + static const SequenceForgeGenerator _sequenceForgeGenerator = SequenceForgeGenerator(); static const MapViewport _fixedGameplayViewport = MapViewport(centerLat: 30.2672, centerLng: -97.7431, zoom: 13); @@ -73,6 +76,7 @@ class PerbugGameController extends StateNotifier { 'Jumped ${_formatDistance(move.node.distanceFromCurrentMeters ?? 0)} to ${move.node.label} (-$spend, +$gained energy)', ...state.history, ], + clearActiveSequenceForgeSession: true, ); return true; } @@ -84,6 +88,120 @@ class PerbugGameController extends StateNotifier { ); } + void launchSequenceForgeForCurrentNode() { + final node = state.currentNode; + if (node == null) return; + + final knobs = SequenceForgeDifficultyKnobs( + sequenceDepth: 6 + (state.visitedNodeIds.length % 3), + transformationLayers: state.visitedNodeIds.length >= 4 ? 2 : 1, + hiddenSteps: state.visitedNodeIds.length >= 3 ? 2 : 1, + operatorComplexity: state.visitedNodeIds.length >= 5 ? 4 : 2, + answerChoices: state.visitedNodeIds.length >= 6 ? 5 : 4, + misleadingSymmetry: state.visitedNodeIds.length >= 5 ? 2 : 0, + ); + + final input = PuzzleSeedInput( + nodeId: node.id, + latitude: node.latitude, + longitude: node.longitude, + difficultyBand: state.visitedNodeIds.length, + ); + + final instance = _sequenceForgeGenerator.generate(seedInput: input, knobs: knobs); + final session = PuzzleSession( + instance: instance, + status: PuzzleSessionStatus.generated, + selectedAnswers: const {}, + startedAt: null, + retries: 0, + ); + + state = state.copyWith( + activeSequenceForgeSession: session, + puzzleEvents: ['generated:${instance.instanceId}', ...state.puzzleEvents], + history: ['Sequence Forge generated for ${node.label} (${instance.difficulty.tier.name})', ...state.history], + ); + } + + void startActivePuzzle() { + final session = state.activeSequenceForgeSession; + if (session == null) return; + if (session.status != PuzzleSessionStatus.generated) return; + state = state.copyWith( + activeSequenceForgeSession: session.copyWith(status: PuzzleSessionStatus.started, startedAt: DateTime.now()), + puzzleEvents: ['started:${session.instance.instanceId}', ...state.puzzleEvents], + ); + } + + void selectPuzzleAnswer({required int hiddenIndex, required String answer}) { + final session = state.activeSequenceForgeSession; + if (session == null) return; + final selected = {...session.selectedAnswers, hiddenIndex: answer}; + state = state.copyWith(activeSequenceForgeSession: session.copyWith(selectedAnswers: selected)); + } + + PuzzleResult? submitActivePuzzle() { + final session = state.activeSequenceForgeSession; + final node = state.currentNode; + if (session == null || node == null) return null; + + final success = validateSequenceForgeSubmission(data: session.instance.data, selectedAnswers: session.selectedAnswers); + final startedAt = session.startedAt ?? DateTime.now(); + final result = PuzzleResult( + type: PuzzleType.perbugSequenceForge, + success: success, + nodeId: node.id, + duration: DateTime.now().difference(startedAt), + retries: session.retries, + difficulty: session.instance.difficulty, + telemetry: { + 'family': session.instance.data.family.name, + 'depth': session.instance.data.fullSequence.length, + 'layers': session.instance.debugMetadata['transformationLayers'], + 'hiddenSteps': session.instance.data.hiddenIndices.length, + 'operatorComplexity': session.instance.debugMetadata['operatorComplexity'], + 'answerChoices': session.instance.debugMetadata['answerChoices'], + 'misleadingSymmetry': session.instance.data.misleadingSymmetryApplied, + }, + ); + + final status = success ? PuzzleSessionStatus.succeeded : PuzzleSessionStatus.failed; + final retries = success ? session.retries : session.retries + 1; + final bonus = success ? 2 : 0; + state = state.copyWith( + energy: (state.energy + bonus).clamp(0, state.maxEnergy), + activeSequenceForgeSession: session.copyWith(status: status, retries: retries), + lastPuzzleResult: result, + puzzleEvents: ['submitted:${session.instance.instanceId}:$success', ...state.puzzleEvents], + history: [ + '${success ? 'Solved' : 'Missed'} Sequence Forge at ${node.label}${success ? ' (+$bonus energy)' : ''}', + ...state.history, + ], + ); + return result; + } + + void abandonActivePuzzle() { + final session = state.activeSequenceForgeSession; + if (session == null) return; + state = state.copyWith( + activeSequenceForgeSession: session.copyWith(status: PuzzleSessionStatus.abandoned), + puzzleEvents: ['abandoned:${session.instance.instanceId}', ...state.puzzleEvents], + history: ['Abandoned Sequence Forge at ${state.currentNode?.label ?? 'node'}', ...state.history], + ); + } + + void resetActivePuzzleSelections() { + final session = state.activeSequenceForgeSession; + if (session == null) return; + state = state.copyWith( + activeSequenceForgeSession: session.copyWith(selectedAnswers: const {}, status: PuzzleSessionStatus.started), + puzzleEvents: ['reset:${session.instance.instanceId}', ...state.puzzleEvents], + clearLastPuzzleResult: 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..b24f4498 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/puzzle_framework.dart'; +import 'puzzles/sequence_forge_puzzle.dart'; enum PerbugNodeState { available, completed, locked, exhausted, special, futureChallengeReady } @@ -66,6 +68,9 @@ class PerbugGameState { required this.loading, required this.visitedNodeIds, required this.history, + required this.puzzleEvents, + this.activeSequenceForgeSession, + this.lastPuzzleResult, this.error, }); @@ -78,6 +83,7 @@ class PerbugGameState { loading: false, visitedNodeIds: {}, history: [], + puzzleEvents: [], ); final List nodes; @@ -88,6 +94,9 @@ class PerbugGameState { final bool loading; final Set visitedNodeIds; final List history; + final List puzzleEvents; + final PuzzleSession? activeSequenceForgeSession; + final PuzzleResult? lastPuzzleResult; final String? error; PerbugNode? get currentNode { @@ -109,6 +118,11 @@ class PerbugGameState { bool? loading, Set? visitedNodeIds, List? history, + List? puzzleEvents, + PuzzleSession? activeSequenceForgeSession, + bool clearActiveSequenceForgeSession = false, + PuzzleResult? lastPuzzleResult, + bool clearLastPuzzleResult = false, String? error, bool clearError = false, }) { @@ -121,6 +135,11 @@ class PerbugGameState { loading: loading ?? this.loading, visitedNodeIds: visitedNodeIds ?? this.visitedNodeIds, history: history ?? this.history, + puzzleEvents: puzzleEvents ?? this.puzzleEvents, + activeSequenceForgeSession: clearActiveSequenceForgeSession + ? null + : (activeSequenceForgeSession ?? this.activeSequenceForgeSession), + lastPuzzleResult: clearLastPuzzleResult ? null : (lastPuzzleResult ?? this.lastPuzzleResult), 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..66514f9d 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/puzzle_framework.dart'; +import 'puzzles/sequence_forge_puzzle.dart'; class PerbugGamePage extends ConsumerStatefulWidget { const PerbugGamePage({super.key}); @@ -112,24 +114,106 @@ 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), + _SequenceForgeSection(state: state, controller: controller), + ], + ), + ); + } +} + +class _SequenceForgeSection extends StatelessWidget { + const _SequenceForgeSection({required this.state, required this.controller}); + + final PerbugGameState state; + final PerbugGameController controller; + + @override + Widget build(BuildContext context) { + final session = state.activeSequenceForgeSession; + if (session == null) { + return _Section( + title: 'Puzzle #6 · Perbug Sequence Forge', + subtitle: 'Deterministic sequence puzzle generated from current node latitude/longitude.', + children: [ + PrimaryButton( + label: 'Generate Sequence Forge', + onPressed: controller.launchSequenceForgeForCurrentNode, + ), + ], + ); + } + + final data = session.instance.data; + return _Section( + title: 'Puzzle #6 · Perbug Sequence Forge', + subtitle: 'Difficulty ${session.instance.difficulty.tier.name.toUpperCase()} • score ${session.instance.difficulty.score}', + children: [ + Text(data.ruleDescription), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [for (final term in data.visibleSequence) Chip(label: Text(term))], + ), + const SizedBox(height: 10), + if (session.status == PuzzleSessionStatus.generated) + PrimaryButton(label: 'Start puzzle', onPressed: controller.startActivePuzzle), + if (session.status != PuzzleSessionStatus.generated) + ...data.hiddenIndices.map((index) { + final options = data.choicesByHiddenIndex[index] ?? const []; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Missing step ${index + 1}'), + const SizedBox(height: 4), + Wrap( + spacing: 8, + children: [ + for (final option in options) + ChoiceChip( + label: Text(option), + selected: session.selectedAnswers[index] == option, + onSelected: (_) => controller.selectPuzzleAnswer(hiddenIndex: index, answer: option), + ), + ], + ), ], ), - ], + ); + }), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: 'Submit', + onPressed: session.status == PuzzleSessionStatus.generated + ? null + : () { + final result = controller.submitActivePuzzle(); + final ok = result?.success == true; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(ok ? 'Sequence forged successfully!' : 'Incorrect sequence. Retry or regenerate.')), + ); + }, + ), + ), + const SizedBox(width: 8), + Expanded(child: SecondaryButton(label: 'Reset', onPressed: controller.resetActivePuzzleSelections)), + const SizedBox(width: 8), + Expanded(child: SecondaryButton(label: 'Abandon', onPressed: controller.abandonActivePuzzle)), + ], + ), + if (state.lastPuzzleResult != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + 'Last result: ${state.lastPuzzleResult!.success ? 'Success' : 'Fail'} · ' + '${state.lastPuzzleResult!.duration.inSeconds}s · retries ${state.lastPuzzleResult!.retries}', + ), ), - ], - ), + ], ); } } 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..359ac1ca --- /dev/null +++ b/app/lib/features/home/puzzles/puzzle_framework.dart @@ -0,0 +1,140 @@ +import 'dart:math'; + +/// Canonical puzzle types for node gameplay. Sequence Forge is #6. +enum PuzzleType { + signalLock, + pathWeave, + glyphMatch, + parityGrid, + relaySwitch, + perbugSequenceForge, +} + +class PuzzleSeedInput { + const PuzzleSeedInput({ + required this.nodeId, + required this.latitude, + required this.longitude, + required this.difficultyBand, + this.salt = '', + }); + + final String nodeId; + final double latitude; + final double longitude; + final int difficultyBand; + final String salt; + + /// Stable string used by all puzzle generators. + String get material => + '$nodeId|${latitude.toStringAsFixed(6)}|${longitude.toStringAsFixed(6)}|$difficultyBand|$salt'; + + /// FNV-1a hash for deterministic RNG seeding. + int get deterministicSeed { + var hash = 0x811c9dc5; + for (final unit in material.codeUnits) { + hash ^= unit; + hash = (hash * 0x01000193) & 0xffffffff; + } + return hash & 0x7fffffff; + } +} + +enum PuzzleDifficultyTier { novice, standard, advanced, expert } + +class PuzzleDifficulty { + const PuzzleDifficulty({ + required this.score, + required this.tier, + required this.contributions, + required this.explainer, + }); + + final int score; + final PuzzleDifficultyTier tier; + final Map contributions; + final String explainer; +} + +class PuzzleInstance { + const PuzzleInstance({ + required this.type, + required this.instanceId, + required this.seed, + required this.difficulty, + required this.data, + required this.debugMetadata, + }); + + final PuzzleType type; + final String instanceId; + final int seed; + final PuzzleDifficulty difficulty; + final TData data; + final Map debugMetadata; +} + +enum PuzzleSessionStatus { generated, started, submitted, succeeded, failed, abandoned } + +class PuzzleSession { + const PuzzleSession({ + required this.instance, + required this.status, + required this.selectedAnswers, + required this.startedAt, + required this.retries, + }); + + final PuzzleInstance instance; + final PuzzleSessionStatus status; + final Map selectedAnswers; + final DateTime? startedAt; + final int retries; + + PuzzleSession copyWith({ + PuzzleSessionStatus? status, + Map? selectedAnswers, + DateTime? startedAt, + bool clearStartedAt = false, + int? retries, + }) { + return PuzzleSession( + instance: instance, + status: status ?? this.status, + selectedAnswers: selectedAnswers ?? this.selectedAnswers, + startedAt: clearStartedAt ? null : (startedAt ?? this.startedAt), + retries: retries ?? this.retries, + ); + } +} + +class PuzzleResult { + const PuzzleResult({ + required this.type, + required this.success, + required this.nodeId, + required this.duration, + required this.retries, + required this.difficulty, + required this.telemetry, + }); + + final PuzzleType type; + final bool success; + final String nodeId; + final Duration duration; + final int retries; + final PuzzleDifficulty difficulty; + final Map telemetry; +} + +abstract class PuzzleGenerator { + PuzzleType get type; + + PuzzleInstance generate({ + required PuzzleSeedInput seedInput, + required TKnobs knobs, + }); +} + +int pickDeterministically(Random random, int maxExclusive) => random.nextInt(maxExclusive); diff --git a/app/lib/features/home/puzzles/puzzle_registry.dart b/app/lib/features/home/puzzles/puzzle_registry.dart new file mode 100644 index 00000000..e32ccd29 --- /dev/null +++ b/app/lib/features/home/puzzles/puzzle_registry.dart @@ -0,0 +1,10 @@ +import 'puzzle_framework.dart'; + +const puzzleTypeOrder = [ + PuzzleType.signalLock, + PuzzleType.pathWeave, + PuzzleType.glyphMatch, + PuzzleType.parityGrid, + PuzzleType.relaySwitch, + PuzzleType.perbugSequenceForge, +]; diff --git a/app/lib/features/home/puzzles/sequence_forge_puzzle.dart b/app/lib/features/home/puzzles/sequence_forge_puzzle.dart new file mode 100644 index 00000000..769724ab --- /dev/null +++ b/app/lib/features/home/puzzles/sequence_forge_puzzle.dart @@ -0,0 +1,292 @@ +import 'dart:math'; + +import 'puzzle_framework.dart'; + +enum SequenceFamily { arithmetic, multiplicative, alternating, symbolicCycle } + +class SequenceForgeDifficultyKnobs { + const SequenceForgeDifficultyKnobs({ + required this.sequenceDepth, + required this.transformationLayers, + required this.hiddenSteps, + required this.operatorComplexity, + required this.answerChoices, + required this.misleadingSymmetry, + }); + + final int sequenceDepth; + final int transformationLayers; + final int hiddenSteps; + final int operatorComplexity; + final int answerChoices; + final int misleadingSymmetry; +} + +class SequenceForgePuzzleData { + const SequenceForgePuzzleData({ + required this.family, + required this.visibleSequence, + required this.fullSequence, + required this.hiddenIndices, + required this.correctAnswers, + required this.choicesByHiddenIndex, + required this.ruleDescription, + required this.misleadingSymmetryApplied, + }); + + final SequenceFamily family; + final List visibleSequence; + final List fullSequence; + final List hiddenIndices; + final Map correctAnswers; + final Map> choicesByHiddenIndex; + final String ruleDescription; + final bool misleadingSymmetryApplied; +} + +class SequenceForgeGenerator extends PuzzleGenerator { + const SequenceForgeGenerator(); + + @override + PuzzleType get type => PuzzleType.perbugSequenceForge; + + @override + PuzzleInstance generate({ + required PuzzleSeedInput seedInput, + required SequenceForgeDifficultyKnobs knobs, + }) { + final seed = seedInput.deterministicSeed; + final random = Random(seed); + final difficulty = _computeDifficulty(knobs); + final family = _selectFamily(knobs, random); + final fullSequence = _buildSequence(family, knobs, random); + final hiddenIndices = _pickHiddenIndices(fullSequence.length, knobs.hiddenSteps, random); + final correct = {for (final index in hiddenIndices) index: fullSequence[index]}; + final misleading = knobs.misleadingSymmetry > 0 && random.nextDouble() < knobs.misleadingSymmetry / 5; + final choices = { + for (final index in hiddenIndices) + index: _buildChoices( + fullSequence: fullSequence, + hiddenIndex: index, + correctAnswer: fullSequence[index], + count: knobs.answerChoices, + random: random, + misleadingSymmetry: misleading, + ), + }; + final visible = [for (var i = 0; i < fullSequence.length; i++) hiddenIndices.contains(i) ? '?' : fullSequence[i]]; + + final data = SequenceForgePuzzleData( + family: family, + visibleSequence: visible, + fullSequence: fullSequence, + hiddenIndices: hiddenIndices, + correctAnswers: correct, + choicesByHiddenIndex: choices, + ruleDescription: _ruleDescription(family, knobs), + misleadingSymmetryApplied: misleading, + ); + + return PuzzleInstance( + type: type, + instanceId: '${seedInput.nodeId}-sequence-forge-${difficulty.tier.name}-$seed', + seed: seed, + difficulty: difficulty, + data: data, + debugMetadata: { + 'seedMaterial': seedInput.material, + 'family': family.name, + 'sequenceDepth': knobs.sequenceDepth, + 'transformationLayers': knobs.transformationLayers, + 'hiddenSteps': knobs.hiddenSteps, + 'operatorComplexity': knobs.operatorComplexity, + 'answerChoices': knobs.answerChoices, + 'misleadingSymmetry': knobs.misleadingSymmetry, + 'ruleDescription': data.ruleDescription, + 'visibleSequence': data.visibleSequence, + 'fullSequence': data.fullSequence, + 'hiddenIndices': data.hiddenIndices, + 'correctAnswers': data.correctAnswers, + 'choices': data.choicesByHiddenIndex, + }, + ); + } + + PuzzleDifficulty _computeDifficulty(SequenceForgeDifficultyKnobs knobs) { + final contributions = { + 'sequenceDepth': knobs.sequenceDepth * 4, + 'transformationLayers': knobs.transformationLayers * 8, + 'hiddenSteps': knobs.hiddenSteps * 10, + 'operatorComplexity': knobs.operatorComplexity * 9, + 'answerChoices': knobs.answerChoices * 3, + 'misleadingSymmetry': knobs.misleadingSymmetry * 7, + }; + final score = contributions.values.fold(0, (sum, value) => sum + value); + final tier = score < 60 + ? PuzzleDifficultyTier.novice + : score < 95 + ? PuzzleDifficultyTier.standard + : score < 130 + ? PuzzleDifficultyTier.advanced + : PuzzleDifficultyTier.expert; + final explainer = contributions.entries.map((e) => '${e.key}:${e.value}').join(', '); + return PuzzleDifficulty(score: score, tier: tier, contributions: contributions, explainer: explainer); + } + + SequenceFamily _selectFamily(SequenceForgeDifficultyKnobs knobs, Random random) { + final familyPool = knobs.operatorComplexity <= 2 + ? [SequenceFamily.arithmetic, SequenceFamily.multiplicative] + : SequenceFamily.values; + return familyPool[pickDeterministically(random, familyPool.length)]; + } + + List _buildSequence(SequenceFamily family, SequenceForgeDifficultyKnobs knobs, Random random) { + switch (family) { + case SequenceFamily.arithmetic: + return _arithmetic(knobs, random).map((v) => '$v').toList(growable: false); + case SequenceFamily.multiplicative: + return _multiplicative(knobs, random).map((v) => '$v').toList(growable: false); + case SequenceFamily.alternating: + return _alternating(knobs, random).map((v) => '$v').toList(growable: false); + case SequenceFamily.symbolicCycle: + return _symbolicCycle(knobs, random); + } + } + + List _arithmetic(SequenceForgeDifficultyKnobs knobs, Random random) { + final start = random.nextInt(15) + 2; + var delta = random.nextInt(5) + 1; + final layerBoost = knobs.transformationLayers > 1 ? random.nextInt(3) + 1 : 0; + final values = []; + var current = start; + for (var i = 0; i < knobs.sequenceDepth; i++) { + values.add(current); + current += delta; + if (knobs.transformationLayers > 1 && i.isEven) { + current += layerBoost; + } + if (knobs.operatorComplexity >= 4 && i % 3 == 2) { + delta += 1; + } + } + return values; + } + + List _multiplicative(SequenceForgeDifficultyKnobs knobs, Random random) { + final start = random.nextInt(4) + 2; + final multiplier = random.nextInt(2) + 2; + final additive = knobs.transformationLayers > 1 ? random.nextInt(3) + 1 : 0; + final values = []; + var current = start; + for (var i = 0; i < knobs.sequenceDepth; i++) { + values.add(current); + current = current * multiplier + additive; + if (knobs.operatorComplexity >= 4 && i.isOdd) { + current -= 1; + } + } + return values; + } + + List _alternating(SequenceForgeDifficultyKnobs knobs, Random random) { + final addA = random.nextInt(5) + 2; + final addB = random.nextInt(4) + 1; + final start = random.nextInt(20) + 5; + final values = []; + var current = start; + for (var i = 0; i < knobs.sequenceDepth; i++) { + values.add(current); + final step = i.isEven ? addA : -addB; + current += step; + if (knobs.transformationLayers > 1 && i % 3 == 1) { + current += 2; + } + } + return values; + } + + List _symbolicCycle(SequenceForgeDifficultyKnobs knobs, Random random) { + const symbols = ['△', '○', '□', '◇', '⬟', '✶']; + final start = random.nextInt(symbols.length); + final jump = random.nextInt(2) + 1; + final colorCycle = ['R', 'G', 'B']; + final values = []; + for (var i = 0; i < knobs.sequenceDepth; i++) { + final symbolIndex = (start + (i * jump)) % symbols.length; + final colorIndex = knobs.transformationLayers > 1 ? i % colorCycle.length : 0; + final suffix = knobs.transformationLayers > 1 ? colorCycle[colorIndex] : ''; + values.add('${symbols[symbolIndex]}$suffix'); + } + return values; + } + + List _pickHiddenIndices(int length, int hiddenSteps, Random random) { + final target = hiddenSteps.clamp(1, max(1, length - 2)); + final candidates = [for (var i = 1; i < length - 1; i++) i]..shuffle(random); + final picked = candidates.take(target).toList()..sort(); + return picked; + } + + List _buildChoices({ + required List fullSequence, + required int hiddenIndex, + required String correctAnswer, + required int count, + required Random random, + required bool misleadingSymmetry, + }) { + final targetCount = max(2, count); + final options = {correctAnswer}; + + String numericNoise(int delta) { + final asInt = int.tryParse(correctAnswer); + if (asInt == null) return '$correctAnswer*'; + return '${max(0, asInt + delta)}'; + } + + if (misleadingSymmetry && hiddenIndex > 0) { + options.add(fullSequence[hiddenIndex - 1]); + } + + options + ..add(numericNoise(1)) + ..add(numericNoise(-1)) + ..add(numericNoise(2)); + + while (options.length < targetCount + 2) { + final suffix = random.nextInt(9) + 1; + final baseInt = int.tryParse(correctAnswer); + options.add(baseInt == null ? '$correctAnswer$suffix' : '${baseInt + suffix}'); + } + + final filtered = options.where((value) => value != correctAnswer || options.length == 1).toSet(); + final decoys = filtered.where((value) => value != correctAnswer).toList()..shuffle(random); + final finalOptions = [correctAnswer, ...decoys.take(targetCount - 1)]..shuffle(random); + return finalOptions; + } + + String _ruleDescription(SequenceFamily family, SequenceForgeDifficultyKnobs knobs) { + final layerLabel = knobs.transformationLayers > 1 ? '${knobs.transformationLayers} layers' : 'single layer'; + switch (family) { + case SequenceFamily.arithmetic: + return 'Arithmetic progression with $layerLabel.'; + case SequenceFamily.multiplicative: + return 'Multiplicative progression with $layerLabel.'; + case SequenceFamily.alternating: + return 'Alternating operators with $layerLabel.'; + case SequenceFamily.symbolicCycle: + return 'Symbol cycle transform with $layerLabel.'; + } + } +} + +bool validateSequenceForgeSubmission({ + required SequenceForgePuzzleData data, + required Map selectedAnswers, +}) { + if (selectedAnswers.length != data.hiddenIndices.length) return false; + for (final entry in data.correctAnswers.entries) { + if (selectedAnswers[entry.key] != entry.value) return false; + } + return true; +} diff --git a/app/test/perbug_game_models_test.dart b/app/test/perbug_game_models_test.dart index 1227a092..e0cad636 100644 --- a/app/test/perbug_game_models_test.dart +++ b/app/test/perbug_game_models_test.dart @@ -16,6 +16,7 @@ void main() { loading: false, visitedNodeIds: {'a'}, history: [], + puzzleEvents: [], ); final moves = state.reachableMoves(); diff --git a/app/test/sequence_forge_puzzle_test.dart b/app/test/sequence_forge_puzzle_test.dart new file mode 100644 index 00000000..11c829bc --- /dev/null +++ b/app/test/sequence_forge_puzzle_test.dart @@ -0,0 +1,106 @@ +import 'package:dryad/features/home/puzzles/puzzle_framework.dart'; +import 'package:dryad/features/home/puzzles/sequence_forge_puzzle.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const generator = SequenceForgeGenerator(); + + const knobs = SequenceForgeDifficultyKnobs( + sequenceDepth: 7, + transformationLayers: 2, + hiddenSteps: 2, + operatorComplexity: 3, + answerChoices: 4, + misleadingSymmetry: 1, + ); + + test('deterministic generation from same node lat/lng seed', () { + const input = PuzzleSeedInput( + nodeId: 'node-a', + latitude: 30.2672, + longitude: -97.7431, + difficultyBand: 3, + ); + + final first = generator.generate(seedInput: input, knobs: knobs); + final second = generator.generate(seedInput: input, knobs: knobs); + + expect(first.seed, second.seed); + expect(first.data.fullSequence, second.data.fullSequence); + expect(first.data.hiddenIndices, second.data.hiddenIndices); + expect(first.data.choicesByHiddenIndex, second.data.choicesByHiddenIndex); + }); + + test('different nodes produce different sequences', () { + const a = PuzzleSeedInput(nodeId: 'a', latitude: 30.2672, longitude: -97.7431, difficultyBand: 2); + const b = PuzzleSeedInput(nodeId: 'b', latitude: 30.2682, longitude: -97.7421, difficultyBand: 2); + + final first = generator.generate(seedInput: a, knobs: knobs); + final second = generator.generate(seedInput: b, knobs: knobs); + + expect(first.data.fullSequence.join(','), isNot(second.data.fullSequence.join(','))); + }); + + test('difficulty score increases with knob complexity', () { + const easy = SequenceForgeDifficultyKnobs( + sequenceDepth: 5, + transformationLayers: 1, + hiddenSteps: 1, + operatorComplexity: 1, + answerChoices: 3, + misleadingSymmetry: 0, + ); + const hard = SequenceForgeDifficultyKnobs( + sequenceDepth: 9, + transformationLayers: 3, + hiddenSteps: 3, + operatorComplexity: 5, + answerChoices: 6, + misleadingSymmetry: 3, + ); + + final easyInstance = generator.generate( + seedInput: const PuzzleSeedInput(nodeId: 'n', latitude: 10, longitude: 10, difficultyBand: 1), + knobs: easy, + ); + final hardInstance = generator.generate( + seedInput: const PuzzleSeedInput(nodeId: 'n', latitude: 10, longitude: 10, difficultyBand: 1), + knobs: hard, + ); + + expect(hardInstance.difficulty.score, greaterThan(easyInstance.difficulty.score)); + }); + + test('hidden positions and choices are valid and deterministic', () { + final instance = generator.generate( + seedInput: const PuzzleSeedInput(nodeId: 'node-z', latitude: 1, longitude: 2, difficultyBand: 4), + knobs: knobs, + ); + + for (final hiddenIndex in instance.data.hiddenIndices) { + expect(hiddenIndex, inInclusiveRange(1, instance.data.fullSequence.length - 2)); + final choices = instance.data.choicesByHiddenIndex[hiddenIndex]!; + expect(choices.length, 4); + expect(choices.toSet().length, 4); + expect(choices, contains(instance.data.correctAnswers[hiddenIndex])); + } + }); + + test('submission validator rejects ambiguous/incorrect answer sets', () { + final instance = generator.generate( + seedInput: const PuzzleSeedInput(nodeId: 'node-k', latitude: 40, longitude: -74, difficultyBand: 5), + knobs: knobs, + ); + + expect( + validateSequenceForgeSubmission(data: instance.data, selectedAnswers: instance.data.correctAnswers), + isTrue, + ); + + final oneWrong = Map.from(instance.data.correctAnswers); + final firstIndex = instance.data.hiddenIndices.first; + oneWrong[firstIndex] = '${instance.data.correctAnswers[firstIndex]}x'; + + expect(validateSequenceForgeSubmission(data: instance.data, selectedAnswers: oneWrong), isFalse); + }); +}