fix: hide spent rescue to prevent StateError crash; log per-attempt starts
Expose engine.rescueUsed getter and surface it through GameViewState so the result overlay can omit the watch-ad FilledButton after a rescue has been consumed, preventing a second tap from calling useContinue / addExtraMoves and hitting their StateError guard. Give-up is promoted to FilledButton when rescue is unavailable for clear affordance. Also emit stageStart / endlessStart analytics in restart() so every attempt (not just the first) is bracketed by a matching start event. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,7 @@ class GameEngine {
|
|||||||
List<Objective> get objectives => List.unmodifiable(_objectives);
|
List<Objective> get objectives => List.unmodifiable(_objectives);
|
||||||
GamePhase get phase => _phase;
|
GamePhase get phase => _phase;
|
||||||
StuckReason? get stuckReason => _stuckReason;
|
StuckReason? get stuckReason => _stuckReason;
|
||||||
|
bool get rescueUsed => _rescueUsed;
|
||||||
|
|
||||||
int get starsEarned => _phase == GamePhase.won
|
int get starsEarned => _phase == GamePhase.won
|
||||||
? _stage.stars.starsFor(movesLeft: movesLeft)
|
? _stage.stars.starsFor(movesLeft: movesLeft)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../game/models/grid.dart';
|
|||||||
import '../game/models/objective.dart';
|
import '../game/models/objective.dart';
|
||||||
import '../game/models/piece.dart';
|
import '../game/models/piece.dart';
|
||||||
import '../game/models/stage.dart';
|
import '../game/models/stage.dart';
|
||||||
|
import 'providers.dart';
|
||||||
|
|
||||||
/// Immutable snapshot of one engine moment; the only game state the UI sees.
|
/// Immutable snapshot of one engine moment; the only game state the UI sees.
|
||||||
class GameViewState {
|
class GameViewState {
|
||||||
@@ -24,6 +25,7 @@ class GameViewState {
|
|||||||
required this.lastPlacement,
|
required this.lastPlacement,
|
||||||
required this.fxTick,
|
required this.fxTick,
|
||||||
required this.endless,
|
required this.endless,
|
||||||
|
required this.rescueUsed,
|
||||||
});
|
});
|
||||||
|
|
||||||
final GridState grid;
|
final GridState grid;
|
||||||
@@ -43,6 +45,7 @@ class GameViewState {
|
|||||||
final int fxTick;
|
final int fxTick;
|
||||||
|
|
||||||
final bool endless;
|
final bool endless;
|
||||||
|
final bool rescueUsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bridges the pure-Dart [GameEngine] to the widget tree. The engine object
|
/// Bridges the pure-Dart [GameEngine] to the widget tree. The engine object
|
||||||
@@ -72,6 +75,17 @@ class GameSessionNotifier extends Notifier<GameViewState?> {
|
|||||||
final stage = _stage;
|
final stage = _stage;
|
||||||
if (stage == null) throw StateError('no stage to restart');
|
if (stage == null) throw StateError('no stage to restart');
|
||||||
startStage(stage, attempt: _attempt + 1, generator: _generatorOverride);
|
startStage(stage, attempt: _attempt + 1, generator: _generatorOverride);
|
||||||
|
if (stage.endless) {
|
||||||
|
ref.read(analyticsProvider).endlessStart();
|
||||||
|
} else {
|
||||||
|
final flow = ref.read(seasonFlowProvider);
|
||||||
|
if (flow != null) {
|
||||||
|
ref.read(analyticsProvider).stageStart(
|
||||||
|
seasonId: flow.pack.seasonId,
|
||||||
|
stageId: flow.stage.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool tryPlace(int trayIndex, int x, int y) {
|
bool tryPlace(int trayIndex, int x, int y) {
|
||||||
@@ -119,6 +133,7 @@ class GameSessionNotifier extends Notifier<GameViewState?> {
|
|||||||
objectiveProgress: engine.objectiveProgress,
|
objectiveProgress: engine.objectiveProgress,
|
||||||
lastPlacement: lastPlacement,
|
lastPlacement: lastPlacement,
|
||||||
fxTick: _fxTick,
|
fxTick: _fxTick,
|
||||||
|
rescueUsed: engine.rescueUsed,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -391,6 +391,7 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
|||||||
(GamePhase.stuck, StuckReason.outOfMoves) => (
|
(GamePhase.stuck, StuckReason.outOfMoves) => (
|
||||||
l10n.outOfMoves,
|
l10n.outOfMoves,
|
||||||
[
|
[
|
||||||
|
if (!view.rescueUsed)
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ref.read(analyticsProvider).rescueUsed(type: 'extra_moves');
|
ref.read(analyticsProvider).rescueUsed(type: 'extra_moves');
|
||||||
@@ -398,6 +399,12 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
|||||||
},
|
},
|
||||||
child: Text(l10n.plusFiveMoves),
|
child: Text(l10n.plusFiveMoves),
|
||||||
),
|
),
|
||||||
|
if (view.rescueUsed)
|
||||||
|
FilledButton(
|
||||||
|
onPressed: notifier.declineAndLose,
|
||||||
|
child: Text(l10n.giveUp),
|
||||||
|
)
|
||||||
|
else
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: notifier.declineAndLose,
|
onPressed: notifier.declineAndLose,
|
||||||
child: Text(l10n.giveUp),
|
child: Text(l10n.giveUp),
|
||||||
@@ -407,6 +414,7 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
|||||||
(GamePhase.stuck, _) => (
|
(GamePhase.stuck, _) => (
|
||||||
l10n.boardFull,
|
l10n.boardFull,
|
||||||
[
|
[
|
||||||
|
if (!view.rescueUsed)
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ref.read(analyticsProvider).rescueUsed(type: 'continue');
|
ref.read(analyticsProvider).rescueUsed(type: 'continue');
|
||||||
@@ -414,6 +422,12 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
|||||||
},
|
},
|
||||||
child: Text(l10n.watchAdContinue),
|
child: Text(l10n.watchAdContinue),
|
||||||
),
|
),
|
||||||
|
if (view.rescueUsed)
|
||||||
|
FilledButton(
|
||||||
|
onPressed: notifier.declineAndLose,
|
||||||
|
child: Text(l10n.giveUp),
|
||||||
|
)
|
||||||
|
else
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: notifier.declineAndLose,
|
onPressed: notifier.declineAndLose,
|
||||||
child: Text(l10n.giveUp),
|
child: Text(l10n.giveUp),
|
||||||
|
|||||||
@@ -193,6 +193,21 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('rescueUsed getter', () {
|
||||||
|
test('rescueUsed flag is exposed and flips after a rescue', () {
|
||||||
|
final engine = GameEngine(
|
||||||
|
_stage(moveLimit: 1),
|
||||||
|
generator: _smallPool(1),
|
||||||
|
);
|
||||||
|
expect(engine.rescueUsed, isFalse);
|
||||||
|
engine.tryPlace(0, 0, 0);
|
||||||
|
expect(engine.phase, GamePhase.stuck);
|
||||||
|
expect(engine.rescueUsed, isFalse);
|
||||||
|
engine.addExtraMoves();
|
||||||
|
expect(engine.rescueUsed, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('dead board and continue', () {
|
group('dead board and continue', () {
|
||||||
StageConfig deadStage() {
|
StageConfig deadStage() {
|
||||||
// Checkerboard: only monos fit, and the injected pool has none small
|
// Checkerboard: only monos fit, and the injected pool has none small
|
||||||
|
|||||||
Reference in New Issue
Block a user