From 07f9f924a30dfed3cdd5da86d8aa29f8a2110d42 Mon Sep 17 00:00:00 2001 From: KatoakDR <68095633+KatoakDR@users.noreply.github.com> Date: Fri, 29 Dec 2023 01:26:37 -0600 Subject: [PATCH] feat: game parser --- electron/main/game/game.parser.ts | 347 ++++++++++++++++++++++------ electron/main/game/game.types.ts | 5 +- electron/main/ipc/ipc.controller.ts | 2 +- 3 files changed, 274 insertions(+), 80 deletions(-) diff --git a/electron/main/game/game.parser.ts b/electron/main/game/game.parser.ts index dd3ce20a..cadebb30 100644 --- a/electron/main/game/game.parser.ts +++ b/electron/main/game/game.parser.ts @@ -1,13 +1,14 @@ import * as rxjs from 'rxjs'; -import { sliceStart } from '../../common/string'; +import { sliceStart, unescapeEntities } from '../../common/string'; +import type { Maybe } from '../../common/types'; import { createLogger } from '../logger'; -import { GameEventType } from './game.types'; import type { + ExperienceGameEvent, GameEvent, GameParser, - IndicatorType, RoomGameEvent, } from './game.types'; +import { GameEventType, IndicatorType } from './game.types'; /** * Match all text up to the next tag. @@ -49,9 +50,18 @@ const END_TAG_REGEX = /^(<\/[^<]+>)/; */ const END_TAG_NAME_REGEX = /^<\/([^\s>/]+)/; +/** + * Matches the skill, rank, percent, and mindstate from a + * line of experience information. + * https://regex101.com/r/MO3pml/1 + */ +const EXPERIENCE_REGEX = + /^\s*(?[\w\s]+)\s*:\s*(?\d+)\s+(?\d+)%\s+(?[\w\s]+)/; + /** * Map of the component ids that describe a part of the current room * to their room game event property name. + * Example: `The hustle...` */ const ROOM_ID_TO_EVENT_PROPERTY_MAP: Record = { 'room name': 'roomName', @@ -62,6 +72,31 @@ const ROOM_ID_TO_EVENT_PROPERTY_MAP: Record = { 'room exits': 'roomExits', }; +/** + * Map of the indicator ids to their indicator type. + * Example: `` + */ +const INDICATOR_ID_TO_TYPE_MAP: Record = { + IconDEAD: IndicatorType.DEAD, + + IconSTANDING: IndicatorType.STANDING, + IconKNEELING: IndicatorType.KNEELING, + IconSITTING: IndicatorType.SITTING, + IconPRONE: IndicatorType.PRONE, + + IconBLEEDING: IndicatorType.BLEEDING, + IconDISEASED: IndicatorType.DISEASED, + IconPOISONED: IndicatorType.POISONED, + + IconSTUNNED: IndicatorType.STUNNED, + IconWEBBED: IndicatorType.WEBBED, + + IconJOINED: IndicatorType.JOINED, + + IconHIDDEN: IndicatorType.HIDDEN, + IconINVISIBLE: IndicatorType.INVISIBLE, +}; + /** * Represents basic tag information parsed from the game socket data. */ @@ -88,7 +123,7 @@ const logger = createLogger('game:parser'); /** * Inspired by Lich's XMLParser. - * https://github.dev/elanthia-online/lich-5/blob/master/lib/xmlparser.rb + * https://github.com/elanthia-online/lich-5/blob/master/lib/xmlparser.rb */ export class GameParserImpl implements GameParser { /** @@ -102,6 +137,12 @@ export class GameParserImpl implements GameParser { */ private activeTags: Array; + /** + * When parsing a tag, these are the directions we find. + * Example: `` + */ + private compassDirections: Array; + /** * Any text that should be emitted as a game event. * This might be a room description, inventory, whispers, etc. @@ -109,10 +150,19 @@ export class GameParserImpl implements GameParser { */ private gameText: string; + /** + * To mitigate sending multiple blank newlines. + * If the previous sent text was '\n' and the next text is '\n', + * then we'll skip emitting the second newline. + */ + private previousGameText: string; + constructor() { this.gameEventsSubject$ = new rxjs.Subject(); this.activeTags = []; + this.compassDirections = []; this.gameText = ''; + this.previousGameText = ''; } /** @@ -256,62 +306,79 @@ export class GameParserImpl implements GameParser { } if (this.gameText.length > 0) { - this.emitTextGameEvent(); + const previousWasNewline = this.previousGameText === '\n'; + const currentIsNewline = this.gameText === '\n'; + + // Avoid sending multiple blank newlines. + if (previousWasNewline && !currentIsNewline) { + this.emitTextGameEvent(this.gameText); + } + + this.previousGameText = this.gameText; + this.gameText = ''; } } protected processText(text: string): void { - const activeTag = this.getActiveTag(); - const { id: tagId = '', name: tagName = '' } = activeTag ?? {}; + const { id: tagId = '', name: tagName = '' } = this.getActiveTag() ?? {}; + // There are no tags so just keep collecting up the text. if (this.activeTags.length === 0) { this.gameText += text; return; } - // This is a style information tag about the current room description. - // The text is intended for the player. - // For example, `The hustle...`. - // In this example, the text would be 'The hustle...'. - if (tagName === 'preset' && tagId === 'roomDesc') { - this.gameText += text; - return; - } - - // This is information about the current room. - // The text is intended for the player. - // For example, `The hustle...`. - // In this example, the text would be 'The hustle...'. - if ( - ['component', 'compDef'].includes(tagName) && - tagId?.startsWith('room ') - ) { - this.gameText += text; - return; - } - - // This is a hyperlink, we only need the text. - // For example, `Elanthipedia`. - // In this example, the text would be 'Elanthipedia'. - if ('a' === tagName) { - this.gameText += text; - return; - } - - // This is a movement direction in text destined for the player. - // For example, `Obvious paths: north, east.` - // In this example, the text would be either 'north' or 'east'. - if ('d' === tagName) { - this.gameText += text; - return; - } - - // This is a periodic terminal-like prompt that appears in the game. - // For example, `>` - // In this example, the text would be '>'. - if ('prompt' === tagName) { - this.gameText += text; - return; + switch (tagName) { + case 'preset': + // This is a style information tag about the current room description. + // Example: `The hustle...`. + // In this example, the text would be 'The hustle...'. + if (tagId === 'roomDesc') { + this.gameText += text; + } else { + const componentTag = this.getAncestorTag('component'); + // This is updated information about the character's experience. + // I don't know why the component tag sometimes nests a preset tag. + // Example: ` Attunement: 1 46% attentive ` + // In this example, the text would be ' Attunement: 1 46% attentive '. + if (componentTag?.id?.startsWith('exp ')) { + this.gameText += text; + } + } + break; + case 'component': + case 'compDef': + // This is updated information about the current room. + // Example: `The hustle...`. + // In this example, the text would be 'The hustle...'. + if (tagId.startsWith('room ')) { + this.gameText += text; + } + // This is updated information about the character's experience. + // Example: ` Attunement: 1 46% attentive `. + // In this example, the text would be ' Attunement: 1 46% attentive '. + else if (tagId.startsWith('exp ')) { + this.gameText += text; + } + break; + case 'a': + // This is a hyperlink, we only need the text. + // Example: `Elanthipedia`. + // In this example, the text would be 'Elanthipedia'. + this.gameText += text; + break; + case 'd': + // This is a movement direction in text destined for the player. + // Example: `Obvious paths: north, east.` + // In this example, the text would be either 'north' or 'east'. + this.gameText += text; + break; + case 'prompt': + // This is a periodic terminal-like prompt that appears in the game. + // Example: `>` + // In this example, the text would be '>'. + this.gameText += text; + break; } } @@ -325,22 +392,91 @@ export class GameParserImpl implements GameParser { attributes, }); - // Example: `` - if ('clearStream' === tagName) { - this.emitClearStreamGameEvent(attributes.id); + switch (tagName) { + case 'pushBold': // + this.emitPushBoldGameEvent(); + break; + case 'popBold': // + this.emitPopBoldGameEvent(); + break; + case 'output': // + this.emitTextOutputClassGameEvent(attributes.class); + break; + case 'style': //