Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions app/lib/features/home/perbug_game_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,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<PerbugGameController, PerbugGameState>((ref) {
return PerbugGameController(ref);
Expand All @@ -14,6 +16,7 @@ class PerbugGameController extends StateNotifier<PerbugGameState> {
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);

Expand Down Expand Up @@ -73,6 +76,7 @@ class PerbugGameController extends StateNotifier<PerbugGameState> {
'Jumped ${_formatDistance(move.node.distanceFromCurrentMeters ?? 0)} to ${move.node.label} (-$spend, +$gained energy)',
...state.history,
],
clearActiveSequenceForgeSession: true,
);
return true;
}
Expand All @@ -84,6 +88,120 @@ class PerbugGameController extends StateNotifier<PerbugGameState> {
);
}

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<SequenceForgePuzzleData>(
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,
Expand Down
19 changes: 19 additions & 0 deletions app/lib/features/home/perbug_game_models.dart
Original file line number Diff line number Diff line change
@@ -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 }

Expand Down Expand Up @@ -66,6 +68,9 @@ class PerbugGameState {
required this.loading,
required this.visitedNodeIds,
required this.history,
required this.puzzleEvents,
this.activeSequenceForgeSession,
this.lastPuzzleResult,
this.error,
});

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

final List<PerbugNode> nodes;
Expand All @@ -88,6 +94,9 @@ class PerbugGameState {
final bool loading;
final Set<String> visitedNodeIds;
final List<String> history;
final List<String> puzzleEvents;
final PuzzleSession<SequenceForgePuzzleData>? activeSequenceForgeSession;
final PuzzleResult? lastPuzzleResult;
final String? error;

PerbugNode? get currentNode {
Expand All @@ -109,6 +118,11 @@ class PerbugGameState {
bool? loading,
Set<String>? visitedNodeIds,
List<String>? history,
List<String>? puzzleEvents,
PuzzleSession<SequenceForgePuzzleData>? activeSequenceForgeSession,
bool clearActiveSequenceForgeSession = false,
PuzzleResult? lastPuzzleResult,
bool clearLastPuzzleResult = false,
String? error,
bool clearError = false,
}) {
Expand All @@ -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),
);
}
Expand Down
114 changes: 99 additions & 15 deletions app/lib/features/home/perbug_game_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down Expand Up @@ -112,24 +114,106 @@ class _PerbugGamePageState extends ConsumerState<PerbugGamePage> {
)
.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 <String>[];
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}',
),
),
],
),
],
);
}
}
Expand Down
Loading