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:
2026-06-12 13:41:59 +09:00
parent 074a21ea2b
commit 9763968db9
4 changed files with 67 additions and 22 deletions
+1
View File
@@ -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)
+15
View File
@@ -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,
); );
} }
} }
+14
View File
@@ -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),
+15
View File
@@ -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