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
155 changes: 155 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/logic_locks_puzzle.dart';
import 'puzzles/puzzle_framework.dart';

final perbugGameControllerProvider = StateNotifierProvider<PerbugGameController, PerbugGameState>((ref) {
return PerbugGameController(ref);
Expand All @@ -14,6 +16,8 @@ class PerbugGameController extends StateNotifier<PerbugGameState> {
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);

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

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<LogicLocksPuzzleData, LogicLocksPlayerState>(
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<LogicLocksPuzzleData> instance, {Map<String, Object?> 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,
Expand Down
33 changes: 33 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/logic_locks_puzzle.dart';
import 'puzzles/puzzle_framework.dart';

enum PerbugNodeState { available, completed, locked, exhausted, special, futureChallengeReady }

Expand Down Expand Up @@ -42,6 +44,28 @@ class PerbugNode {
}
}



class PerbugPuzzleSessionData {
const PerbugPuzzleSessionData({
required this.nodeId,
required this.logicLocksSession,
});

final String nodeId;
final PuzzleSession<LogicLocksPuzzleData, LogicLocksPlayerState> logicLocksSession;

PerbugPuzzleSessionData copyWith({
String? nodeId,
PuzzleSession<LogicLocksPuzzleData, LogicLocksPlayerState>? logicLocksSession,
}) {
return PerbugPuzzleSessionData(
nodeId: nodeId ?? this.nodeId,
logicLocksSession: logicLocksSession ?? this.logicLocksSession,
);
}
}

class PerbugMoveCandidate {
const PerbugMoveCandidate({
required this.node,
Expand All @@ -67,6 +91,8 @@ class PerbugGameState {
required this.visitedNodeIds,
required this.history,
this.error,
this.activePuzzleSession,
this.puzzleHistory = const [],
});

factory PerbugGameState.initial() => const PerbugGameState(
Expand All @@ -89,6 +115,8 @@ class PerbugGameState {
final Set<String> visitedNodeIds;
final List<String> history;
final String? error;
final PerbugPuzzleSessionData? activePuzzleSession;
final List<PuzzleAnalyticsEvent> puzzleHistory;

PerbugNode? get currentNode {
final id = currentNodeId;
Expand All @@ -111,6 +139,9 @@ class PerbugGameState {
List<String>? history,
String? error,
bool clearError = false,
PerbugPuzzleSessionData? activePuzzleSession,
bool clearPuzzleSession = false,
List<PuzzleAnalyticsEvent>? puzzleHistory,
}) {
return PerbugGameState(
nodes: nodes ?? this.nodes,
Expand All @@ -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,
);
}

Expand Down
Loading