Added chatterbox connections
This commit is contained in:
1 parent
41ef12f954
commit
e58ceeb192
58 files changed
+8931
-8
No files matched your search
@@ -0,0 +1,437 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'teddy_audio.dart';
|
||||
import 'hint_panel.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class RoomPage extends StatefulWidget {
|
||||
const RoomPage({super.key, this.client});
|
||||
final http.Client? client;
|
||||
@override
|
||||
State<RoomPage> createState() => _RoomPageState();
|
||||
}
|
||||
|
||||
class _RoomPageState extends State<RoomPage> {
|
||||
static final base = (Uri.base.scheme == 'http' || Uri.base.scheme == 'https') ? Uri.base.origin : 'http://127.0.0.1:18777';
|
||||
late final http.Client client;
|
||||
Timer? timer;
|
||||
Map<String, dynamic>? room;
|
||||
bool pending = false, polling = false;
|
||||
String? error;
|
||||
String notice = 'Connecting to the isolated room controller...';
|
||||
String profile = 'standard';
|
||||
int party = 6, tab = 0, serial = 0, generation = 0;
|
||||
final Map<String, int> selections = {};
|
||||
int? selectionWave;
|
||||
Map<String, dynamic>? trace;
|
||||
String speechStatus = 'Teddy audio not enabled (captions available)';
|
||||
bool speechPolling = false;
|
||||
final audio = TeddyAudio();
|
||||
String? readyAudioUrl;
|
||||
|
||||
Future<void> refreshSpeech() async {
|
||||
if (speechPolling) return;
|
||||
speechPolling = true;
|
||||
var status = 'Teddy audio not enabled. Start lab.py start-room --speech.';
|
||||
try {
|
||||
final response = await client.get(Uri.parse('$base/speech/health')).timeout(const Duration(seconds: 2));
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
final teddy = room?['teddy'];
|
||||
final valid = error == null && data['output'] == 'browser' &&
|
||||
data['run_id'] == room?['run_id'] && data['cue_id'] != null &&
|
||||
data['cue_id'] == teddy?['current']?['id'] &&
|
||||
teddy?['muted'] != true && teddy?['online'] == true &&
|
||||
(teddy?['takeover'] != true || (teddy?['current']?['key'] as String? ?? '').startsWith('gm-'));
|
||||
readyAudioUrl = valid && data['audio_url'] is String ? '$base/speech${data['audio_url']}' : null;
|
||||
audio.sync(readyAudioUrl);
|
||||
status = 'Teddy: ${data['status']} · ${data['voice']} — ${data['message']}';
|
||||
} else { readyAudioUrl=null; audio.sync(null); }
|
||||
} catch (_) { readyAudioUrl=null; audio.sync(null); /* Speech is optional; room controls stay available. */ }
|
||||
if (mounted) setState(() => speechStatus = status);
|
||||
speechPolling = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
client = widget.client ?? http.Client();
|
||||
refresh();
|
||||
refreshSpeech();
|
||||
timer = Timer.periodic(const Duration(seconds: 1), (_) { refresh(); refreshSpeech(); });
|
||||
}
|
||||
@override
|
||||
void dispose() { timer?.cancel(); audio.dispose(); client.close(); super.dispose(); }
|
||||
|
||||
Future<void> refresh() async {
|
||||
if (pending || polling) return;
|
||||
polling = true;
|
||||
final gen = generation;
|
||||
try {
|
||||
final response = await client.get(Uri.parse('$base/room/state')).timeout(const Duration(seconds: 6));
|
||||
if (response.statusCode != 200) throw Exception();
|
||||
final data = jsonDecode(response.body);
|
||||
if (data['state'] is! Map) throw Exception();
|
||||
if (mounted && gen == generation) setState(() { if (room == null || error != null) notice = 'Connected. The room timer runs after briefing; Pause freezes gameplay.'; room = Map<String, dynamic>.from(data['state']); error = null; });
|
||||
} catch (_) {
|
||||
if (mounted && gen == generation) setState(() => error = 'Room simulator disconnected. Start lab.py start-room; controls are disabled.');
|
||||
} finally { polling = false; }
|
||||
}
|
||||
|
||||
Future<void> act(String action, [String entity = '', dynamic value]) async {
|
||||
if (pending || room == null || error != null) return;
|
||||
generation++;
|
||||
setState(() => pending = true);
|
||||
try {
|
||||
final response = await client.post(Uri.parse('$base/room/command'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'command_id': 'gm-${DateTime.now().microsecondsSinceEpoch}-${serial++}',
|
||||
'run_id': room!['run_id'], 'expected_revision': room!['revision'],
|
||||
'expires_at_ms': DateTime.now().millisecondsSinceEpoch + 10000,
|
||||
'input': {'action': action, 'entity': entity, 'value': value},
|
||||
})).timeout(const Duration(seconds: 7));
|
||||
if (response.statusCode != 200) throw Exception();
|
||||
final data = jsonDecode(response.body);
|
||||
if (data['state'] is! Map) throw Exception();
|
||||
if (mounted) setState(() {
|
||||
room = Map<String, dynamic>.from(data['state']);
|
||||
notice = '${data['accepted'] == true ? 'Accepted' : 'Rejected'}: ${data['message']}';
|
||||
if (room?['teddy']?['current'] == null) audio.sync(null);
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => error = 'Delivery unconfirmed. Refresh the room state before retrying.');
|
||||
} finally { if (mounted) setState(() => pending = false); }
|
||||
}
|
||||
|
||||
Future<void> confirmReset() async {
|
||||
final confirmed = await showDialog<bool>(context: context, builder: (context) => AlertDialog(
|
||||
title: const Text('Reset this simulated run?'),
|
||||
content: const Text('Clears tokens, wins, charge, calibration and launch readiness. No physical room is affected. The current process trace retains the reset.'),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Reset simulation'))]));
|
||||
if (confirmed == true) await act('reset', '', {'confirmed': true});
|
||||
}
|
||||
|
||||
Future<void> withReason(String action, String entity) async {
|
||||
final controller = TextEditingController();
|
||||
final reason = await showDialog<String>(context: context, builder: (context) => AlertDialog(
|
||||
title: Text(action == 'deliver' ? 'Confirm GM hand-delivery' : 'Finish as assisted rescue'),
|
||||
content: TextField(controller: controller, autofocus: true,
|
||||
decoration: const InputDecoration(labelText: 'Staff name and reason')),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
FilledButton(onPressed: () {
|
||||
if (controller.text.trim().isNotEmpty) Navigator.pop(context, controller.text.trim());
|
||||
}, child: const Text('Record intervention'))]));
|
||||
controller.dispose();
|
||||
if (reason != null) await act(action, entity, action == 'deliver' ? {'hand': true, 'reason': reason} : reason);
|
||||
}
|
||||
|
||||
Future<void> loadTrace() async {
|
||||
try {
|
||||
final response = await client.get(Uri.parse('$base/room/trace')).timeout(const Duration(seconds: 6));
|
||||
if (response.statusCode != 200) throw Exception();
|
||||
final data = jsonDecode(response.body);
|
||||
if (mounted) setState(() => trace = Map<String, dynamic>.from(data));
|
||||
} catch (_) { if (mounted) setState(() => notice = 'Could not fetch trace.'); }
|
||||
}
|
||||
|
||||
bool get enabled => room != null && error == null && !pending;
|
||||
List<dynamic> get stations => room?['stations'] ?? [];
|
||||
String get phase => room?['phase'] ?? 'UNKNOWN';
|
||||
String get lifecycle => room?['lifecycle'] ?? 'UNKNOWN';
|
||||
|
||||
Widget button(String label, String action, {String entity = '', dynamic value, bool allow = true}) =>
|
||||
OutlinedButton(onPressed: enabled && allow ? () => act(action, entity, value) : null, child: Text(label));
|
||||
Widget panel(String title, List<Widget> children, {Color? color}) => SizedBox(width: double.infinity, child: Card(
|
||||
color: color, child: Padding(padding: const EdgeInsets.all(18),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(title, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12), ...children]))));
|
||||
Widget wrap(List<Widget> children) => Wrap(spacing: 8, runSpacing: 8, children: children);
|
||||
Widget fact(String text) => Padding(padding: const EdgeInsets.only(bottom: 8), child: Text(text));
|
||||
Widget badge(String text, [Color color = Colors.cyan]) => Chip(
|
||||
label: Text(text), side: BorderSide(color: color.withValues(alpha: .5)));
|
||||
|
||||
String blocker() {
|
||||
if ((room?['hold'] ?? '') != '') return room!['hold'];
|
||||
switch (phase) {
|
||||
case 'BRIEFING': return 'Start the run, then finish briefing to enable the discovery puzzles.';
|
||||
case 'POWER_RESTORE': return 'Find the junction box and key, remove the lock, then raise the large switch. Teddy is awake; cabinets are off.';
|
||||
case 'BOOTING': return 'Power restored. All arcade cabinets are running their four-second startup check.';
|
||||
case 'ARCADE': return 'Find six tokens, authorize their cabinets, and achieve six distinct first-state wins.';
|
||||
case 'BOSS': return 'All six wins are latched. Start and defeat The Dark Machine.';
|
||||
case 'REVEAL': return 'Passage release is requested. ACK is not an open sensor. Confirm opening and advance 300 ms.';
|
||||
case 'CELLS': return 'Confirm charged bays open, remove each cell, and install it in its matching socket. Advance 2.5 seconds for stability.';
|
||||
case 'CALIBRATION_TUTORIAL': return 'Complete two readback/Ready/Execute practices before scored waves.';
|
||||
case 'CALIBRATION': return 'Match ship targets, simulate stable Ready at each active station, then Execute.';
|
||||
case 'LAUNCH_ARM': return 'Arm all six stations sequentially, acknowledge each operator group, then navigator Commit.';
|
||||
case 'COUNTDOWN': return 'Five-second countdown. Removing a cell or losing a required station cancels launch.';
|
||||
case 'DEBRIEF': return 'Run finished. Review the trace or reset the simulation.';
|
||||
default: return 'Waiting for a room snapshot.';
|
||||
}
|
||||
}
|
||||
|
||||
Widget navigation() => ListView(children: [
|
||||
const Padding(padding: EdgeInsets.all(20), child: Text('ROOM OPERATIONS', style: TextStyle(fontWeight: FontWeight.bold))),
|
||||
for (final entry in ['Room Flow','Cabinets','Ship & Cells','Calibration & Launch','Teddy','Diagnostics'].asMap().entries)
|
||||
ListTile(selected: tab == entry.key, leading: Icon([Icons.account_tree,Icons.sports_esports,Icons.battery_charging_full,Icons.rocket_launch,Icons.record_voice_over,Icons.monitor_heart][entry.key]),
|
||||
title: Text(entry.value), onTap: () { setState(() => tab=entry.key); if (MediaQuery.sizeOf(context).width < 900) Navigator.pop(context); }),
|
||||
]);
|
||||
|
||||
Widget overview() => Column(children: [
|
||||
panel('What happens next', [
|
||||
fact(blocker()),
|
||||
wrap([
|
||||
button('Start run', 'start_run', allow: lifecycle == 'READY'),
|
||||
button('Briefing complete', 'briefing_done', allow: lifecycle == 'RUNNING' && phase == 'BRIEFING'),
|
||||
button('Flip power switch up', 'power_switch', value: true, allow: lifecycle == 'RUNNING' && phase == 'POWER_RESTORE'),
|
||||
button('Start boss', 'boss_start', allow: lifecycle == 'RUNNING' && room?['boss'] == 'READY'),
|
||||
button('Simulate boss victory', 'boss_win', allow: lifecycle == 'RUNNING' && room?['boss'] == 'PLAYING'),
|
||||
button('Observe passage open', 'passage', value: true, allow: phase == 'REVEAL'),
|
||||
]),
|
||||
]),
|
||||
panel('Room flow · conceptual layout', [
|
||||
const Text('Discovery arcade → Dark Machine → hidden ship → calibration → launch. This is a flow diagram, not a measured floor plan.'),
|
||||
const SizedBox(height: 12),
|
||||
wrap([
|
||||
badge('Puzzles ${(room?['puzzles'] as List? ?? []).where((p) => p['solved'] == true).length}/7'),
|
||||
badge('First wins ${stations.where((s) => s['won'] == true).length}/6'),
|
||||
badge('Boss ${room?['boss']}'),
|
||||
badge('Passage ${room?['passage_open'] == true ? 'OPEN' : 'UNCONFIRMED'}'),
|
||||
badge('Ship power ${room?['ship_power'] == true ? 'STABLE' : 'WAITING'}'),
|
||||
badge('Launches ${room?['launches']}'),
|
||||
]),
|
||||
]),
|
||||
panel('Discovery puzzles and rewards', [
|
||||
const Text('These are the supplied placeholder puzzle contracts. Solve and physical delivery are separate observations.'),
|
||||
const SizedBox(height: 12),
|
||||
...((room?['puzzles'] as List?) ?? []).map((p) {
|
||||
const labels = {'P01':'Restore claw controls','P02A':'Claw stage A','P02B':'Claw stage B',
|
||||
'P03':'Record and sleeve','P04':'Service-panel symbols','P05':'Ticket values','P06':'Poster overlay'};
|
||||
final effects = (room?['effects'] as List? ?? []).where((e) => e['target'] == p['token']).toList();
|
||||
return Padding(padding: const EdgeInsets.only(bottom: 14), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('${p['id']} · ${labels[p['id']]} ${p['token']}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text(p['delivered'] == true ? 'Delivered' : p['solved'] == true ?
|
||||
(p['token'] == '' ? 'Solved' : 'Solved · reward pending') : 'Unsolved'),
|
||||
wrap([
|
||||
button('Simulate solve', 'solve', entity: p['id'], allow: phase == 'ARCADE' && p['solved'] != true),
|
||||
if (p['token'] != '') button('Observe delivery', 'deliver', entity: p['token'], allow: p['solved'] == true && p['delivered'] != true),
|
||||
if (effects.isNotEmpty) button('ACK only / jam test', 'ack', entity: effects.first['id'], allow: effects.first['status'] == 'REQUESTED'),
|
||||
if (p['solved'] == true && p['token'] != '' && p['delivered'] != true)
|
||||
TextButton(onPressed: enabled ? () => withReason('deliver', p['token']) : null, child: const Text('GM hand-delivery...')),
|
||||
]),
|
||||
]));
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
|
||||
Widget cabinets() => LayoutBuilder(builder: (context, constraints) {
|
||||
final width = constraints.maxWidth > 1000 ? (constraints.maxWidth - 24) / 3 :
|
||||
constraints.maxWidth > 650 ? (constraints.maxWidth - 16) / 2 : constraints.maxWidth;
|
||||
return wrap(stations.map<Widget>((s) => SizedBox(width: width, child: panel(
|
||||
'${s['id']} · ${s['game'].toString().replaceAll('_', ' ')}', [
|
||||
wrap([badge(s['status'], s['won'] == true ? Colors.green : Colors.cyan),
|
||||
badge(s['online'] == true ? 'ONLINE' : 'OFFLINE', s['online'] == true ? Colors.green : Colors.red)]),
|
||||
if (s['status'] == 'BOOTING') ...[const LinearProgressIndicator(), const Text('ROM check / startup - four seconds')],
|
||||
if (s['status'] == 'TOKEN_WAIT') const Text('ATTRACT MODE - insert token'),
|
||||
fact('Token ${s['token']} · ${s['authorized'] == true ? 'authorized for retries' : 'not authorized'}'),
|
||||
fact('Objective ${s['progress']} / ${s['target']} · attempt ${s['attempt']} · losses ${s['losses']}'),
|
||||
LinearProgressIndicator(value: (s['charge_ms'] as num) / 30000),
|
||||
fact('${s['cell']} charge ${((s['charge_ms'] as num) / 300).round()}% · calibration passes ${s['passes']}'),
|
||||
wrap([
|
||||
button('Scan ${s['token']}', 'token', entity: s['id'], value: {'token':s['token'],'age_ms':0}, allow: s['status'] == 'TOKEN_WAIT'),
|
||||
button('Wrong token', 'token', entity: s['id'], value: {'token':'WRONG','age_ms':0}, allow: s['status'] == 'TOKEN_WAIT'),
|
||||
button('Start attempt', 'start', entity: s['id'], allow: s['status'] == 'READY'),
|
||||
button('Simulate target reached', 'win', entity: s['id'], value: {'attempt':s['attempt'],'progress':s['target']}, allow: s['status'] == 'PLAYING'),
|
||||
button('Simulate loss', 'lose', entity: s['id'], value: {'attempt':s['attempt']}, allow: s['status'] == 'PLAYING'),
|
||||
button(s['online'] == true ? 'Disconnect' : 'Reconnect', 'online', entity:s['id'], value:s['online'] != true),
|
||||
]),
|
||||
]))).toList());
|
||||
});
|
||||
|
||||
Widget ship() {
|
||||
final locations = Map<String, dynamic>.from(room?['locations'] ?? {});
|
||||
final faults = room?['cell_faults'] as List? ?? [];
|
||||
return Column(children: [
|
||||
panel('Passage and ship power', [
|
||||
fact('Passage: ${room?['passage_open'] == true ? 'observed open' : 'unconfirmed'} · ship power: ${room?['ship_power']}'),
|
||||
if (faults.isNotEmpty) Text(faults.join('\n'), style: const TextStyle(color: Colors.amber)),
|
||||
]),
|
||||
...stations.map<Widget>((s) {
|
||||
final bay = 'BAY${s['cell'].toString().substring(4)}';
|
||||
final here = locations.entries.where((e) => e.value == s['cell']).map((e) => e.key).join(', ');
|
||||
return panel('${s['cell']} → ${s['socket']}', [
|
||||
fact('Location: ${here.isEmpty ? 'carried / unobserved' : here} · bay open: ${s['bay_open']}'),
|
||||
wrap([
|
||||
button('Observe bay open', 'bay', entity:s['id'], allow: room?['passage_open'] == true && s['charge_ms'] == 30000 && s['bay_open'] != true),
|
||||
button('Remove from bay', 'cell', entity:bay, value:{'cell':s['cell'],'present':false}, allow: s['bay_open'] == true && locations[bay] == s['cell']),
|
||||
button('Insert matching socket', 'cell', entity:s['socket'], value:{'cell':s['cell'],'present':true}, allow: room?['passage_open'] == true && locations[s['socket']] != s['cell']),
|
||||
button('Remove from socket', 'cell', entity:s['socket'], value:{'cell':s['cell'],'present':false}, allow: locations[s['socket']] == s['cell']),
|
||||
]),
|
||||
]);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget calibration() {
|
||||
final wave = room?['wave'] as Map?;
|
||||
if (wave?['id'] != selectionWave) {
|
||||
selectionWave = wave?['id'];
|
||||
selections.clear();
|
||||
}
|
||||
final targets = Map<String, dynamic>.from(wave?['targets'] ?? {});
|
||||
const ranges = {'cabinet_01':[0,3],'cabinet_02':[1,5],'cabinet_03':[0,7],
|
||||
'cabinet_04':[1,3],'cabinet_05':[1,4],'cabinet_06':[1,4]};
|
||||
final hold = {'guided':1000,'standard':2000,'challenge':3000}[room?['profile']] ?? 2000;
|
||||
return Column(children: [
|
||||
panel('Navigator and operator practice', [
|
||||
fact('Party ${room?['party']} · ${room?['operators']} operator groups · practices ${room?['tutorial']}/2'),
|
||||
fact(blocker()),
|
||||
if (wave != null) ...[
|
||||
fact('Challenge ${wave['id']} · active-time deadline ${wave['deadline'] ?? 'untimed practice'} ms'),
|
||||
...targets.entries.map((entry) {
|
||||
final range = ranges[entry.key]!;
|
||||
final selected = selections[entry.key] ?? range.first;
|
||||
return Padding(padding: const EdgeInsets.only(bottom: 14), child: wrap([
|
||||
SizedBox(width: 230, child: Text('${entry.key}\nShip target: ${entry.value}\nReadback: ${wave['ready'][entry.key] ?? 'waiting'}')),
|
||||
DropdownButton<int>(value:selected, items:[for (int i = range.first; i <= range.last; i++)
|
||||
DropdownMenuItem(value:i, child:Text('Selection $i'))],
|
||||
onChanged:enabled ? (v) => setState(() => selections[entry.key] = v!) : null),
|
||||
button('Simulate held Ready', 'ready', entity:entry.key,
|
||||
value:{'challenge':wave['id'],'selection':selected,'stable_ms':hold}),
|
||||
button('Revoke Ready', 'revoke', entity:entry.key, value:wave['id']),
|
||||
]));
|
||||
}),
|
||||
button('Navigator Execute', 'execute', value:wave['id']),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
wrap(stations.map<Widget>((s) => badge('${s['id']}: ${s['passes']} passes')).toList()),
|
||||
]),
|
||||
panel('Launch barrier', [
|
||||
fact('Epoch ${room?['launch_epoch']} · Armed ${(room?['armed'] as List? ?? []).length}/6'),
|
||||
wrap(stations.map<Widget>((s) => button('Arm ${s['id']}', 'arm', entity:s['id'], value:room?['launch_epoch'],
|
||||
allow:phase == 'LAUNCH_ARM' && !(room?['armed'] as List? ?? []).contains(s['id']))).toList()),
|
||||
const SizedBox(height: 12),
|
||||
wrap([for (int i=0; i < (room?['operators'] ?? 1); i++)
|
||||
button('Group ${i+1} ready', 'group_ready', value:{'epoch':room?['launch_epoch'],'group':i},
|
||||
allow:phase == 'LAUNCH_ARM' && !(room?['groups'] as List? ?? []).contains(i))]),
|
||||
fact('One operator can arm several stations in sequence. No six-person simultaneous button press is required.'),
|
||||
button('Navigator Commit', 'commit', value:room?['launch_epoch'], allow:phase == 'LAUNCH_ARM'),
|
||||
if (phase == 'COUNTDOWN') button('Advance five-second countdown', 'advance', value:5000),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget teddy() {
|
||||
final t = room?['teddy'] as Map? ?? {};
|
||||
return panel('Teddy Ruxpin · hints and speech', [
|
||||
wrap([badge(t['mode'] ?? 'DORMANT'), badge(t['online'] == true ? 'ONLINE' : 'FAULT'),
|
||||
badge(t['muted'] == true ? 'MUTED' : 'CAPTIONS + OPTIONAL PIPER'), if (t['takeover'] == true) badge('GM TAKEOVER')]),
|
||||
Text(speechStatus),
|
||||
Text(audio.status),
|
||||
OutlinedButton(onPressed: () async {
|
||||
if (audio.enabled) { setState(() => audio.disable()); }
|
||||
else { await audio.enable(); if (mounted) { setState(() {}); await refreshSpeech(); } }
|
||||
}, child: Text(audio.enabled ? 'Disable audio on this device' : 'Enable audio and test speaker')),
|
||||
Wrap(spacing:8,runSpacing:8,children:[
|
||||
OutlinedButton(onPressed:() async { await audio.enable(); if(mounted) setState(() {}); },child:const Text('Test speaker tone')),
|
||||
FilledButton(onPressed:readyAudioUrl == null ? null : () { audio.playNow(readyAudioUrl!); setState(() {}); },child:const Text('Play current hint here')),
|
||||
]),
|
||||
if (t['takeover'] == true) const Text('GM takeover: automatic narration is paused. Say now and Replay still work.'),
|
||||
if (t['muted'] == true || t['online'] != true) const Text('Teddy is muted or offline. Unmute/reconnect before sending hints.'),
|
||||
const Text('With browser speech enabled, sound plays on each device that opts in. Keep the page open. Listen before advancing virtual time: advancing may stop or replace the cue. Doll movement remains simulated.'),
|
||||
const SizedBox(height: 18),
|
||||
SelectableText(t['transcript'] == '' ? 'Waiting for a cue...' : t['transcript'] ?? '',
|
||||
style: const TextStyle(fontSize: 22)),
|
||||
const SizedBox(height: 18),
|
||||
fact('Current cue: ${t['current']?['key'] ?? 'none'} · Hint level: ${t['hint_level'] ?? 0}'),
|
||||
wrap([
|
||||
button('Quick phase hint now', 'hint', allow:['ARCADE','CALIBRATION','CALIBRATION_TUTORIAL'].contains(phase) && t['online'] == true && t['muted'] != true),
|
||||
button(t['muted'] == true ? 'Unmute' : 'Mute', 'teddy', value:'mute'),
|
||||
button('Stop cue', 'teddy', value:'stop'),
|
||||
button('Replay transcript cue', 'teddy', value:'replay'),
|
||||
button(t['online'] == true ? 'Simulate Teddy failure' : 'Reconnect Teddy', 'teddy', value:'offline'),
|
||||
button(t['takeover'] == true ? 'Release to autopilot' : 'GM takeover', 'teddy',
|
||||
value:t['takeover'] == true ? 'release' : 'takeover'),
|
||||
]),
|
||||
const SizedBox(height: 18),
|
||||
const Text('Queued cues'),
|
||||
...((t['queue'] as List?) ?? []).map((c) => ListTile(title:Text(c['key']), subtitle:Text(c['transcript']))),
|
||||
HintPanel(key:const ValueKey('hint-library'),room:room!,client:client,base:base,canSpeak:enabled && t['online'] == true && t['muted'] != true,
|
||||
onSpeak:(text)=>act('speak','',{'text':text})),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget diagnostics() => panel('Diagnostics and replay trace', [
|
||||
const Text('Fast-forward the simulation clock for testing:'),
|
||||
wrap([
|
||||
button('+ 1 second','advance',value:1000),
|
||||
button('+ 3 seconds','advance',value:3000),
|
||||
button('+ 30 seconds','advance',value:30000),
|
||||
button('+ 2 minutes','advance',value:120000),
|
||||
]),
|
||||
fact('Run ${room?['run_id']} · revision ${room?['revision']}'),
|
||||
const Text('In-memory simulation trace. Save it before closing the controller. Durable recovery and physical output replay are not implemented.'),
|
||||
wrap([
|
||||
OutlinedButton(onPressed: enabled ? loadTrace : null, child: const Text('Refresh trace')),
|
||||
OutlinedButton(onPressed: trace == null ? null : () async {
|
||||
await Clipboard.setData(ClipboardData(text:const JsonEncoder.withIndent(' ').convert(trace)));
|
||||
if (mounted) setState(() => notice = 'Trace copied. Save it as a JSON file for replay.');
|
||||
}, child: const Text('Copy trace JSON')),
|
||||
]),
|
||||
if (trace != null) fact('Trace events: ${(trace!['trace'] as List).length} · final SHA-256: ${trace!['final_hash']}'),
|
||||
...((room?['effects'] as List?) ?? []).map((e) => ListTile(
|
||||
title:Text('${e['kind']} · ${e['target']}'), subtitle:Text('${e['status']} — ${e['id']}'),
|
||||
trailing:button('Simulate ACK', 'ack', entity:e['id'], allow:e['status']=='REQUESTED'))),
|
||||
if (trace != null) ...((trace!['trace'] as List).reversed.take(20)).map((e) => ListTile(
|
||||
leading:Icon(e['accepted'] == true ? Icons.check_circle_outline : Icons.block),
|
||||
title:Text('${e['request']['input']['action']} ${e['request']['input']['entity']}'),
|
||||
subtitle:Text(e['message']))),
|
||||
]);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final remaining = ((room?['limit_ms'] ?? 3600000) - (room?['active_ms'] ?? 0)) ~/ 1000;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Escape Gallery / Glitch: the Dark Machine')),
|
||||
drawer: Drawer(child: SafeArea(child: navigation())),
|
||||
body: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (MediaQuery.sizeOf(context).width >= 900) SizedBox(width: 230, child: navigation()),
|
||||
Expanded(child: Center(child: ConstrainedBox(constraints: const BoxConstraints(maxWidth: 1450),
|
||||
child: ListView(padding: const EdgeInsets.all(20), children: [
|
||||
const Text('LAB SIMULATION • LIVE TIMER AFTER BRIEFING • NO GPIO',
|
||||
style: TextStyle(color: Colors.amber, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 12),
|
||||
wrap([badge(lifecycle),badge(phase),badge('${room?['profile'] ?? 'standard'} / ${room?['party'] ?? 6} players'),badge('Remaining ${remaining ~/ 60}:${(remaining % 60).toString().padLeft(2,'0')}'),
|
||||
badge(room?['assisted'] == true ? 'ASSISTED' : 'UNASSISTED'),
|
||||
if (room?['outcome'] != null) badge(room!['outcome'],Colors.green)]),
|
||||
if (lifecycle == 'READY') wrap([
|
||||
DropdownButton<String>(value:profile,items:['guided','standard','challenge'].map((p) =>
|
||||
DropdownMenuItem(value:p,child:Text(p))).toList(),onChanged:enabled ? (v)=>setState(()=>profile=v!):null),
|
||||
DropdownButton<int>(value:party,items:[for(int i=1;i<=8;i++) DropdownMenuItem(value:i,child:Text('$i players'))],
|
||||
onChanged:enabled ? (v)=>setState(()=>party=v!):null),
|
||||
button('Apply profile', 'prepare', value:{'profile':profile,'party':party,'seed':42}),
|
||||
]),
|
||||
wrap([
|
||||
button('Pause','pause',allow:lifecycle=='RUNNING'),
|
||||
button('Resume','resume',allow:['PAUSED','RECOVERY_HOLD'].contains(lifecycle)),
|
||||
button('Extend 5 minutes','extend',value:5),
|
||||
TextButton(onPressed:enabled ? ()=>withReason('finish_assisted',''):null,child:const Text('Finish assisted...')),
|
||||
TextButton(onPressed:enabled ? confirmReset:null,child:const Text('Reset simulation...')),
|
||||
]),
|
||||
const SizedBox(height: 10),
|
||||
Text(error ?? notice,style:TextStyle(color:error==null ? Colors.white70:Colors.orange)),
|
||||
const Divider(height: 30),
|
||||
const SizedBox(height: 16),
|
||||
if (room == null) const LinearProgressIndicator()
|
||||
else [overview,cabinets,ship,calibration,teddy,diagnostics][tab](),
|
||||
])))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user