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,169 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Null means outside the current context; all parallel unfinished branches qualify.
|
||||
String? hintReason(Map<String,dynamic> hint, Map<String,dynamic> room) {
|
||||
final phase = room['phase'];
|
||||
if (hint['phase'] != 'ALL' && hint['phase'] != phase) return null;
|
||||
final target = hint['target'];
|
||||
if (target == 'room') return hint['phase'] == 'ALL' ? 'General room hint' : 'Current phase: $phase';
|
||||
final isPuzzle = (target as String).startsWith('P');
|
||||
final items = (room[isPuzzle ? 'puzzles' : 'stations'] as List?) ?? [];
|
||||
final matches = items.where((p) => p['id'] == target);
|
||||
if (matches.isEmpty) return null;
|
||||
final item = matches.first;
|
||||
final complete = item[isPuzzle ? 'solved' : 'won'] == true;
|
||||
if (hint['condition'] == 'complete' && !complete) return null;
|
||||
if (hint['condition'] == 'incomplete') {
|
||||
if (complete || phase != 'ARCADE') return null;
|
||||
bool flag(String id, String field) => ((room['puzzles'] as List?) ?? []).any((p) => p['id'] == id && p[field] == true);
|
||||
if (target == 'P02A' && !flag('P01','solved')) return null;
|
||||
if (target == 'P02B' && !flag('P02A','delivered')) return null;
|
||||
}
|
||||
return '$target: ${complete ? 'completed' : 'unfinished'}';
|
||||
}
|
||||
|
||||
class HintPanel extends StatefulWidget {
|
||||
const HintPanel({super.key, required this.room, required this.client, required this.base, required this.onSpeak, required this.canSpeak});
|
||||
final Map<String,dynamic> room;
|
||||
final http.Client client;
|
||||
final String base;
|
||||
final Future<void> Function(String text) onSpeak;
|
||||
final bool canSpeak;
|
||||
@override
|
||||
State<HintPanel> createState() => _HintPanelState();
|
||||
}
|
||||
class _HintPanelState extends State<HintPanel> {
|
||||
List<Map<String,dynamic>> hints = [];
|
||||
int revision = 0;
|
||||
bool loading = false, suggested = true;
|
||||
String category = 'All categories', query = '', message = '';
|
||||
final draft = TextEditingController();
|
||||
@override
|
||||
void initState() { super.initState(); load(); }
|
||||
@override
|
||||
void dispose() { draft.dispose(); super.dispose(); }
|
||||
Future<void> load() async {
|
||||
if (loading) return;
|
||||
setState(() => loading = true);
|
||||
try {
|
||||
final response = await widget.client.get(Uri.parse('${widget.base}/hints')).timeout(const Duration(seconds:5));
|
||||
final body = jsonDecode(response.body);
|
||||
if (response.statusCode != 200 || body['hints'] is! List) throw Exception('Hint library unavailable. Restart the updated lab gateway.');
|
||||
if (mounted) setState(() {
|
||||
hints = (body['hints'] as List).map((h)=>Map<String,dynamic>.from(h)).toList();
|
||||
revision = body['revision']; message = '';
|
||||
});
|
||||
} catch (e) { if (mounted) setState(()=>message=e.toString()); }
|
||||
finally { if (mounted) setState(()=>loading=false); }
|
||||
}
|
||||
Future<void> edit([Map<String,dynamic>? original, String? initialText]) async {
|
||||
final title = TextEditingController(text:original?['title'] ?? '');
|
||||
final text = TextEditingController(text:original?['text'] ?? initialText ?? '');
|
||||
final tags = TextEditingController(text:((original?['categories'] as List?) ?? ['General']).join(', '));
|
||||
var phase = original?['phase'] ?? 'ALL';
|
||||
var target = original?['target'] ?? 'room';
|
||||
var condition = original?['condition'] ?? 'any';
|
||||
var level = original?['level'] ?? 1;
|
||||
var error = '';
|
||||
var saving = false;
|
||||
var editRevision = revision;
|
||||
await showDialog<void>(context:context,builder:(context)=>StatefulBuilder(builder:(context,update) {
|
||||
Widget pick(String label, dynamic value, List<dynamic> values, void Function(dynamic) change) =>
|
||||
DropdownButtonFormField<dynamic>(initialValue:value,isExpanded:true,
|
||||
decoration:InputDecoration(labelText:label),
|
||||
items:values.map((v)=>DropdownMenuItem<dynamic>(value:v,child:Text('$v'))).toList(),
|
||||
onChanged:saving ? null : (v)=>update(()=>change(v)));
|
||||
return AlertDialog(
|
||||
title:Text(original == null ? 'Create saved hint' : 'Edit saved hint'),
|
||||
content:SizedBox(width:580,child:SingleChildScrollView(child:Column(mainAxisSize:MainAxisSize.min,children:[
|
||||
TextField(controller:title,maxLength:100,decoration:const InputDecoration(labelText:'Hint title')),
|
||||
TextField(controller:text,maxLength:1500,minLines:3,maxLines:6,decoration:const InputDecoration(labelText:'What Teddy should say')),
|
||||
TextField(controller:tags,decoration:const InputDecoration(labelText:'Categories, separated by commas')),
|
||||
pick('Room phase',phase,['ALL','BRIEFING','ARCADE','BOSS','REVEAL','CELLS','CALIBRATION_TUTORIAL','CALIBRATION','LAUNCH_ARM','COUNTDOWN','DEBRIEF'],(v)=>phase=v),
|
||||
pick('Puzzle or cabinet',target,['room','P01','P02A','P02B','P03','P04','P05','P06',...List.generate(6,(i)=>'cabinet_0${i+1}')],(v)=>target=v),
|
||||
pick('Show when target is',condition,['any','incomplete','complete'],(v)=>condition=v),
|
||||
pick('Hint strength: 1 subtle, 2 direct, 3 solution',level,[1,2,3],(v)=>level=v),
|
||||
if (error.isNotEmpty) Text(error,style:TextStyle(color:Theme.of(context).colorScheme.error)),
|
||||
]))),
|
||||
actions:[
|
||||
TextButton(onPressed:saving?null:()=>Navigator.pop(context),child:const Text('Cancel')),
|
||||
FilledButton(onPressed:saving?null:() async {
|
||||
update(()=>saving=true);
|
||||
try {
|
||||
final result=await widget.client.post(Uri.parse('${widget.base}/hints'),
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:jsonEncode({'revision':editRevision,'hint':{
|
||||
'id':original?['id'] ?? '', 'title':title.text,'text':text.text,
|
||||
'categories':tags.text.split(',').map((s)=>s.trim()).where((s)=>s.isNotEmpty).toList(),
|
||||
'phase':phase,'target':target,'condition':condition,'level':level,
|
||||
}})).timeout(const Duration(seconds:6));
|
||||
final body=jsonDecode(result.body);
|
||||
if (result.statusCode != 200) {
|
||||
if (result.statusCode == 409) {
|
||||
await load(); editRevision=revision;
|
||||
throw Exception('Library changed on another device. Your draft is kept. Review it, then Save again to apply your version.');
|
||||
}
|
||||
throw Exception(body['error'] ?? 'Save failed');
|
||||
}
|
||||
if (mounted) setState(() {
|
||||
hints=(body['hints'] as List).map((h)=>Map<String,dynamic>.from(h)).toList();
|
||||
revision=body['revision']; message='Hint saved on the host PC.';
|
||||
});
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
} catch(e) { if(context.mounted) update(()=>error=e.toString()); }
|
||||
finally { if(context.mounted) update(()=>saving=false); }
|
||||
},child:Text(saving?'Saving...':'Save hint')),
|
||||
],
|
||||
);
|
||||
}));
|
||||
// Controllers are released after the dialog route's closing animation.
|
||||
await Future<void>.delayed(const Duration(milliseconds:250));
|
||||
title.dispose(); text.dispose(); tags.dispose();
|
||||
}
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final categories = {'All categories',...hints.expand((h)=>(h['categories'] as List).cast<String>())}.toList()..sort();
|
||||
final shown = hints.where((h) {
|
||||
if(suggested && hintReason(h,widget.room)==null) return false;
|
||||
if(category!='All categories' && !(h['categories'] as List).contains(category)) return false;
|
||||
return '${h['title']} ${h['text']} ${h['categories']}'.toLowerCase().contains(query.toLowerCase());
|
||||
}).toList()..sort((a,b)=>(a['level'] as int).compareTo(b['level'] as int));
|
||||
return Column(crossAxisAlignment:CrossAxisAlignment.start,children:[
|
||||
const SizedBox(height:24),
|
||||
Text('Hint library',style:Theme.of(context).textTheme.titleLarge),
|
||||
Text('Room phase: ${widget.room['phase']}. Suggestions include all eligible parallel puzzles. These are suggestions; the GM chooses what to say.'),
|
||||
Wrap(spacing:10,runSpacing:8,children:[
|
||||
FilterChip(label:const Text('Relevant now'),selected:suggested,onSelected:(v)=>setState(()=>suggested=v)),
|
||||
OutlinedButton(onPressed:()=>edit(),child:const Text('New saved hint')),
|
||||
TextButton(onPressed:loading?null:load,child:const Text('Reload library')),
|
||||
]),
|
||||
DropdownButton<String>(isExpanded:true,value:categories.contains(category)?category:'All categories',
|
||||
items:categories.map((c)=>DropdownMenuItem(value:c,child:Text(c))).toList(),
|
||||
onChanged:(v)=>setState(()=>category=v!)),
|
||||
TextField(decoration:const InputDecoration(labelText:'Search hints'),onChanged:(v)=>setState(()=>query=v)),
|
||||
if(message.isNotEmpty) Text(message),
|
||||
if(loading) const LinearProgressIndicator(),
|
||||
if(!loading && shown.isEmpty) const Text('No matching hints. Turn off Relevant now to browse the whole library.'),
|
||||
...shown.map((h)=>Card(child:Padding(padding:const EdgeInsets.all(12),child:Column(crossAxisAlignment:CrossAxisAlignment.start,children:[
|
||||
Text(h['title'],style:Theme.of(context).textTheme.titleMedium),
|
||||
Text('${(h['categories'] as List).join(' / ')} · Level ${h['level']} · ${hintReason(h,widget.room) ?? 'Outside current context'}'),
|
||||
Text(h['text']),
|
||||
Wrap(spacing:8,children:[
|
||||
FilledButton(onPressed:widget.canSpeak?()=>widget.onSpeak(h['text']):null,child:const Text('Say now')),
|
||||
TextButton(onPressed:()=>edit(h),child:const Text('Edit')),
|
||||
]),
|
||||
])))),
|
||||
const SizedBox(height:16),
|
||||
TextField(controller:draft,minLines:2,maxLines:5,maxLength:1500,
|
||||
decoration:const InputDecoration(labelText:'Write a one-off hint',hintText:'Type exactly what Teddy should say')),
|
||||
Wrap(spacing:8,children:[
|
||||
FilledButton(onPressed:widget.canSpeak?() {
|
||||
if(draft.text.trim().isNotEmpty) widget.onSpeak(draft.text.trim());
|
||||
}:null,child:const Text('Say typed hint now')),
|
||||
OutlinedButton(onPressed:()=>edit(null,draft.text),child:const Text('Save typed hint...')),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'room_page.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
void main() => runApp(const GlitchLab());
|
||||
class GlitchLab extends StatelessWidget {
|
||||
const GlitchLab({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context) => MaterialApp(
|
||||
title: 'Glitch GM Lab',
|
||||
theme: ThemeData.dark(useMaterial3: true).copyWith(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.cyan, brightness: Brightness.dark)),
|
||||
home: Uri.base.queryParameters['room'] == '1' ? const RoomPage() : const CabinetPage(),
|
||||
);
|
||||
}
|
||||
class CabinetPage extends StatefulWidget {
|
||||
const CabinetPage({super.key, this.client});
|
||||
final http.Client? client;
|
||||
@override
|
||||
State<CabinetPage> createState() => _CabinetPageState();
|
||||
}
|
||||
class _CabinetPageState extends State<CabinetPage> {
|
||||
late final http.Client client;
|
||||
Timer? timer;
|
||||
Map<String, dynamic>? cabinet;
|
||||
String? error;
|
||||
String notice = 'Connecting to the local simulation...';
|
||||
bool pending = false;
|
||||
bool polling = false;
|
||||
int sequence = 0;
|
||||
int revision = 0;
|
||||
static const base = 'http://127.0.0.1:18766';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
client = widget.client ?? http.Client();
|
||||
refresh();
|
||||
timer = Timer.periodic(const Duration(seconds: 1), (_) => refresh());
|
||||
}
|
||||
Future<void> refresh() async {
|
||||
if (polling || pending) return;
|
||||
polling = true;
|
||||
final requestRevision = revision;
|
||||
try {
|
||||
final response = await client.get(Uri.parse('$base/state')).timeout(const Duration(seconds: 3));
|
||||
if (response.statusCode != 200) throw Exception('Cabinet or controller unavailable');
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
if (mounted && !pending && requestRevision == revision) setState(() { cabinet = data['state']; error = null; });
|
||||
} catch (_) {
|
||||
if (mounted && requestRevision == revision) setState(() => error = 'Disconnected - controls disabled. Check the lab terminals.');
|
||||
} finally { polling = false; }
|
||||
}
|
||||
Future<void> command(String action) async {
|
||||
if (pending || error != null || cabinet == null) return;
|
||||
revision++;
|
||||
setState(() { pending = true; notice = 'Waiting for cabinet acknowledgement...'; });
|
||||
try {
|
||||
final response = await client.post(Uri.parse('$base/command'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'id': 'gm-${DateTime.now().microsecondsSinceEpoch}-${sequence++}',
|
||||
'session': cabinet!['session'],
|
||||
'expires_ms': DateTime.now().millisecondsSinceEpoch + 10000,
|
||||
'action': action,
|
||||
})).timeout(const Duration(seconds: 4));
|
||||
if (response.statusCode != 200) throw Exception('No acknowledgement');
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
if (mounted) setState(() {
|
||||
cabinet = data['state'];
|
||||
notice = '${data['accepted'] == true ? 'Accepted' : 'Rejected'}: ${data['message']}';
|
||||
error = null;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() {
|
||||
error = 'Delivery unconfirmed. Refresh state before trying again.';
|
||||
notice = 'No success acknowledgement received.';
|
||||
});
|
||||
} finally { if (mounted) setState(() => pending = false); }
|
||||
}
|
||||
@override
|
||||
void dispose() { timer?.cancel(); client.close(); super.dispose(); }
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final phase = cabinet?['phase'] ?? 'unknown';
|
||||
final available = cabinet != null && error == null && !pending;
|
||||
Widget control(String label, String action, bool enabled) => FilledButton(
|
||||
onPressed: available && enabled ? () => command(action) : null, child: Text(label));
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Escape Gallery / Glitch: the Dark Machine')),
|
||||
body: Center(child: ConstrainedBox(constraints: const BoxConstraints(maxWidth: 900),
|
||||
child: ListView(padding: const EdgeInsets.all(24), children: [
|
||||
Text(cabinet?['simulated'] == false ? 'INTEGRATION LAB - REAL GODOT CABINET' : 'INTEGRATION LAB - SIMULATED CABINET', style: const TextStyle(color: Colors.amber)),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Cabinet 1 / Pac-Man', style: TextStyle(fontSize: 28)),
|
||||
Text(cabinet?['simulated'] == false ? 'Real Pac-Man. Target: 500 points. No reward outputs. RFID injection: ${cabinet?['rfid_injection'] == true ? 'enabled' : 'disabled'}.' : 'No physical RFID, LEDs, GPIO, or arcade game is connected.'),
|
||||
const SizedBox(height: 24),
|
||||
Card(child: Padding(padding: const EdgeInsets.all(24), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(phase.replaceAll('_', ' ').toUpperCase(), style: const TextStyle(fontSize: 32)),
|
||||
Text('Credits: ${cabinet?['credits'] ?? '-'} Accepted scans: ${cabinet?['scans'] ?? '-'}'),
|
||||
const SizedBox(height: 12),
|
||||
Text(phase == 'powered_off' ? 'Waiting for operator' :
|
||||
phase == 'booting' ? 'Four-second boot sequence...' :
|
||||
phase == 'ready' ? 'Scan a card, then press Start' :
|
||||
phase == 'playing' ? 'Game in progress' :
|
||||
phase == 'won' ? 'Objective completed' : phase == 'lost' ? 'Game lost - reset the test to retry' : 'Awaiting state'),
|
||||
]))),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(spacing: 12, runSpacing: 12, children: [
|
||||
control('Power on', 'power_on', phase == 'powered_off'),
|
||||
control('Simulate RFID scan', 'scan', phase == 'ready' && (cabinet?['simulated'] == true || cabinet?['rfid_injection'] == true)),
|
||||
control('Start game', 'start', phase == 'ready' && cabinet?['credits'] == 1),
|
||||
control('Simulate win', 'win', phase == 'playing' && cabinet?['simulated'] == true),
|
||||
control('Reset test', 'reset', true),
|
||||
]),
|
||||
const SizedBox(height: 24),
|
||||
Text(error ?? notice, style: TextStyle(color: error == null ? Colors.white70 : Colors.orange)),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Flutter → local JSON gateway → NATS / MessagePack → simulated cabinet'),
|
||||
]))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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](),
|
||||
])))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'teddy_audio_stub.dart' if (dart.library.html) 'teddy_audio_web.dart';
|
||||
@@ -0,0 +1,9 @@
|
||||
class TeddyAudio {
|
||||
bool enabled = false;
|
||||
String status = 'Audio disabled on this device';
|
||||
Future<void> enable() async { enabled = true; }
|
||||
void sync(String? url) {}
|
||||
void playNow(String url) { enabled = true; }
|
||||
void disable() { enabled = false; }
|
||||
void dispose() {}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// ignore_for_file: deprecated_member_use
|
||||
import 'dart:html' as html;
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:math' as math;
|
||||
|
||||
class TeddyAudio {
|
||||
final html.AudioElement _audio = html.AudioElement();
|
||||
bool enabled = false;
|
||||
String status = 'Audio disabled on this device';
|
||||
String? _url;
|
||||
int _generation = 0;
|
||||
TeddyAudio() {
|
||||
_audio.onEnded.listen((_) { status = 'Cue finished'; });
|
||||
_audio.onError.listen((_) { status = 'Browser audio error ${_audio.error?.code}. Use Play current hint here to retry.'; });
|
||||
}
|
||||
Future<void> enable() async {
|
||||
// Unlock this exact media element in the user's tap handler (required on phones).
|
||||
final data = ByteData(16044);
|
||||
void text(int offset, String value) { for (var i=0;i<value.length;i++) { data.setUint8(offset+i,value.codeUnitAt(i)); } }
|
||||
text(0,'RIFF'); data.setUint32(4,16036,Endian.little); text(8,'WAVEfmt ');
|
||||
data.setUint32(16,16,Endian.little); data.setUint16(20,1,Endian.little);
|
||||
data.setUint16(22,1,Endian.little); data.setUint32(24,8000,Endian.little);
|
||||
data.setUint32(28,16000,Endian.little); data.setUint16(32,2,Endian.little);
|
||||
data.setUint16(34,16,Endian.little); text(36,'data'); data.setUint32(40,16000,Endian.little);
|
||||
for (var i=0;i<8000;i++) { data.setInt16(44+i*2,(4000*math.sin(2*math.pi*440*i/8000)).round(),Endian.little); }
|
||||
_audio.src = 'data:audio/wav;base64,${base64Encode(data.buffer.asUint8List())}';
|
||||
try {
|
||||
await _audio.play();
|
||||
enabled = true;
|
||||
_url = null;
|
||||
status = 'Speaker test playing (one-second tone)';
|
||||
} catch (_) {
|
||||
enabled = false;
|
||||
status = 'Browser blocked audio. Tap Enable audio again.';
|
||||
}
|
||||
}
|
||||
void playNow(String url) { enabled = true; sync(url, force:true); }
|
||||
void sync(String? url, {bool force=false}) {
|
||||
if (!enabled || (!force && url == _url)) return;
|
||||
_generation++;
|
||||
final generation = _generation;
|
||||
_audio.pause();
|
||||
_url = url;
|
||||
if (url == null) { status = 'Waiting for Teddy'; return; }
|
||||
_audio.src = url;
|
||||
status = 'Loading Teddy audio';
|
||||
_audio.play().then((_) {
|
||||
if (generation == _generation) status = 'Playing on this device';
|
||||
}).catchError((Object error) {
|
||||
if (generation == _generation) status = 'Playback blocked or unavailable. Tap Play current hint here.';
|
||||
});
|
||||
}
|
||||
void disable() {
|
||||
_generation++;
|
||||
enabled = false;
|
||||
_url = null;
|
||||
_audio.pause();
|
||||
status = 'Audio disabled on this device';
|
||||
}
|
||||
void dispose() { disable(); _audio.src = ''; }
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.20"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.2"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.4.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
sdks:
|
||||
dart: ">=3.11.0-0 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
@@ -0,0 +1,15 @@
|
||||
name: glitch_gm_lab
|
||||
description: Isolated Glitch game master integration experiment.
|
||||
publish_to: none
|
||||
version: 0.1.0+1
|
||||
environment:
|
||||
sdk: '>=3.9.2 <4.0.0'
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
http: ^1.5.0
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"state":{"active_ms":10000,"armed":[],"assisted":false,"boss":"DARK","cell_faults":["SOCKET01 needs CELL01","CELL01 is not charged","SOCKET02 needs CELL02","CELL02 is not charged","SOCKET03 needs CELL03","CELL03 is not charged","SOCKET04 needs CELL04","CELL04 is not charged","SOCKET05 needs CELL05","CELL05 is not charged","SOCKET06 needs CELL06","CELL06 is not charged"],"config_hash":"38b54e5bdcdc9df3b9852cf204bcb5b1973018d94bc4ad04565b7bb959f68d20","countdown":null,"effects":[{"id":"sim-1792303569839-next:token.dispense:T01","kind":"token.dispense","status":"OBSERVED","target":"T01"}],"groups":[],"hold":"","last_progress":0,"launch_epoch":0,"launches":0,"lifecycle":"RUNNING","limit_ms":3600000,"location_since":{},"locations":{"BAY01":"CELL01","BAY02":"CELL02","BAY03":"CELL03","BAY04":"CELL04","BAY05":"CELL05","BAY06":"CELL06"},"operators":5,"outcome":null,"party":6,"passage_open":false,"passage_since":null,"phase":"ARCADE","power_since":null,"profile":"standard","puzzles":[{"delivered":false,"id":"P01","solved":true,"token":""},{"delivered":true,"id":"P02A","solved":true,"token":"T01"},{"delivered":false,"id":"P02B","solved":false,"token":"T02"},{"delivered":false,"id":"P03","solved":false,"token":"T03"},{"delivered":false,"id":"P04","solved":false,"token":"T04"},{"delivered":false,"id":"P05","solved":false,"token":"T05"},{"delivered":false,"id":"P06","solved":false,"token":"T06"}],"ready_deadline":null,"reveal_at":0,"revision":100,"run_id":"sim-1792303569839-next","seed":42,"ship_power":false,"simulation":true,"stations":[{"attempt":1,"authorized":true,"bay_open":false,"cell":"CELL01","charge_ms":10000,"charging":true,"game":"pacman","id":"cabinet_01","last_target":null,"losses":0,"online":true,"passes":0,"progress":0,"retry_at":0,"socket":"SOCKET01","status":"PLAYING","target":500,"token":"T01","won":false},{"attempt":0,"authorized":false,"bay_open":false,"cell":"CELL02","charge_ms":0,"charging":false,"game":"galaga","id":"cabinet_02","last_target":null,"losses":0,"online":true,"passes":0,"progress":0,"retry_at":0,"socket":"SOCKET02","status":"TOKEN_WAIT","target":500,"token":"T02","won":false},{"attempt":0,"authorized":false,"bay_open":false,"cell":"CELL03","charge_ms":0,"charging":false,"game":"tempest","id":"cabinet_03","last_target":null,"losses":0,"online":true,"passes":0,"progress":0,"retry_at":0,"socket":"SOCKET03","status":"TOKEN_WAIT","target":500,"token":"T03","won":false},{"attempt":0,"authorized":false,"bay_open":false,"cell":"CELL04","charge_ms":0,"charging":false,"game":"missile_command","id":"cabinet_04","last_target":null,"losses":0,"online":true,"passes":0,"progress":0,"retry_at":0,"socket":"SOCKET04","status":"TOKEN_WAIT","target":500,"token":"T04","won":false},{"attempt":0,"authorized":false,"bay_open":false,"cell":"CELL05","charge_ms":0,"charging":false,"game":"frogger","id":"cabinet_05","last_target":null,"losses":0,"online":true,"passes":0,"progress":0,"retry_at":0,"socket":"SOCKET05","status":"TOKEN_WAIT","target":1,"token":"T05","won":false},{"attempt":0,"authorized":false,"bay_open":false,"cell":"CELL06","charge_ms":0,"charging":false,"game":"space_invaders","id":"cabinet_06","last_target":null,"losses":0,"online":true,"passes":0,"progress":0,"retry_at":0,"socket":"SOCKET06","status":"TOKEN_WAIT","target":500,"token":"T06","won":false}],"teddy":{"current":null,"hint_level":0,"mode":"GUIDE","muted":false,"next_voice_at":0,"online":true,"played":["welcome"],"queue":[],"takeover":false,"transcript":"I remember this place full of people. The machines remember too. Find their tokens. Each one belongs to only one machine."},"tutorial":0,"wall_ms":10000,"wave":null,"wave_serial":0}}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:glitch_gm_lab/hint_panel.dart';
|
||||
|
||||
void main() {
|
||||
test('suggestions follow parallel branches and claw prerequisites', () {
|
||||
final room=<String,dynamic>{'phase':'ARCADE','puzzles':[
|
||||
{'id':'P01','solved':false,'delivered':false},
|
||||
{'id':'P02A','solved':false,'delivered':false},
|
||||
{'id':'P02B','solved':false,'delivered':false},
|
||||
{'id':'P03','solved':false,'delivered':false},
|
||||
{'id':'P04','solved':false,'delivered':false},
|
||||
]};
|
||||
Map<String,dynamic> hint(String target) => {'phase':'ARCADE','target':target,'condition':'incomplete'};
|
||||
expect(hintReason(hint('P01'),room),isNotNull);
|
||||
expect(hintReason(hint('P03'),room),isNotNull);
|
||||
expect(hintReason(hint('P04'),room),isNotNull);
|
||||
expect(hintReason(hint('P02A'),room),isNull);
|
||||
(room['puzzles'] as List)[0]['solved']=true;
|
||||
expect(hintReason(hint('P01'),room),isNull);
|
||||
expect(hintReason(hint('P02A'),room),isNotNull);
|
||||
expect(hintReason(hint('P02B'),room),isNull);
|
||||
(room['puzzles'] as List)[1]['delivered']=true;
|
||||
expect(hintReason(hint('P02B'),room),isNotNull);
|
||||
room['phase']='CELLS';
|
||||
expect(hintReason(hint('P03'),room),isNull);
|
||||
expect(hintReason({'phase':'ALL','target':'room','condition':'any'},room),isNotNull);
|
||||
});
|
||||
test('cabinet first wins remove incomplete hints', () {
|
||||
final room=<String,dynamic>{'phase':'ARCADE','stations':[{'id':'cabinet_01','won':false}]};
|
||||
final hint=<String,dynamic>{'phase':'ALL','target':'cabinet_01','condition':'incomplete'};
|
||||
expect(hintReason(hint,room),isNotNull);
|
||||
(room['stations'] as List)[0]['won']=true;
|
||||
expect(hintReason(hint,room),isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:glitch_gm_lab/room_page.dart';
|
||||
|
||||
void main() {
|
||||
for (final width in [390.0, 900.0, 1400.0]) {
|
||||
testWidgets('room panels fit at width $width and use authoritative revision', (tester) async {
|
||||
WidgetController.hitTestWarningShouldBeFatal = true;
|
||||
addTearDown(() => WidgetController.hitTestWarningShouldBeFatal = false);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = Size(width, 1000);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final data = jsonDecode(File('test/fixtures/room.json').readAsStringSync());
|
||||
final state = data['state'] as Map<String, dynamic>;
|
||||
Map<String, dynamic>? sent;
|
||||
final client = MockClient((request) async {
|
||||
if (request.method == 'POST') {
|
||||
sent = jsonDecode(request.body);
|
||||
return http.Response(jsonEncode({'accepted':true,'message':'test accepted','state':state}), 200);
|
||||
}
|
||||
if (request.url.path.endsWith('trace')) {
|
||||
return http.Response(jsonEncode({'trace':[],'final_hash':'fixture'}),200);
|
||||
}
|
||||
return http.Response(jsonEncode({'state':state}),200);
|
||||
});
|
||||
await tester.pumpWidget(MaterialApp(home:RoomPage(client:client)));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('Connected. The room timer'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
Future<void> select(String name) async {
|
||||
if (width < 900) { await tester.tap(find.byTooltip('Open navigation menu')); await tester.pumpAndSettle(); }
|
||||
await tester.ensureVisible(find.text(name)); await tester.tap(find.text(name)); await tester.pumpAndSettle();
|
||||
}
|
||||
await select('Diagnostics');
|
||||
await tester.ensureVisible(find.text('+ 1 second'));
|
||||
await tester.tap(find.text('+ 1 second'));
|
||||
await tester.pump();
|
||||
expect(sent?['expected_revision'], state['revision']);
|
||||
expect(sent?['run_id'], state['run_id']);
|
||||
for (final tab in ['Cabinets','Ship & Cells','Calibration & Launch','Teddy','Diagnostics','Room Flow']) {
|
||||
await select(tab);
|
||||
expect(tester.takeException(), isNull, reason:tab);
|
||||
}
|
||||
state['phase']='CALIBRATION';
|
||||
state['wave']={'id':99,'targets':{'cabinet_01':2},'ready':{},'deadline':40000,'retry_at':0};
|
||||
await tester.pump(const Duration(seconds:1));
|
||||
await tester.pump();
|
||||
await select('Calibration & Launch');
|
||||
expect(find.text('Simulate held Ready'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
}
|
||||
testWidgets('disconnected room disables mutating controls', (tester) async {
|
||||
await tester.pumpWidget(MaterialApp(home:RoomPage(client:MockClient((_) async => http.Response('{}',503)))));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('disconnected'),findsOneWidget);
|
||||
for (final button in tester.widgetList<OutlinedButton>(find.byType(OutlinedButton))) {
|
||||
expect(button.onPressed,isNull);
|
||||
}
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:glitch_gm_lab/main.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('commands remain disabled without cabinet state', (tester) async {
|
||||
final client = MockClient((_) async => http.Response('{}', 503));
|
||||
await tester.pumpWidget(MaterialApp(home: CabinetPage(client: client)));
|
||||
await tester.pump();
|
||||
expect(find.text('INTEGRATION LAB - SIMULATED CABINET'), findsOneWidget);
|
||||
expect(find.text('Power on'), findsOneWidget);
|
||||
for (final button in tester.widgetList<FilledButton>(find.byType(FilledButton))) {
|
||||
expect(button.onPressed, isNull);
|
||||
}
|
||||
expect(find.textContaining('Disconnected'), findsOneWidget);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{{flutter_js}}
|
||||
{{flutter_build_config}}
|
||||
// The live lab must not retain an older operator UI across local rebuilds.
|
||||
for (const build of _flutter.buildConfig.builds) {
|
||||
if (build.mainJsPath) build.mainJsPath += '?v=' + Date.now();
|
||||
}
|
||||
_flutter.loader.load();
|
||||
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Glitch GM Lab | Escape Gallery</title>
|
||||
</head>
|
||||
<body><script>
|
||||
const bootstrap = document.createElement('script');
|
||||
bootstrap.src = 'flutter_bootstrap.js?v=' + Date.now();
|
||||
bootstrap.async = true;
|
||||
document.body.appendChild(bootstrap);
|
||||
</script></body>
|
||||
</html>
|
||||
Reference in new issue
Block a user