Files
room_glitch/labs/glitch_odin/gm/lib/room_page.dart
T

438 lines
27 KiB
Dart

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](),
])))),
]),
);
}
}