Added chatterbox connections

This commit is contained in:
Jeff Luke committed 2026-10-18 10:54:38 -07:00
1 parent 41ef12f954
commit e58ceeb192
58 files changed
+8931 -8

No files matched your search

+169
View File
@@ -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...')),
]),
]);
}
}
+126
View File
@@ -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'),
]))),
);
}
}
+437
View File
@@ -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](),
])))),
]),
);
}
}
+1
View File
@@ -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 = ''; }
}