roadsman 0.1.2 copy "roadsman: ^0.1.2" to clipboard
roadsman: ^0.1.2 copied to clipboard

Pure-Dart road-map (bead plate) engine + Flutter render panel for baccarat/dragon-tiger/sic-bo.

example/lib/main.dart

import 'dart:convert';

import 'package:flutter/material.dart' hide Theme;
import 'package:flutter/services.dart' show rootBundle;
import 'package:roadsman/roadsman.dart';

void main() {
  runApp(const RoadmapDemoApp());
}

/// A Flutter demo for the roadsman package, matching the feature set of
/// `example/main.ts` in casino/apps/baccarat-roadmap: switching game type, toggling
/// which roads are shown, manually appending a round, replay, "what if" prediction,
/// and UX toggles. It doesn't aim to pixel-match the web layout, only functional parity.
class RoadmapDemoApp extends StatelessWidget {
  const RoadmapDemoApp({super.key});

  @override
  Widget build(BuildContext context) => MaterialApp(
    title: 'roadsman demo',
    theme: ThemeData.dark(useMaterial3: true),
    home: const RoadmapDemoPage(),
  );
}

const _allRoadIds = [
  'beadPlate',
  'bigRoad',
  'bigEyeBoy',
  'smallRoad',
  'cockroachRoad',
  'pairRoad',
  'naturalRoad',
  'derivedTrio',
  'compactRoadSheet',
  'statsPanel',
];

const _roadLabels = {
  'beadPlate': 'Bead Plate',
  'bigRoad': 'Big Road',
  'bigEyeBoy': 'Big Eye Boy',
  'smallRoad': 'Small Road',
  'cockroachRoad': 'Cockroach Road',
  'pairRoad': 'Pair Road',
  'naturalRoad': 'Natural Road',
  'derivedTrio': 'Derived Trio',
  'compactRoadSheet': 'Compact Road Sheet',
  'statsPanel': 'Stats Panel',
};

/// Roads exclusive to baccarat (hidden when switching to other game types, matching
/// what example/main.ts does).
const _baccaratOnlyRoads = {'pairRoad', 'naturalRoad'};

/// Roads available for roulette: the derived-road plugins (big road/big eye boy etc.)
/// currently only understand baccarat's B/P semantics, so they're meaningless against
/// roulette's 37-number outcomes -- only the bead plate is enabled.
const _rouletteRoads = {'beadPlate'};

class RoadmapDemoPage extends StatefulWidget {
  const RoadmapDemoPage({super.key});

  @override
  State<RoadmapDemoPage> createState() => _RoadmapDemoPageState();
}

class _RoadmapDemoPageState extends State<RoadmapDemoPage> {
  List<Shoe> _shoes = [];
  bool _loading = true;

  GameSpec _currentSpec = baccaratSpec;
  String _currentShoeId = '';
  final Set<String> _enabledRoads = {
    'beadPlate',
    'bigRoad',
    'bigEyeBoy',
    'smallRoad',
    'cockroachRoad',
    'pairRoad',
    'naturalRoad',
    'statsPanel',
  };

  late RoadmapStore _store;
  Replayer? _replayer;
  List<RawResult> _replayScript = [];
  Engine? _engine;
  ComputeOutput? _output;

  /// "What if" prediction result: assuming the next round is a banker or player win,
  /// whether each of the three derived roads would gain an extra cell and what color it
  /// would be. This is a statistical prediction independent of the engine's `predict()`
  /// mechanism (`predictNextOutcome` in `core/predict.dart`) -- the big eye boy/small
  /// road/cockroach road plugins themselves don't implement `predict()` (matching the TS
  /// version, where "what if" prediction is a separate system outside the engine), so it
  /// can't be read from `ComputeOutput.predictions` and has to be computed separately.
  PredictResult? _prediction;

  /// "What if" mode: none / B / P.
  String _predictMode = 'none';

  /// The results reference from the last time predictNextOutcome ran (memoized by reference).
  List<RawResult>? _lastPredictedFor;

  /// Global theme (the demo doesn't support skinning, so it's resolved once and reused
  /// throughout, to avoid rebuilding the whole theme tree on every build).
  late final _theme = resolveTheme();

  /// Layout config (fixed, cached once) -- paired with the store's stable snapshot so that
  /// Engine.compute's by-reference memoization can hit directly when recomputing for
  /// UI-only changes (toggling a switch etc.), keeping the layout object the same instance.
  late final _cfg = LayoutConfig(cellSize: 18, rows: 6, theme: _theme);

  /// Memo of the merged decorations list (keyed by road id): if the input references
  /// haven't changed, reuse the same List instance, so a new list isn't built on every
  /// build that would defeat RoadPanel's "data unchanged" check.
  final Map<String, (RoadLayout, PredictionForRoad?, String, List<DrawCommand>)> _decoCache = {};

  bool _pulseEnabled = true;
  bool _celebrationEnabled = true;
  bool _hapticsEnabled = false;

  /// The kind of the most recent store change, which decides whether RoadPanel should
  /// play an insert animation / auto-follow the tail -- only a genuine "append one round"
  /// (UpdateKind.append) should animate and follow; a full refresh like switching shoes or
  /// game type (UpdateKind.full) should jump straight to the final state, without yanking
  /// the user away from the history position they're currently looking at.
  RoadUpdateKind _lastEventKind = RoadUpdateKind.setResults;

  @override
  void initState() {
    super.initState();
    _store = createStore(
      onOutOfSync: (expected, actual) =>
          debugPrint('roadsman demo: out of sync, expected round $expected, got $actual'),
    );
    _store.subscribe((event) {
      _lastEventKind = switch (event.kind) {
        UpdateKind.append => RoadUpdateKind.append,
        UpdateKind.patch => RoadUpdateKind.patch,
        UpdateKind.full => RoadUpdateKind.setResults,
      };
      setState(_recompute);
    });
    _loadMock();
  }

  Future<void> _loadMock() async {
    final raw = await rootBundle.loadString('assets/mock.json');
    final list = jsonDecode(raw) as List;
    final shoes = list.map((e) => Shoe.fromJson(e as Map<String, dynamic>)).toList();
    setState(() {
      _shoes = shoes;
      _loading = false;
    });
    if (shoes.isNotEmpty) _loadShoe(shoes.first.shoeId);
  }

  void _rebuildEngine() {
    _engine = createEngine(_enabledRoads.toList(), spec: _currentSpec);
  }

  void _recompute() {
    _engine ??= createEngine(_enabledRoads.toList(), spec: _currentSpec);
    final results = _store.getResults();
    _output = _engine!.compute(results, _cfg);
    // predictNextOutcome only makes sense for baccarat's binary B/P outcome (recomputing
    // the big road assuming the next winner is 'B'/'P'); it isn't called when switched to
    // dragon tiger/sicbo, and _onGameTypeChanged also hides the corresponding dropdown then.
    // The prediction result is memoized by the results reference: a UI-only recompute
    // doesn't rerun predictNextOutcome, so the PredictionForRoad instance stays stable and
    // _decoCache's identical check can hit.
    if (_currentSpec.id != 'baccarat') {
      _prediction = null;
      _lastPredictedFor = null;
    } else if (!identical(results, _lastPredictedFor)) {
      _prediction = predictNextOutcome(results);
      _lastPredictedFor = results;
    }
  }

  void _loadShoe(String shoeId) {
    final shoe = _shoes.firstWhere((s) => s.shoeId == shoeId);
    _currentShoeId = shoeId;
    _replayScript = shoe.results;
    _replayer?.stop();
    _replayer = createReplayer(_replayScript, _store);
    _rebuildEngine();
    setState(() => _store.setResults(shoe.results));
  }

  void _onGameTypeChanged(GameSpec spec) {
    setState(() {
      _currentSpec = spec;
      _enabledRoads.removeWhere((id) => _baccaratOnlyRoads.contains(id) && spec.id != 'baccarat');
      if (spec.id == 'roulette') _enabledRoads.retainWhere(_rouletteRoads.contains);
      _rebuildEngine();
      _store.setResults(const []);
      _replayer?.stop();
      if (spec.id == 'baccarat' && _shoes.isNotEmpty) {
        _loadShoe(_shoes.first.shoeId);
      }
    });
  }

  void _appendOneRound() {
    final results = _store.getResults();
    final next = results.isNotEmpty ? results.last.no + 1 : 1;
    final winner = _currentSpec.outcomes[next % _currentSpec.outcomes.length].code;
    _store.append(RawResult(no: next, winner: winner, bankerPair: false, playerPair: false));
  }

  @override
  Widget build(BuildContext context) {
    if (_loading) {
      return const Scaffold(body: Center(child: CircularProgressIndicator()));
    }

    final output = _output;
    final theme = _theme;

    return Scaffold(
      appBar: AppBar(title: const Text('roadsman demo — Baccarat/Dragon Tiger/Sic Bo road charts')),
      body: Column(
        children: [
          _buildControlBar(),
          const Divider(height: 1),
          Expanded(
            child: output == null
                ? const Center(child: Text('Waiting for a new shoe'))
                : SingleChildScrollView(
                    padding: const EdgeInsets.all(12),
                    child: Wrap(
                      spacing: 12,
                      runSpacing: 12,
                      children: [
                        for (final id in _enabledRoads)
                          if (output.layouts.containsKey(id))
                            _buildRoadCard(id, output, theme)
                          else if (id == 'statsPanel' && output.data['statsPanel'] != null)
                            _buildStatsCard(output.data['statsPanel'] as StatsData),
                      ],
                    ),
                  ),
          ),
        ],
      ),
    );
  }

  Widget _buildControlBar() {
    return Padding(
      padding: const EdgeInsets.all(8),
      child: Wrap(
        spacing: 12,
        runSpacing: 8,
        crossAxisAlignment: WrapCrossAlignment.center,
        children: [
          DropdownButton<GameSpec>(
            value: _currentSpec,
            items: [baccaratSpec, dragonTigerSpec, sicboSpec, rouletteSpec]
                .map((s) => DropdownMenuItem(value: s, child: Text('Game: ${s.label}')))
                .toList(),
            onChanged: (s) => s != null ? _onGameTypeChanged(s) : null,
          ),
          if (_currentSpec.id == 'baccarat')
            DropdownButton<String>(
              value: _currentShoeId.isEmpty ? null : _currentShoeId,
              hint: const Text('Shoe'),
              items: _shoes.map((s) => DropdownMenuItem(value: s.shoeId, child: Text(s.shoeId))).toList(),
              onChanged: (id) => id != null ? _loadShoe(id) : null,
            ),
          ..._allRoadIds
              .where(
                (id) => _currentSpec.id == 'roulette'
                    ? _rouletteRoads.contains(id)
                    : _currentSpec.id == 'baccarat' || !_baccaratOnlyRoads.contains(id),
              )
              .map(
                (id) => FilterChip(
                  label: Text(_roadLabels[id] ?? id),
                  selected: _enabledRoads.contains(id),
                  onSelected: (sel) => setState(() {
                    sel ? _enabledRoads.add(id) : _enabledRoads.remove(id);
                    _rebuildEngine();
                    _recompute();
                  }),
                ),
              ),
          ElevatedButton(onPressed: _appendOneRound, child: const Text('Add a round')),
          if (_replayer != null) ...[
            IconButton(
              icon: const Icon(Icons.play_arrow),
              tooltip: 'Replay',
              onPressed: () => setState(() {
                // Loading a shoe already pushes the whole shoe's data into the store via
                // setResults so the user can see the full picture, but the replayer's
                // internal cursor starts from 0 -- calling play() without clearing the
                // store first would make each appended round number mismatch "the store's
                // last round + 1", so append silently fails (via the onOutOfSync callback)
                // and replay has no visible effect. Clearing and rebuilding is only needed
                // when starting fresh from idle (just loaded / previously stopped); resuming
                // from paused should just continue playing, not clear anything.
                if (_replayer!.state == ReplayState.idle) {
                  _store.setResults(const []);
                  _replayer = createReplayer(_replayScript, _store);
                }
                _replayer!.play();
              }),
            ),
            IconButton(
              icon: const Icon(Icons.pause),
              tooltip: 'Pause',
              onPressed: () => setState(() => _replayer!.pause()),
            ),
            IconButton(
              icon: const Icon(Icons.stop),
              tooltip: 'Stop (restore the full shoe)',
              onPressed: () => setState(() => _replayer!.stop()),
            ),
          ],
          if (_currentSpec.id != 'sicbo')
            DropdownButton<String>(
              value: _predictMode,
              items: const [
                DropdownMenuItem(value: 'none', child: Text('No prediction')),
                DropdownMenuItem(value: 'B', child: Text('Ask Banker')),
                DropdownMenuItem(value: 'P', child: Text('Ask Player')),
              ],
              onChanged: (v) => setState(() => _predictMode = v ?? 'none'),
            ),
          FilterChip(
            label: const Text('Pulse highlight'),
            selected: _pulseEnabled,
            onSelected: (v) => setState(() => _pulseEnabled = v),
          ),
          FilterChip(
            label: const Text('Long-streak celebration'),
            selected: _celebrationEnabled,
            onSelected: (v) => setState(() => _celebrationEnabled = v),
          ),
          FilterChip(
            label: const Text('Haptic feedback'),
            selected: _hapticsEnabled,
            onSelected: (v) => setState(() => _hapticsEnabled = v),
          ),
        ],
      ),
    );
  }

  Widget _buildRoadCard(String id, ComputeOutput output, Theme theme) {
    final layout = output.layouts[id]!;
    final prediction = _predictMode == 'none' ? null : _predictionFor(id);
    final decorations = _mergedDecorations(id, layout, prediction);

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(8),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(_roadLabels[id] ?? id, style: const TextStyle(fontWeight: FontWeight.bold)),
            const SizedBox(height: 4),
            RoadPanel(
              key: ValueKey(id),
              cells: layout.cells,
              decorations: decorations,
              contentWidth: layout.contentWidth,
              contentHeight: layout.contentHeight,
              grid: layout.grid,
              theme: theme,
              panelWidth: 360,
              panelHeight: layout.contentHeight.clamp(60, 260),
              eventType: _lastEventKind,
              followTail: _lastEventKind == RoadUpdateKind.append ? FollowTail.ease : FollowTail.none,
              pulseEnabled: _pulseEnabled,
            ),
          ],
        ),
      ),
    );
  }

  /// Gets the "what if" prediction result for a given road from [_prediction] (only
  /// meaningful for big eye boy/small road/cockroach road).
  PredictionForRoad? _predictionFor(String id) {
    final p = _prediction;
    if (p == null) return null;
    return switch (id) {
      'bigEyeBoy' => p.bigEyeBoy,
      'smallRoad' => p.smallRoad,
      'cockroachRoad' => p.cockroachRoad,
      _ => null,
    };
  }

  /// Merges layout.decorations with the "what if" ghost marker, memoized by
  /// (layout, prediction, predictMode) references: returns the same List instance when the
  /// inputs haven't changed, so RoadPanel can recognize the data as unchanged.
  List<DrawCommand> _mergedDecorations(String id, RoadLayout layout, PredictionForRoad? prediction) {
    final cached = _decoCache[id];
    if (cached != null &&
        identical(cached.$1, layout) &&
        identical(cached.$2, prediction) &&
        cached.$3 == _predictMode) {
      return cached.$4;
    }
    final ghost = _buildGhostDecoration(prediction, layout);
    final merged = ghost.isEmpty
        ? (layout.decorations ?? const <DrawCommand>[])
        : [...(layout.decorations ?? const <DrawCommand>[]), ...ghost];
    _decoCache[id] = (layout, prediction, _predictMode, merged);
    return merged;
  }

  /// "What if" ghost marker: assuming the next round is a banker or player win, whether a
  /// derived road would gain an extra cell and what color it would be, drawn as a
  /// semi-transparent dot at the end of that road's current content (right next to the
  /// last cell, same row).
  List<DrawCommand> _buildGhostDecoration(PredictionForRoad? prediction, RoadLayout layout) {
    if (prediction == null) return const [];
    final color = _predictMode == 'B' ? prediction.ifBanker : prediction.ifPlayer;
    if (color == null) return const [];
    // Color follows the theme palette instead of being hardcoded (keeps the ghost dot
    // consistent with the road chart when the theme changes)
    final argb = color == DerivedColor.red ? _theme.palette.red : _theme.palette.blue;
    if (layout.cells.isEmpty) {
      return [DotCommand(x: 9, y: 9, r: 6, fill: argb, alpha: 0.5)];
    }
    final last = layout.cells.last;
    final cx = last.x + last.w * 1.5;
    final cy = last.y + last.h / 2;
    return [DotCommand(x: cx, y: cy, r: last.w * 0.3, fill: argb, alpha: 0.5)];
  }

  Widget _buildStatsCard(StatsData stats) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          mainAxisSize: MainAxisSize.min,
          children: [
            const Text('Stats Panel', style: TextStyle(fontWeight: FontWeight.bold)),
            const SizedBox(height: 8),
            Text('Total rounds: ${stats.total}'),
            Text('Banker: ${stats.banker} (${stats.bankerPct}%)'),
            Text('Player: ${stats.player} (${stats.playerPct}%)'),
            Text('Tie: ${stats.tie} (${stats.tiePct}%)'),
            Text('Longest banker streak: ${stats.longestBankerStreak}'),
            Text('Longest player streak: ${stats.longestPlayerStreak}'),
            if (stats.currentStreak != null)
              Text('Current streak: ${stats.currentStreak!.winner} × ${stats.currentStreak!.length}'),
          ],
        ),
      ),
    );
  }
}
0
likes
150
points
7
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Pure-Dart road-map (bead plate) engine + Flutter render panel for baccarat/dragon-tiger/sic-bo.

Topics

#baccarat #roadmap #canvas #game #visualization

License

MIT (license)

Dependencies

flutter

More

Packages that depend on roadsman