diff --git a/app/lib/features/home/perbug_game_controller.dart b/app/lib/features/home/perbug_game_controller.dart index 61751472..41456d55 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/perbug_puzzle_framework.dart'; +import 'puzzles/perbug_symbol_match.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; + final SymbolMatchGenerator _symbolMatchGenerator = const SymbolMatchGenerator(); static const MapViewport _fixedGameplayViewport = MapViewport(centerLat: 30.2672, centerLng: -97.7431, zoom: 13); @@ -77,6 +80,66 @@ class PerbugGameController extends StateNotifier { return true; } + SymbolMatchPuzzleInstance buildSymbolMatchPuzzleForNode( + PerbugNode node, { + SymbolMatchDifficultyKnobs? knobs, + int salt = 0, + }) { + final generatedKnobs = knobs ?? defaultSymbolMatchKnobsForNode(node); + final seed = PuzzleSeedInput( + nodeId: node.id, + latitude: node.latitude, + longitude: node.longitude, + salt: salt, + ); + + final puzzle = _symbolMatchGenerator.generate(seedInput: seed, knobs: generatedKnobs); + final session = PuzzleSession( + puzzleId: puzzle.id, + puzzleType: puzzle.type, + nodeId: node.id, + startedAt: DateTime.now(), + currentRound: 0, + mistakes: 0, + retries: 0, + ); + + state = state.copyWith(activePuzzleSession: session); + return puzzle; + } + + void recordPuzzleEvent({ + required String type, + required String nodeId, + required Map payload, + }) { + state = state.copyWith( + puzzleEvents: [ + PerbugPuzzleEvent(type: type, timestamp: DateTime.now(), nodeId: nodeId, payload: payload), + ...state.puzzleEvents, + ], + ); + } + + void finalizePuzzleResult({ + required PerbugNode node, + required PuzzleResult result, + }) { + recordPuzzleEvent( + type: result.success ? 'puzzle_succeeded' : 'puzzle_failed', + nodeId: node.id, + payload: { + 'durationMs': result.duration.inMilliseconds, + 'mistakes': result.mistakes, + 'completedRounds': result.completedRounds, + 'totalRounds': result.totalRounds, + 'failureReason': result.failureReason, + }, + ); + + state = state.copyWith(clearActivePuzzleSession: true); + } + void claimPassiveEnergy() { state = state.copyWith( energy: (state.energy + 3).clamp(0, state.maxEnergy), diff --git a/app/lib/features/home/perbug_game_models.dart b/app/lib/features/home/perbug_game_models.dart index 696c6f46..85ecce34 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/perbug_puzzle_framework.dart'; +import 'puzzles/perbug_symbol_match.dart'; enum PerbugNodeState { available, completed, locked, exhausted, special, futureChallengeReady } @@ -66,6 +68,8 @@ class PerbugGameState { required this.loading, required this.visitedNodeIds, required this.history, + required this.puzzleEvents, + this.activePuzzleSession, this.error, }); @@ -78,6 +82,7 @@ class PerbugGameState { loading: false, visitedNodeIds: {}, history: [], + puzzleEvents: [], ); final List nodes; @@ -88,6 +93,8 @@ class PerbugGameState { final bool loading; final Set visitedNodeIds; final List history; + final List puzzleEvents; + final PuzzleSession? activePuzzleSession; final String? error; PerbugNode? get currentNode { @@ -109,6 +116,9 @@ class PerbugGameState { bool? loading, Set? visitedNodeIds, List? history, + List? puzzleEvents, + PuzzleSession? activePuzzleSession, + bool clearActivePuzzleSession = false, String? error, bool clearError = false, }) { @@ -121,6 +131,8 @@ class PerbugGameState { loading: loading ?? this.loading, visitedNodeIds: visitedNodeIds ?? this.visitedNodeIds, history: history ?? this.history, + puzzleEvents: puzzleEvents ?? this.puzzleEvents, + activePuzzleSession: clearActivePuzzleSession ? null : (activePuzzleSession ?? this.activePuzzleSession), error: clearError ? null : (error ?? this.error), ); } @@ -178,3 +190,32 @@ PerbugNodeState deriveNodeStateFromPin(MapPin pin) { if (pin.hasReviews) return PerbugNodeState.futureChallengeReady; return PerbugNodeState.available; } + +class PerbugPuzzleEvent { + const PerbugPuzzleEvent({ + required this.type, + required this.timestamp, + required this.nodeId, + required this.payload, + }); + + final String type; + final DateTime timestamp; + final String nodeId; + final Map payload; +} + +SymbolMatchDifficultyKnobs defaultSymbolMatchKnobsForNode(PerbugNode node) { + final absLat = node.latitude.abs(); + final absLng = node.longitude.abs(); + final densityRoll = ((absLat * 10 + absLng * 3).round()) % 4; + final timer = ((absLat + absLng).round()) % 5; + return SymbolMatchDifficultyKnobs( + symbolPoolSize: 12 + densityRoll * 2, + ruleComplexity: 2 + (densityRoll % 3), + decoyCount: 3 + (densityRoll % 2), + rounds: 3 + (timer % 2), + overlapSimilarity: 0.45 + (densityRoll * 0.12).clamp(0.0, 0.4), + timerPressure: timer, + ); +} diff --git a/app/lib/features/home/perbug_game_page.dart b/app/lib/features/home/perbug_game_page.dart index caa6b754..58262e21 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/perbug_puzzle_framework.dart'; +import 'puzzles/perbug_symbol_match_screen.dart'; class PerbugGamePage extends ConsumerStatefulWidget { const PerbugGamePage({super.key}); @@ -15,6 +17,40 @@ class PerbugGamePage extends ConsumerStatefulWidget { } class _PerbugGamePageState extends ConsumerState { + Future _openMovePuzzle({ + required BuildContext context, + required PerbugMoveCandidate move, + required PerbugGameController controller, + }) async { + final puzzle = controller.buildSymbolMatchPuzzleForNode(move.node); + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => SymbolMatchPuzzleSheet( + node: move.node, + puzzle: puzzle, + onEvent: (event, data) => controller.recordPuzzleEvent(type: event, nodeId: move.node.id, payload: data), + ), + ); + + if (!mounted || result == null) return; + controller.finalizePuzzleResult(node: move.node, result: result); + if (result.success) { + final ok = await controller.jumpTo(move); + if (!mounted) return; + if (ok) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Puzzle cleared. Jumped to ${move.node.label}.')), + ); + } + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(result.failureReason ?? 'Puzzle failed. Jump cancelled.')), + ); + } + @override void initState() { super.initState(); @@ -97,16 +133,10 @@ class _PerbugGamePageState extends ConsumerState { trailing: TextButton( onPressed: move.isReachable ? () async { - final ok = await controller.jumpTo(move); - if (!context.mounted) return; - if (ok) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Jumped to ${move.node.label}')), - ); - } + await _openMovePuzzle(context: context, move: move, controller: controller); } : null, - child: Text('Jump (${move.energyCost})'), + child: Text('Challenge (${move.energyCost})'), ), ), ) @@ -128,6 +158,18 @@ class _PerbugGamePageState extends ConsumerState { ), ], ), + _Section( + title: 'Puzzle telemetry (debug)', + subtitle: 'Lifecycle hooks and balancing data emitted from Symbol Match sessions.', + children: state.puzzleEvents.take(6).map((event) { + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text('${event.type} • ${event.nodeId}'), + subtitle: Text(event.timestamp.toIso8601String()), + ); + }).toList(growable: false), + ), ], ), ); 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..7f3e9c35 --- /dev/null +++ b/app/lib/features/home/puzzles/perbug_puzzle_framework.dart @@ -0,0 +1,184 @@ +import 'dart:math' as math; + +/// Shared puzzle type registry used by node challenges. +enum PuzzleType { gridPath, patternRecall, logicLocks, symbolMatch } + +class PuzzleSeedInput { + const PuzzleSeedInput({ + required this.nodeId, + required this.latitude, + required this.longitude, + this.salt = 0, + }); + + final String nodeId; + final double latitude; + final double longitude; + final int salt; + + /// Stable seed derived from node id + quantized lat/lng. + /// + /// We quantize coordinates at 1e6 precision so the same node always + /// resolves to the same integer seed value. + int toSeed() { + final lat = (latitude * 1000000).round(); + final lng = (longitude * 1000000).round(); + final nodeHash = _stableStringHash(nodeId); + var value = 146959810; + value = (value * 16777619) ^ lat; + value = (value * 16777619) ^ lng; + value = (value * 16777619) ^ nodeHash; + value = (value * 16777619) ^ salt; + return value & 0x7fffffff; + } +} + +int _stableStringHash(String input) { + var hash = 2166136261; + for (final codeUnit in input.codeUnits) { + hash ^= codeUnit; + hash *= 16777619; + } + return hash & 0x7fffffff; +} + +class PuzzleDifficulty { + const PuzzleDifficulty({ + required this.score, + required this.tier, + required this.explainers, + }); + + final double score; + final String tier; + final Map explainers; +} + +abstract class PuzzleInstance { + const PuzzleInstance({ + required this.id, + required this.type, + required this.seedInput, + required this.difficulty, + }); + + final String id; + final PuzzleType type; + final PuzzleSeedInput seedInput; + final PuzzleDifficulty difficulty; +} + +class PuzzleSession { + const PuzzleSession({ + required this.puzzleId, + required this.puzzleType, + required this.nodeId, + required this.startedAt, + required this.currentRound, + required this.mistakes, + required this.retries, + this.endedAt, + this.abandoned = false, + }); + + final String puzzleId; + final PuzzleType puzzleType; + final String nodeId; + final DateTime startedAt; + final DateTime? endedAt; + final int currentRound; + final int mistakes; + final int retries; + final bool abandoned; + + PuzzleSession copyWith({ + DateTime? endedAt, + int? currentRound, + int? mistakes, + int? retries, + bool? abandoned, + }) { + return PuzzleSession( + puzzleId: puzzleId, + puzzleType: puzzleType, + nodeId: nodeId, + startedAt: startedAt, + endedAt: endedAt ?? this.endedAt, + currentRound: currentRound ?? this.currentRound, + mistakes: mistakes ?? this.mistakes, + retries: retries ?? this.retries, + abandoned: abandoned ?? this.abandoned, + ); + } +} + +class PuzzleResult { + const PuzzleResult({ + required this.success, + required this.completedRounds, + required this.totalRounds, + required this.mistakes, + required this.startedAt, + required this.endedAt, + this.failureReason, + }); + + final bool success; + final int completedRounds; + final int totalRounds; + final int mistakes; + final DateTime startedAt; + final DateTime endedAt; + final String? failureReason; + + Duration get duration => endedAt.difference(startedAt); +} + +class DeterministicRng { + DeterministicRng(int seed) : _state = seed & 0x7fffffff; + + int _state; + + int nextInt(int max) { + if (max <= 0) return 0; + _state = (1103515245 * _state + 12345) & 0x7fffffff; + return _state % max; + } + + double nextDouble() { + _state = (1103515245 * _state + 12345) & 0x7fffffff; + return _state / 0x7fffffff; + } + + T pick(List values) => values[nextInt(values.length)]; + + List shuffled(List values) { + final mutable = [...values]; + for (var i = mutable.length - 1; i > 0; i--) { + final j = nextInt(i + 1); + final swap = mutable[i]; + mutable[i] = mutable[j]; + mutable[j] = swap; + } + return mutable; + } +} + +double normalizedScore(double value, double min, double max) { + if (max <= min) return 0; + return ((value - min) / (max - min)).clamp(0, 1); +} + +String tierFromScore(double score) { + if (score < 0.24) return 'Trivial'; + if (score < 0.45) return 'Easy'; + if (score < 0.65) return 'Moderate'; + if (score < 0.82) return 'Hard'; + return 'Brutal'; +} + +int deterministicIdFromSeed(PuzzleType type, int seed) { + return ((type.index + 1) * 100000000 + seed) & 0x7fffffff; +} + +int boundInt(int value, int min, int max) => math.max(min, math.min(max, value)); diff --git a/app/lib/features/home/puzzles/perbug_symbol_match.dart b/app/lib/features/home/puzzles/perbug_symbol_match.dart new file mode 100644 index 00000000..6e162bc8 --- /dev/null +++ b/app/lib/features/home/puzzles/perbug_symbol_match.dart @@ -0,0 +1,321 @@ +import 'perbug_puzzle_framework.dart'; + +enum SymbolShape { circle, triangle, square, diamond, hexagon, star } +enum SymbolColorFamily { ember, ocean, moss, dusk, sun, violet } +enum SymbolMark { dot, line, ring, cross } + +enum SymbolRuleKind { + sameShape, + sameColor, + sameMark, + sameShapeDifferentColor, + sameColorDifferentMark, + sharedOneTraitOnly, +} + +class SymbolMatchDifficultyKnobs { + const SymbolMatchDifficultyKnobs({ + this.symbolPoolSize = 14, + this.ruleComplexity = 2, + this.decoyCount = 3, + this.rounds = 3, + this.overlapSimilarity = 0.55, + this.timerPressure = 0, + }); + + final int symbolPoolSize; + final int ruleComplexity; + final int decoyCount; + final int rounds; + final double overlapSimilarity; + final int timerPressure; +} + +class PerbugSymbol { + const PerbugSymbol({ + required this.id, + required this.shape, + required this.color, + required this.mark, + required this.rotationQuarterTurns, + }); + + final String id; + final SymbolShape shape; + final SymbolColorFamily color; + final SymbolMark mark; + final int rotationQuarterTurns; + + int overlapScoreWith(PerbugSymbol other) { + var score = 0; + if (shape == other.shape) score += 2; + if (color == other.color) score += 2; + if (mark == other.mark) score += 2; + if (rotationQuarterTurns == other.rotationQuarterTurns) score += 1; + return score; + } +} + +class SymbolMatchRule { + const SymbolMatchRule({ + required this.kind, + required this.complexityWeight, + required this.hint, + }); + + final SymbolRuleKind kind; + final int complexityWeight; + final String hint; + + bool matches(PerbugSymbol anchor, PerbugSymbol candidate) { + switch (kind) { + case SymbolRuleKind.sameShape: + return anchor.shape == candidate.shape; + case SymbolRuleKind.sameColor: + return anchor.color == candidate.color; + case SymbolRuleKind.sameMark: + return anchor.mark == candidate.mark; + case SymbolRuleKind.sameShapeDifferentColor: + return anchor.shape == candidate.shape && anchor.color != candidate.color; + case SymbolRuleKind.sameColorDifferentMark: + return anchor.color == candidate.color && anchor.mark != candidate.mark; + case SymbolRuleKind.sharedOneTraitOnly: + final matches = [ + anchor.shape == candidate.shape, + anchor.color == candidate.color, + anchor.mark == candidate.mark, + ]; + return matches.where((it) => it).length == 1; + } + } + + String get debugDescription => kind.name; +} + +class SymbolMatchRound { + const SymbolMatchRound({ + required this.index, + required this.anchorSymbol, + required this.candidates, + required this.correctCandidateIndex, + required this.rule, + required this.partialHint, + required this.timerSeconds, + required this.decoyDebug, + }); + + final int index; + final PerbugSymbol anchorSymbol; + final List candidates; + final int correctCandidateIndex; + final SymbolMatchRule rule; + final String partialHint; + final int timerSeconds; + final List decoyDebug; +} + +class SymbolMatchPuzzleInstance extends PuzzleInstance { + const SymbolMatchPuzzleInstance({ + required super.id, + required super.seedInput, + required super.difficulty, + required this.knobs, + required this.symbolPool, + required this.rounds, + required this.debugMetadata, + }) : super(type: PuzzleType.symbolMatch); + + final SymbolMatchDifficultyKnobs knobs; + final List symbolPool; + final List rounds; + final Map debugMetadata; +} + +class SymbolMatchGenerator { + const SymbolMatchGenerator(); + + SymbolMatchPuzzleInstance generate({ + required PuzzleSeedInput seedInput, + required SymbolMatchDifficultyKnobs knobs, + }) { + final seed = seedInput.toSeed(); + final rng = DeterministicRng(seed); + final difficulty = _computeDifficulty(knobs); + final pool = _buildPool(rng, knobs.symbolPoolSize); + final rounds = []; + + for (var i = 0; i < knobs.rounds; i++) { + rounds.add(_buildRound(rng: rng, roundIndex: i, pool: pool, knobs: knobs)); + } + + return SymbolMatchPuzzleInstance( + id: 'SM-${deterministicIdFromSeed(PuzzleType.symbolMatch, seed)}', + seedInput: seedInput, + difficulty: difficulty, + knobs: knobs, + symbolPool: pool, + rounds: rounds, + debugMetadata: { + 'seed': seed, + 'symbolPoolSize': pool.length, + 'ruleKinds': rounds.map((r) => r.rule.debugDescription).toList(growable: false), + 'answers': rounds.map((r) => r.correctCandidateIndex).toList(growable: false), + }, + ); + } + + PuzzleDifficulty _computeDifficulty(SymbolMatchDifficultyKnobs knobs) { + final pool = normalizedScore(knobs.symbolPoolSize.toDouble(), 8, 24); + final complexity = normalizedScore(knobs.ruleComplexity.toDouble(), 1, 5); + final decoys = normalizedScore(knobs.decoyCount.toDouble(), 1, 5); + final rounds = normalizedScore(knobs.rounds.toDouble(), 1, 6); + final overlap = normalizedScore(knobs.overlapSimilarity, 0.1, 0.95); + final timer = normalizedScore(knobs.timerPressure.toDouble(), 0, 10); + + final explainers = { + 'symbolPoolSize': pool, + 'ruleComplexity': complexity, + 'decoyCount': decoys, + 'rounds': rounds, + 'overlapSimilarity': overlap, + 'timerPressure': timer, + }; + + final score = ( + pool * 0.18 + + complexity * 0.24 + + decoys * 0.16 + + rounds * 0.18 + + overlap * 0.14 + + timer * 0.10 + ).clamp(0.0, 1.0); + + return PuzzleDifficulty(score: score, tier: tierFromScore(score), explainers: explainers); + } + + List _buildPool(DeterministicRng rng, int size) { + final all = []; + for (final shape in SymbolShape.values) { + for (final color in SymbolColorFamily.values) { + for (final mark in SymbolMark.values) { + final rotationQuarterTurns = (shape.index + color.index + mark.index) % 4; + all.add( + PerbugSymbol( + id: '${shape.name}-${color.name}-${mark.name}-$rotationQuarterTurns', + shape: shape, + color: color, + mark: mark, + rotationQuarterTurns: rotationQuarterTurns, + ), + ); + } + } + } + + all.sort((a, b) => a.id.compareTo(b.id)); + final shuffled = rng.shuffled(all); + return shuffled.take(boundInt(size, 8, all.length)).toList(growable: false); + } + + SymbolMatchRound _buildRound({ + required DeterministicRng rng, + required int roundIndex, + required List pool, + required SymbolMatchDifficultyKnobs knobs, + }) { + final allowedRules = _allowedRules(knobs.ruleComplexity); + + for (var attempt = 0; attempt < 30; attempt++) { + final anchor = rng.pick(pool); + final rule = rng.pick(allowedRules); + final valid = pool.where((s) => s.id != anchor.id && rule.matches(anchor, s)).toList(growable: false); + if (valid.isEmpty) continue; + + final correct = rng.pick(valid); + final decoys = _pickDecoys(rng: rng, pool: pool, anchor: anchor, rule: rule, knobs: knobs); + if (decoys.length < knobs.decoyCount) continue; + + final candidates = [...decoys.take(knobs.decoyCount), correct]; + final shuffled = rng.shuffled(candidates); + final correctIndex = shuffled.indexWhere((s) => s.id == correct.id); + if (correctIndex < 0) continue; + if (!_isFairRound(anchor: anchor, candidates: shuffled, rule: rule)) continue; + + final hint = _hintFor(rule, knobs.ruleComplexity, rng); + final timerSeconds = knobs.timerPressure <= 0 + ? 0 + : boundInt(35 - knobs.timerPressure * 2 - roundIndex, 8, 30); + + return SymbolMatchRound( + index: roundIndex, + anchorSymbol: anchor, + candidates: shuffled, + correctCandidateIndex: correctIndex, + rule: rule, + partialHint: hint, + timerSeconds: timerSeconds, + decoyDebug: decoys.map((d) => 'decoy:${d.id}|overlap:${anchor.overlapScoreWith(d)}').toList(growable: false), + ); + } + + throw StateError('Could not generate a fair Symbol Match round.'); + } + + bool _isFairRound({ + required PerbugSymbol anchor, + required List candidates, + required SymbolMatchRule rule, + }) { + final matches = candidates.where((candidate) => rule.matches(anchor, candidate)).length; + return matches == 1; + } + + List _pickDecoys({ + required DeterministicRng rng, + required List pool, + required PerbugSymbol anchor, + required SymbolMatchRule rule, + required SymbolMatchDifficultyKnobs knobs, + }) { + final nonMatches = pool.where((symbol) => symbol.id != anchor.id && !rule.matches(anchor, symbol)).toList(); + nonMatches.sort((a, b) { + final aScore = anchor.overlapScoreWith(a); + final bScore = anchor.overlapScoreWith(b); + return bScore.compareTo(aScore); + }); + + final topSpan = boundInt((nonMatches.length * knobs.overlapSimilarity).round(), knobs.decoyCount, nonMatches.length); + final preferred = nonMatches.take(topSpan).toList(growable: false); + return rng.shuffled(preferred); + } + + List _allowedRules(int complexity) { + final clamped = boundInt(complexity, 1, 5); + final base = [ + const SymbolMatchRule(kind: SymbolRuleKind.sameShape, complexityWeight: 1, hint: 'shape family'), + const SymbolMatchRule(kind: SymbolRuleKind.sameColor, complexityWeight: 1, hint: 'color family'), + const SymbolMatchRule(kind: SymbolRuleKind.sameMark, complexityWeight: 1, hint: 'inner mark style'), + const SymbolMatchRule(kind: SymbolRuleKind.sameShapeDifferentColor, complexityWeight: 2, hint: 'same shell, different hue'), + const SymbolMatchRule(kind: SymbolRuleKind.sameColorDifferentMark, complexityWeight: 3, hint: 'same hue, mark shifts'), + const SymbolMatchRule(kind: SymbolRuleKind.sharedOneTraitOnly, complexityWeight: 4, hint: 'exactly one trait is shared'), + ]; + return base.where((rule) => rule.complexityWeight <= clamped).toList(growable: false); + } + + String _hintFor(SymbolMatchRule rule, int complexity, DeterministicRng rng) { + if (complexity <= 1) { + return 'Hint: match by ${rule.hint}'; + } + if (complexity <= 3) { + return rng.nextInt(2) == 0 ? 'Hint: focus on outer silhouette' : 'Hint: not all traits need to match'; + } + return 'Hidden relation active. Find the one valid counterpart.'; + } +} + +bool validateSymbolMatchAnswer({ + required SymbolMatchRound round, + required int selectedIndex, +}) { + return selectedIndex == round.correctCandidateIndex; +} diff --git a/app/lib/features/home/puzzles/perbug_symbol_match_screen.dart b/app/lib/features/home/puzzles/perbug_symbol_match_screen.dart new file mode 100644 index 00000000..0ff1d6e3 --- /dev/null +++ b/app/lib/features/home/puzzles/perbug_symbol_match_screen.dart @@ -0,0 +1,381 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../../app/theme/widgets.dart'; +import '../perbug_game_models.dart'; +import 'perbug_puzzle_framework.dart'; +import 'perbug_symbol_match.dart'; + +class SymbolMatchPuzzleSheet extends StatefulWidget { + const SymbolMatchPuzzleSheet({ + required this.node, + required this.puzzle, + required this.onEvent, + super.key, + }); + + final PerbugNode node; + final SymbolMatchPuzzleInstance puzzle; + final void Function(String event, Map data) onEvent; + + @override + State createState() => _SymbolMatchPuzzleSheetState(); +} + +class _SymbolMatchPuzzleSheetState extends State { + late final DateTime _startedAt; + int _roundIndex = 0; + int _mistakes = 0; + int? _selected; + bool _showResult = false; + bool _resultIsCorrect = false; + int _remainingSeconds = 0; + Timer? _timer; + bool _started = false; + + SymbolMatchRound get _currentRound => widget.puzzle.rounds[_roundIndex]; + + @override + void initState() { + super.initState(); + _startedAt = DateTime.now(); + widget.onEvent('puzzle_generated', { + 'nodeId': widget.node.id, + 'puzzleId': widget.puzzle.id, + 'difficulty': widget.puzzle.difficulty.score, + 'knobs': widget.puzzle.difficulty.explainers, + 'seed': widget.puzzle.seedInput.toSeed(), + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + void _startPuzzle() { + setState(() => _started = true); + widget.onEvent('puzzle_started', {'puzzleId': widget.puzzle.id, 'nodeId': widget.node.id}); + _startRoundTimer(); + } + + void _startRoundTimer() { + _timer?.cancel(); + final seconds = _currentRound.timerSeconds; + if (seconds <= 0) { + _remainingSeconds = 0; + widget.onEvent('round_started', {'round': _roundIndex, 'timed': false}); + return; + } + + _remainingSeconds = seconds; + widget.onEvent('round_started', {'round': _roundIndex, 'timed': true, 'seconds': seconds}); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) return; + setState(() => _remainingSeconds -= 1); + if (_remainingSeconds <= 0) { + timer.cancel(); + _finishPuzzle(success: false, reason: 'Time expired on round ${_roundIndex + 1}'); + } + }); + } + + void _submitSelection() { + final selected = _selected; + if (selected == null) return; + final correct = validateSymbolMatchAnswer(round: _currentRound, selectedIndex: selected); + + setState(() { + _showResult = true; + _resultIsCorrect = correct; + if (!correct) _mistakes += 1; + }); + + widget.onEvent('round_completed', { + 'round': _roundIndex, + 'correct': correct, + 'mistakes': _mistakes, + 'selectedIndex': selected, + 'correctIndex': _currentRound.correctCandidateIndex, + }); + } + + void _nextRound() { + if (!_resultIsCorrect) { + _finishPuzzle(success: false, reason: 'Incorrect match on round ${_roundIndex + 1}'); + return; + } + + if (_roundIndex >= widget.puzzle.rounds.length - 1) { + _finishPuzzle(success: true); + return; + } + + setState(() { + _roundIndex += 1; + _selected = null; + _showResult = false; + _resultIsCorrect = false; + }); + _startRoundTimer(); + } + + void _finishPuzzle({required bool success, String? reason}) { + _timer?.cancel(); + final result = PuzzleResult( + success: success, + completedRounds: success ? widget.puzzle.rounds.length : _roundIndex, + totalRounds: widget.puzzle.rounds.length, + mistakes: _mistakes, + startedAt: _startedAt, + endedAt: DateTime.now(), + failureReason: reason, + ); + + widget.onEvent(success ? 'puzzle_succeeded' : 'puzzle_failed', { + 'nodeId': widget.node.id, + 'puzzleId': widget.puzzle.id, + 'completedRounds': result.completedRounds, + 'mistakes': _mistakes, + 'durationMs': result.duration.inMilliseconds, + 'failureReason': reason, + }); + + Navigator.of(context).pop(result); + } + + @override + Widget build(BuildContext context) { + if (!_started) { + return _buildEntry(context); + } + + final round = _currentRound; + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Perbug Symbol Match', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 6), + Text('Node: ${widget.node.label} • Round ${_roundIndex + 1}/${widget.puzzle.rounds.length}'), + if (round.timerSeconds > 0) ...[ + const SizedBox(height: 6), + Text('Timer: ${_remainingSeconds}s', style: TextStyle(color: _remainingSeconds < 6 ? Colors.redAccent : null)), + ], + const SizedBox(height: 10), + Text(round.partialHint), + const SizedBox(height: 12), + Center(child: _SymbolChip(symbol: round.anchorSymbol, size: 72, label: 'Anchor')), + const SizedBox(height: 12), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: round.candidates.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2, childAspectRatio: 1.15, crossAxisSpacing: 8, mainAxisSpacing: 8), + itemBuilder: (_, index) { + final selected = index == _selected; + return InkWell( + onTap: _showResult ? null : () => setState(() => _selected = index), + borderRadius: BorderRadius.circular(12), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: selected ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.outlineVariant, + width: selected ? 2 : 1, + ), + ), + child: Center(child: _SymbolChip(symbol: round.candidates[index], size: 54)), + ), + ); + }, + ), + const SizedBox(height: 12), + if (_showResult) + Text( + _resultIsCorrect ? 'Correct! Proceed to next round.' : 'That match is invalid for this hidden rule.', + style: TextStyle(color: _resultIsCorrect ? Colors.greenAccent : Colors.orangeAccent), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: SecondaryButton(label: 'Abandon', onPressed: () => _finishPuzzle(success: false, reason: 'Abandoned by player')), + ), + const SizedBox(width: 8), + Expanded( + child: PrimaryButton( + label: _showResult ? 'Continue' : 'Submit', + onPressed: _showResult ? _nextRound : (_selected == null ? null : _submitSelection), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildEntry(BuildContext context) { + final score = (widget.puzzle.difficulty.score * 100).toStringAsFixed(0); + final knobs = widget.puzzle.knobs; + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Perbug Symbol Match', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + Text('Generated from node seed (${widget.node.latitude.toStringAsFixed(4)}, ${widget.node.longitude.toStringAsFixed(4)}).'), + const SizedBox(height: 10), + AppCard( + tone: AppCardTone.featured, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Difficulty: ${widget.puzzle.difficulty.tier} ($score/100)'), + const SizedBox(height: 4), + Text('Pool ${knobs.symbolPoolSize} • Rule ${knobs.ruleComplexity} • Decoys ${knobs.decoyCount} • Rounds ${knobs.rounds}'), + Text('Overlap ${(knobs.overlapSimilarity * 100).toStringAsFixed(0)}% • Timer pressure ${knobs.timerPressure}'), + ], + ), + ), + const SizedBox(height: 10), + Text('Find the single valid counterpart for each anchor symbol using hidden relation rules.'), + const SizedBox(height: 12), + Row( + children: [ + Expanded(child: SecondaryButton(label: 'Cancel', onPressed: () => Navigator.of(context).pop())), + const SizedBox(width: 8), + Expanded(child: PrimaryButton(label: 'Start Puzzle', onPressed: _startPuzzle)), + ], + ), + ], + ), + ); + } +} + +class _SymbolChip extends StatelessWidget { + const _SymbolChip({required this.symbol, required this.size, this.label}); + + final PerbugSymbol symbol; + final double size; + final String? label; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: size, + height: size, + child: CustomPaint(painter: _SymbolPainter(symbol: symbol)), + ), + if (label != null) ...[ + const SizedBox(height: 4), + Text(label!), + ], + ], + ); + } +} + +class _SymbolPainter extends CustomPainter { + const _SymbolPainter({required this.symbol}); + + final PerbugSymbol symbol; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = math.min(size.width, size.height) * 0.34; + final fill = Paint()..color = _color(symbol.color); + final stroke = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..color = Colors.white; + + final path = _shapePath(center, radius, symbol.shape, symbol.rotationQuarterTurns * math.pi / 2); + canvas.drawPath(path, fill); + canvas.drawPath(path, stroke); + _drawMark(canvas, center, radius, symbol.mark); + } + + Path _shapePath(Offset center, double radius, SymbolShape shape, double angle) { + switch (shape) { + case SymbolShape.circle: + return Path()..addOval(Rect.fromCircle(center: center, radius: radius)); + default: + final points = switch (shape) { + SymbolShape.triangle => 3, + SymbolShape.square => 4, + SymbolShape.diamond => 4, + SymbolShape.hexagon => 6, + SymbolShape.star => 10, + SymbolShape.circle => 0, + }; + final isStar = shape == SymbolShape.star; + final path = Path(); + for (var i = 0; i < points; i++) { + final ratio = isStar && i.isOdd ? 0.45 : 1.0; + final theta = angle + (2 * math.pi * i / points) - math.pi / 2; + final point = Offset(center.dx + math.cos(theta) * radius * ratio, center.dy + math.sin(theta) * radius * ratio); + if (i == 0) { + path.moveTo(point.dx, point.dy); + } else { + path.lineTo(point.dx, point.dy); + } + } + path.close(); + return path; + } + } + + void _drawMark(Canvas canvas, Offset center, double radius, SymbolMark mark) { + final p = Paint() + ..color = Colors.white + ..strokeWidth = 2 + ..style = PaintingStyle.stroke; + + switch (mark) { + case SymbolMark.dot: + canvas.drawCircle(center, radius * 0.16, Paint()..color = Colors.white); + case SymbolMark.line: + canvas.drawLine(Offset(center.dx - radius * 0.4, center.dy), Offset(center.dx + radius * 0.4, center.dy), p); + case SymbolMark.ring: + canvas.drawCircle(center, radius * 0.3, p); + case SymbolMark.cross: + canvas.drawLine(Offset(center.dx - radius * 0.3, center.dy - radius * 0.3), Offset(center.dx + radius * 0.3, center.dy + radius * 0.3), p); + canvas.drawLine(Offset(center.dx + radius * 0.3, center.dy - radius * 0.3), Offset(center.dx - radius * 0.3, center.dy + radius * 0.3), p); + } + } + + Color _color(SymbolColorFamily family) { + switch (family) { + case SymbolColorFamily.ember: + return const Color(0xFFE76F51); + case SymbolColorFamily.ocean: + return const Color(0xFF4EA8DE); + case SymbolColorFamily.moss: + return const Color(0xFF2A9D8F); + case SymbolColorFamily.dusk: + return const Color(0xFF6C5CE7); + case SymbolColorFamily.sun: + return const Color(0xFFF4A261); + case SymbolColorFamily.violet: + return const Color(0xFFB5179E); + } + } + + @override + bool shouldRepaint(covariant _SymbolPainter oldDelegate) => oldDelegate.symbol != symbol; +} 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/perbug_symbol_match_test.dart b/app/test/perbug_symbol_match_test.dart new file mode 100644 index 00000000..2f176264 --- /dev/null +++ b/app/test/perbug_symbol_match_test.dart @@ -0,0 +1,76 @@ +import 'package:dryad/features/home/puzzles/perbug_puzzle_framework.dart'; +import 'package:dryad/features/home/puzzles/perbug_symbol_match.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const generator = SymbolMatchGenerator(); + + test('symbol match generation is deterministic for same node lat/lng seed', () { + const seedInput = PuzzleSeedInput(nodeId: 'node-1', latitude: 30.2672, longitude: -97.7431); + const knobs = SymbolMatchDifficultyKnobs(symbolPoolSize: 14, ruleComplexity: 3, decoyCount: 3, rounds: 4, overlapSimilarity: 0.7, timerPressure: 3); + + final first = generator.generate(seedInput: seedInput, knobs: knobs); + final second = generator.generate(seedInput: seedInput, knobs: knobs); + + expect(first.id, second.id); + expect(first.symbolPool.map((s) => s.id).toList(), second.symbolPool.map((s) => s.id).toList()); + expect( + first.rounds.map((r) => '${r.anchorSymbol.id}|${r.correctCandidateIndex}|${r.rule.debugDescription}').toList(), + second.rounds.map((r) => '${r.anchorSymbol.id}|${r.correctCandidateIndex}|${r.rule.debugDescription}').toList(), + ); + }); + + test('different coordinates produce different puzzle ids', () { + const knobs = SymbolMatchDifficultyKnobs(); + final a = generator.generate( + seedInput: const PuzzleSeedInput(nodeId: 'same', latitude: 30.2672, longitude: -97.7431), + knobs: knobs, + ); + final b = generator.generate( + seedInput: const PuzzleSeedInput(nodeId: 'same', latitude: 30.2673, longitude: -97.7431), + knobs: knobs, + ); + + expect(a.id, isNot(equals(b.id))); + }); + + test('difficulty score increases with harder knobs', () { + final easier = generator.generate( + seedInput: const PuzzleSeedInput(nodeId: 'n', latitude: 1, longitude: 2), + knobs: const SymbolMatchDifficultyKnobs(symbolPoolSize: 8, ruleComplexity: 1, decoyCount: 1, rounds: 1, overlapSimilarity: 0.1, timerPressure: 0), + ); + final harder = generator.generate( + seedInput: const PuzzleSeedInput(nodeId: 'n', latitude: 1, longitude: 2), + knobs: const SymbolMatchDifficultyKnobs(symbolPoolSize: 20, ruleComplexity: 5, decoyCount: 5, rounds: 6, overlapSimilarity: 0.9, timerPressure: 8), + ); + + expect(harder.difficulty.score, greaterThan(easier.difficulty.score)); + }); + + test('every generated round has exactly one valid answer', () { + final puzzle = generator.generate( + seedInput: const PuzzleSeedInput(nodeId: 'fair', latitude: 40.7, longitude: -74), + knobs: const SymbolMatchDifficultyKnobs(ruleComplexity: 4, decoyCount: 4, rounds: 5, overlapSimilarity: 0.85), + ); + + for (final round in puzzle.rounds) { + final matches = round.candidates.where((candidate) => round.rule.matches(round.anchorSymbol, candidate)).length; + expect(matches, 1, reason: 'Round ${round.index} became ambiguous'); + expect(validateSymbolMatchAnswer(round: round, selectedIndex: round.correctCandidateIndex), isTrue); + } + }); + + test('timer pressure knob can produce timed rounds', () { + final untimed = generator.generate( + seedInput: const PuzzleSeedInput(nodeId: 'timer', latitude: 30, longitude: 31), + knobs: const SymbolMatchDifficultyKnobs(timerPressure: 0), + ); + final timed = generator.generate( + seedInput: const PuzzleSeedInput(nodeId: 'timer', latitude: 30, longitude: 31), + knobs: const SymbolMatchDifficultyKnobs(timerPressure: 7), + ); + + expect(untimed.rounds.every((r) => r.timerSeconds == 0), isTrue); + expect(timed.rounds.any((r) => r.timerSeconds > 0), isTrue); + }); +}