mirror of
https://github.com/chenasraf/mudblock.git
synced 2026-05-17 17:48:05 +00:00
422 lines
11 KiB
Dart
422 lines
11 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
|
|
import 'package:ctelnet/ctelnet.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:mudblock/core/profile_presets.dart';
|
|
import 'package:mudblock/core/storage.dart';
|
|
import 'package:mudblock/pages/select_profile_page.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import 'color_utils.dart';
|
|
import 'consts.dart';
|
|
import 'features/alias.dart';
|
|
import 'features/game_button_set.dart';
|
|
import 'features/profile.dart';
|
|
import 'features/trigger.dart';
|
|
import 'features/variable.dart';
|
|
|
|
const maxLines = 2000;
|
|
|
|
class GameStore extends ChangeNotifier {
|
|
final List<String> _lines = [];
|
|
late CTelnetClient _client;
|
|
final ScrollController scrollController = ScrollController();
|
|
final TextEditingController input = TextEditingController();
|
|
final FocusNode inputFocus = FocusNode();
|
|
bool isCompressed = false;
|
|
final ZLibDecoder decoder = ZLibDecoder();
|
|
final msgSplitPattern = RegExp("($cr$lf)|($lf$cr)|$cr|$lf");
|
|
final outgoingMsgSplitPattern = RegExp("($cr$lf)|($lf$cr)|$cr|$lf|$csp");
|
|
final ZLibCodec _decoder = ZLibCodec();
|
|
final StreamController<List<int>> _rawStreamController = StreamController();
|
|
late Stream<List<int>> _decodedStream;
|
|
late StreamSubscription<List<int>> _decodedSub;
|
|
late final List<MUDProfile> profiles = [];
|
|
MUDProfile? _currentProfile;
|
|
bool _clientReady = false;
|
|
|
|
// TODO move to settings
|
|
/// command separator
|
|
static const csp = ";";
|
|
|
|
// features
|
|
// TODO - move to MUDProfile and make that reactive
|
|
final List<Trigger> triggers = [];
|
|
final List<Alias> aliases = [];
|
|
final Map<String, Variable> variables = {};
|
|
final List<GameButtonSetData> buttonSets = [];
|
|
|
|
MUDProfile get currentProfile => _currentProfile!;
|
|
|
|
Future<GameStore> init() async {
|
|
debugPrint('GameStore.init');
|
|
fillStockProfiles();
|
|
return this;
|
|
}
|
|
|
|
void connect(BuildContext context) async {
|
|
final profile = await showDialog<MUDProfile?>(
|
|
context: context,
|
|
builder: (context) {
|
|
return const SelectProfilePage();
|
|
});
|
|
if (profile == null) {
|
|
return;
|
|
}
|
|
_currentProfile = profile;
|
|
echo('Connecting...');
|
|
_client = CTelnetClient(
|
|
host: currentProfile.host,
|
|
port: currentProfile.port,
|
|
onConnect: _onConnect,
|
|
onDisconnect: onDisconnect,
|
|
onData: onData,
|
|
onError: onError,
|
|
);
|
|
await Future.wait([
|
|
loadTriggers(),
|
|
loadAliases(),
|
|
loadVariables(),
|
|
loadButtonSets(),
|
|
]);
|
|
_client.connect();
|
|
}
|
|
|
|
Future<void> loadTriggers() async {
|
|
debugPrint('loadTriggers');
|
|
final list = await currentProfile.loadTriggers();
|
|
triggers.clear();
|
|
triggers.addAll(list);
|
|
notifyListeners();
|
|
debugPrint('Triggers: ${triggers.length}');
|
|
}
|
|
|
|
Future<void> loadAliases() async {
|
|
final list = await currentProfile.loadAliases();
|
|
aliases.clear();
|
|
aliases.addAll(list);
|
|
notifyListeners();
|
|
debugPrint('Aliases: ${aliases.length}');
|
|
}
|
|
|
|
Future<void> loadVariables() async {
|
|
final list = await currentProfile.loadVariables();
|
|
variables.clear();
|
|
variables.addAll(Map.fromEntries(list.map((e) => MapEntry(e.name, e))));
|
|
notifyListeners();
|
|
debugPrint('Variables: ${variables.length}');
|
|
}
|
|
|
|
Future<void> loadButtonSets() async {
|
|
final list = await currentProfile.loadButtonSets();
|
|
buttonSets.clear();
|
|
buttonSets.addAll(list);
|
|
notifyListeners();
|
|
debugPrint('ButtonSets: ${buttonSets.length}');
|
|
}
|
|
|
|
bool processTriggers(String line) {
|
|
bool showLine = true;
|
|
final str = ColorUtils.stripColor(line);
|
|
for (final trigger in triggers) {
|
|
if (!trigger.isAvailable) {
|
|
continue;
|
|
}
|
|
if (trigger.matches(str)) {
|
|
trigger.invokeEffect(this, str);
|
|
if (trigger.isRemovedFromBuffer) {
|
|
showLine = false;
|
|
}
|
|
if (trigger.autoDisable) {
|
|
trigger.tempDisabled = true;
|
|
}
|
|
}
|
|
}
|
|
return showLine;
|
|
}
|
|
|
|
bool processAliases(String line) {
|
|
bool sendLine = true;
|
|
final str = line;
|
|
for (final alias in aliases) {
|
|
if (!alias.isAvailable) {
|
|
continue;
|
|
}
|
|
if (alias.matches(str)) {
|
|
alias.invokeEffect(this, str);
|
|
sendLine = false;
|
|
}
|
|
if (alias.autoDisable) {
|
|
alias.tempDisabled = true;
|
|
}
|
|
}
|
|
return sendLine;
|
|
}
|
|
|
|
Future<void> _onConnect() async {
|
|
_clientReady = true;
|
|
echo('Connected');
|
|
if (currentProfile.authMethod != AuthMethod.none &&
|
|
currentProfile.username.isNotEmpty &&
|
|
currentProfile.password.isNotEmpty) {
|
|
debugPrint('Sending username and password');
|
|
if (currentProfile.authMethod == AuthMethod.diku) {
|
|
await Future.delayed(const Duration(milliseconds: 100));
|
|
send(currentProfile.username);
|
|
await Future.delayed(const Duration(milliseconds: 100));
|
|
send(currentProfile.password);
|
|
}
|
|
}
|
|
}
|
|
|
|
void requestMCCP() {
|
|
debugPrint('requestMCCP');
|
|
_client.doo(86);
|
|
sendBytes([Symbols.iac, Symbols.doo, 86]);
|
|
}
|
|
|
|
Future<void> disconnect() {
|
|
return _client.disconnect();
|
|
}
|
|
|
|
void onDisconnect() {
|
|
echo('Disconnected');
|
|
}
|
|
|
|
void onRawData(List<int> bytes) {
|
|
try {
|
|
final data = Message(bytes);
|
|
handleMCCPHandshake(data);
|
|
for (final line in data.text.trimRight().split(msgSplitPattern)) {
|
|
onLine(line);
|
|
}
|
|
} catch (e, stack) {
|
|
debugPrint('error: $e$newline$stack');
|
|
echo(String.fromCharCodes(bytes));
|
|
echo('Error: $e');
|
|
}
|
|
}
|
|
|
|
void onData(Message data) {
|
|
try {
|
|
if (currentProfile.mccpEnabled && isCompressed) {
|
|
_rawStreamController.add(data.bytes);
|
|
return;
|
|
}
|
|
if (currentProfile.mccpEnabled) {
|
|
handleMCCPHandshake(data);
|
|
}
|
|
|
|
for (final line in data.text.trimRight().split(msgSplitPattern)) {
|
|
onLine(line);
|
|
}
|
|
} catch (e, stack) {
|
|
debugPrint('error: $e$newline$stack');
|
|
echo(data.text);
|
|
echo('Error: $e');
|
|
}
|
|
}
|
|
|
|
void handleMCCPHandshake(Message data) {
|
|
if (isCompressed) {
|
|
if (data.se()) {
|
|
disableMCCP();
|
|
}
|
|
} else {
|
|
if (data.sb(86)) {
|
|
enableMCCP();
|
|
}
|
|
if (data.will(86)) {
|
|
requestMCCP();
|
|
echo('Compression requested');
|
|
}
|
|
}
|
|
}
|
|
|
|
void disableMCCP() {
|
|
echo('Compression disabled');
|
|
isCompressed = false;
|
|
_decodedSub.cancel();
|
|
}
|
|
|
|
void enableMCCP() {
|
|
isCompressed = true;
|
|
_decodedStream = _decoder.decoder.bind(_rawStreamController.stream);
|
|
_decodedSub = _decodedStream.listen(onRawData);
|
|
echo('Compression enabled');
|
|
}
|
|
|
|
void onError(Object error) {
|
|
echo('Error: $error');
|
|
}
|
|
|
|
void onLine(String line) {
|
|
final showLine = processTriggers(line);
|
|
if (showLine) {
|
|
echo(line);
|
|
}
|
|
}
|
|
|
|
List<String> get lines =>
|
|
_lines.sublist(max(0, _lines.length - maxLines), _lines.length);
|
|
|
|
void echo(String line) {
|
|
_lines.add(line);
|
|
notifyListeners();
|
|
scrollToEnd();
|
|
}
|
|
|
|
void echoOwn(String line) {
|
|
_lines.add('$esc[93m$line');
|
|
notifyListeners();
|
|
scrollToEnd();
|
|
}
|
|
|
|
void sendBytes(List<int> bytes) {
|
|
var output = bytes;
|
|
_client.sendBytes(output);
|
|
}
|
|
|
|
void sendString(String line) {
|
|
debugPrint('sending string: $line');
|
|
_client.send(line + newline);
|
|
}
|
|
|
|
void send(String text) {
|
|
for (final line in text.trimRight().split(outgoingMsgSplitPattern)) {
|
|
if (isCompressed) {
|
|
debugPrint(
|
|
'sending bytes${isCompressed ? ' (compressed)' : ''}: $line');
|
|
sendBytes(line.codeUnits + newline.codeUnits);
|
|
} else {
|
|
debugPrint('sending string: $line');
|
|
sendString(line);
|
|
}
|
|
}
|
|
}
|
|
|
|
void execute(String text) {
|
|
debugPrint('processing aliases for: $text');
|
|
for (final line in text.trimRight().split(outgoingMsgSplitPattern)) {
|
|
var sendLine = processAliases(line);
|
|
if (sendLine) {
|
|
sendString(line);
|
|
}
|
|
}
|
|
}
|
|
|
|
void submitInput(String text) {
|
|
if (!_clientReady || !_client.connected) {
|
|
return;
|
|
}
|
|
echoOwn(text);
|
|
execute(text);
|
|
scrollToEnd();
|
|
selectInput();
|
|
}
|
|
|
|
void scrollToEnd() {
|
|
Future.delayed(const Duration(milliseconds: 50), () {
|
|
scrollController.animateTo(
|
|
scrollController.position.maxScrollExtent,
|
|
duration: const Duration(milliseconds: 50),
|
|
curve: Curves.easeIn,
|
|
);
|
|
});
|
|
}
|
|
|
|
void unselectInput() {
|
|
input.selection = const TextSelection.collapsed(offset: -1);
|
|
}
|
|
|
|
void selectInput() {
|
|
// if (inputFocus.hasFocus || inputFocus.hasPrimaryFocus) {
|
|
// return;
|
|
// }
|
|
input.selection = TextSelection(
|
|
baseOffset: 0,
|
|
extentOffset: input.text.length,
|
|
);
|
|
inputFocus.previousFocus();
|
|
inputFocus.requestFocus();
|
|
}
|
|
|
|
void setInput(String content) {
|
|
input.text = content;
|
|
selectInput();
|
|
}
|
|
|
|
static consumer({
|
|
Widget? child,
|
|
required Widget Function(
|
|
BuildContext context, GameStore value, Widget? child)
|
|
builder,
|
|
}) {
|
|
return Consumer<GameStore>(
|
|
builder: builder,
|
|
child: child,
|
|
);
|
|
}
|
|
|
|
static provider({
|
|
Widget? child,
|
|
Widget Function(BuildContext context, Widget? child)? builder,
|
|
}) {
|
|
return ChangeNotifierProvider<GameStore>.value(
|
|
value: gameStore,
|
|
builder: builder,
|
|
child: child,
|
|
);
|
|
}
|
|
|
|
void loadProfiles() async {
|
|
final list = await ProfileStorage.listAllProfiles();
|
|
profiles.clear();
|
|
debugPrint('loading profiles: $list');
|
|
for (final name in list) {
|
|
final profile = await ProfileStorage.readProfileFile(name, name);
|
|
profiles.add(MUDProfile.fromJson(profile!));
|
|
}
|
|
if (_currentProfile != null) {
|
|
_currentProfile = profiles.firstWhere((e) => e.id == currentProfile.id);
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
void fillStockProfiles() async {
|
|
final list = await ProfileStorage.listAllProfiles();
|
|
debugPrint('existing profiles: $list');
|
|
if (list.isEmpty) {
|
|
debugPrint('filling stock profiles');
|
|
for (final profile in profilePresets) {
|
|
await MUDProfile.save(profile);
|
|
profiles.add(profile);
|
|
}
|
|
} else {
|
|
debugPrint('loading profiles: $list');
|
|
for (final name in list) {
|
|
final profile = await ProfileStorage.readProfileFile(name, name);
|
|
debugPrint('profile: $profile');
|
|
profiles.add(MUDProfile.fromJson(profile!));
|
|
}
|
|
}
|
|
debugPrint('profiles: ${profiles.map((e) => [e.name, e.password])}');
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
mixin GameStoreMixin {
|
|
GameStore storeOf(BuildContext context) =>
|
|
Provider.of<GameStore>(context, listen: false);
|
|
}
|
|
|
|
mixin GameStoreStateMixin<T extends StatefulWidget> on State<T> {
|
|
GameStore get store => Provider.of<GameStore>(context, listen: false);
|
|
}
|
|
|
|
final gameStore = GameStore();
|
|
|