Partial TypeScript rewrite - can generate grammatical parsings, cannot validate with game state; no rules engine
parent
75048c3003
commit
230eef2585
@ -0,0 +1,68 @@
|
||||
import {enableMapSet, createDraft, finishDraft, Draft} from 'immer'
|
||||
import GameState, { Room, Door, Item, ObjectType } from './types/GameState'
|
||||
|
||||
enableMapSet()
|
||||
|
||||
type ChangeListener = (state : GameState) => void
|
||||
|
||||
export default class Game {
|
||||
private gameState : GameState = {directions: new Map(), rooms: new Map(), doors: new Map(), items: new Map()}
|
||||
private draft : Draft<GameState> | null = null
|
||||
private onChangeListeners : ChangeListener [] = []
|
||||
|
||||
constructor() {
|
||||
console.log('adding directions')
|
||||
let state = this.getState()
|
||||
state.directions.set('north', {type: ObjectType.Direction, name: 'north', aliases: ['n']})
|
||||
state.directions.set('east', {type: ObjectType.Direction, name: 'east', aliases: ['e']})
|
||||
state.directions.set('south', {type: ObjectType.Direction, name: 'south', aliases: ['s']})
|
||||
state.directions.set('west', {type: ObjectType.Direction, name: 'west', aliases: ['w']})
|
||||
state.directions.set('up', {type: ObjectType.Direction, name: 'up', aliases: ['u']})
|
||||
state.directions.set('down', {type: ObjectType.Direction, name: 'down', aliases: ['d']})
|
||||
this.saveDraft()
|
||||
}
|
||||
|
||||
onChange(callback : ChangeListener) {
|
||||
this.onChangeListeners.push(callback)
|
||||
}
|
||||
|
||||
beginDraft() {
|
||||
if(this.draft)
|
||||
console.warn('Destroying already created gamestate draft')
|
||||
|
||||
this.draft = createDraft(this.gameState)
|
||||
}
|
||||
|
||||
saveDraft() {
|
||||
if(!this.draft)
|
||||
throw new Error('Game has no open draft state')
|
||||
|
||||
this.gameState = finishDraft(this.draft)
|
||||
this.draft = null
|
||||
|
||||
for(const callback of this.onChangeListeners)
|
||||
callback(this.gameState)
|
||||
}
|
||||
|
||||
getState() : Draft<GameState> {
|
||||
if(!this.draft)
|
||||
this.beginDraft()
|
||||
|
||||
return this.draft!
|
||||
}
|
||||
|
||||
addRoom(room : Room) {
|
||||
let state = this.getState()
|
||||
state.rooms.set(room.name, room)
|
||||
}
|
||||
|
||||
addDoor(door: Door) {
|
||||
let state = this.getState()
|
||||
state.doors.set(door.name, door)
|
||||
}
|
||||
|
||||
addItem(item: Item) {
|
||||
let state = this.getState()
|
||||
state.items.set(item.name, item)
|
||||
}
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
import Game from "./Game";
|
||||
import RulesEngine from './RulesEngine'
|
||||
import ParsedCommand from "./types/ParsedCommand";
|
||||
import Verb from './types/Verb';
|
||||
|
||||
export default class Parser {
|
||||
private game : Game
|
||||
private engine : RulesEngine
|
||||
private verbs : Verb [] = []
|
||||
|
||||
constructor(gameState : Game, engine : RulesEngine) {
|
||||
this.game = gameState
|
||||
this.engine = engine
|
||||
}
|
||||
|
||||
handleCommand(rawCommand : string) {
|
||||
const grammaticalParsings : ParsedCommand[] = this.parseCommandString(rawCommand)
|
||||
console.log(grammaticalParsings)
|
||||
|
||||
// const validParsings : ParsedCommand[] = this.game.filterCommandsForValidity(grammaticalParsings)
|
||||
}
|
||||
|
||||
parseCommandString(rawCommand: string): ParsedCommand[] {
|
||||
const words = rawCommand.toLocaleLowerCase().split(' ').filter(chunk => chunk !== '')
|
||||
|
||||
let parsings : ParsedCommand[] = []
|
||||
for(const verb of this.verbs) {
|
||||
parsings = [...parsings, ...verb.attemptParse(words)]
|
||||
}
|
||||
|
||||
return parsings
|
||||
}
|
||||
|
||||
understand(name : string) : VerbBuilder {
|
||||
const verb = new Verb(name)
|
||||
this.verbs.push(verb)
|
||||
return new VerbBuilder(verb)
|
||||
}
|
||||
}
|
||||
|
||||
class VerbBuilder {
|
||||
private verb : Verb
|
||||
|
||||
constructor(verb : Verb) {
|
||||
this.verb = verb
|
||||
}
|
||||
|
||||
as(template : string) : VerbBuilder {
|
||||
this.verb.understand(template)
|
||||
return this
|
||||
}
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import '../index.css';
|
||||
import App from '../components/App/App';
|
||||
|
||||
import Game from "./Game";
|
||||
import Parser from "./Parser";
|
||||
import GameEvent, { GameEventCommand } from './types/GameEvent'
|
||||
|
||||
|
||||
export default class Renderer {
|
||||
private parser : Parser
|
||||
private game : Game
|
||||
private output : GameEvent[] = []
|
||||
|
||||
constructor(parser : Parser, game : Game) {
|
||||
this.parser = parser
|
||||
this.game = game
|
||||
}
|
||||
|
||||
start() {
|
||||
this.render()
|
||||
}
|
||||
|
||||
private handleCommand(command : string) {
|
||||
this.output.push(new GameEventCommand(command))
|
||||
this.render()
|
||||
|
||||
this.parser.handleCommand(command)
|
||||
}
|
||||
|
||||
private render() {
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<App messages={this.output} game={this.game} onCommand={this.handleCommand.bind(this)}/>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
export default class RulesEngine {
|
||||
// constructor() {
|
||||
// }
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
export enum GameEventType {
|
||||
Message = 'message',
|
||||
Command = 'command'
|
||||
}
|
||||
|
||||
export default interface GameEvent {
|
||||
getType() : GameEventType;
|
||||
}
|
||||
|
||||
export class GameEventMessage implements GameEvent {
|
||||
private message : string
|
||||
|
||||
getType() : GameEventType { return GameEventType.Message }
|
||||
constructor(message : string) {
|
||||
this.message = message
|
||||
}
|
||||
}
|
||||
|
||||
export class GameEventCommand implements GameEvent {
|
||||
private rawCommand : string
|
||||
|
||||
getType() : GameEventType { return GameEventType.Command }
|
||||
constructor(rawCommand : string) {
|
||||
this.rawCommand = rawCommand
|
||||
}
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
type GameState = {
|
||||
readonly directions: Map<ObjectID, GameObject>,
|
||||
readonly rooms: Map<ObjectID, GameObject>,
|
||||
readonly doors: Map<ObjectID, GameObject>,
|
||||
readonly items: Map<ObjectID, GameObject>
|
||||
}
|
||||
|
||||
export default GameState
|
||||
|
||||
export enum ObjectType {
|
||||
Item = 'item',
|
||||
Room = 'room',
|
||||
Door = 'door',
|
||||
Direction = 'direction'
|
||||
}
|
||||
|
||||
type ObjectID = string
|
||||
|
||||
type GameObject = {
|
||||
readonly type : ObjectType,
|
||||
readonly name : ObjectID,
|
||||
readonly aliases : string[]
|
||||
}
|
||||
|
||||
export type Room = GameObject & {
|
||||
readonly neighbors : Map<ObjectID, ObjectID>
|
||||
}
|
||||
|
||||
export type Door = Room & {
|
||||
readonly locked : boolean,
|
||||
readonly key : ObjectID
|
||||
}
|
||||
|
||||
export type Item = GameObject & {
|
||||
readonly location: ObjectID | 'inventory'
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
enum NounPosition {
|
||||
Subject,
|
||||
Object
|
||||
}
|
||||
|
||||
export default NounPosition
|
@ -0,0 +1,53 @@
|
||||
import { ObjectType } from "./GameState"
|
||||
import NounPosition from "./NounPosition"
|
||||
import Verb from "./Verb"
|
||||
|
||||
export enum TokenType {
|
||||
Expression = 'expression',
|
||||
Literal = 'literal'
|
||||
}
|
||||
|
||||
export class ParsedToken {
|
||||
readonly type : TokenType
|
||||
|
||||
constructor(type : TokenType) {
|
||||
this.type = type
|
||||
}
|
||||
}
|
||||
|
||||
export class ParsedTokenLiteral extends ParsedToken {
|
||||
readonly word : string
|
||||
|
||||
constructor(word : string) {
|
||||
super(TokenType.Literal)
|
||||
this.word = word
|
||||
}
|
||||
}
|
||||
|
||||
export class ParsedTokenExpression extends ParsedToken {
|
||||
readonly itemType : ObjectType
|
||||
readonly sentencePosition : NounPosition
|
||||
readonly name : string
|
||||
|
||||
constructor(type : ObjectType, position : NounPosition, name : string) {
|
||||
super(TokenType.Expression)
|
||||
this.itemType = type
|
||||
this.sentencePosition = position
|
||||
this.name = name
|
||||
}
|
||||
}
|
||||
|
||||
export default class ParsedCommand {
|
||||
readonly verb : Verb
|
||||
private tokens : ParsedToken[] = []
|
||||
|
||||
constructor(verb : Verb) {
|
||||
this.verb = verb
|
||||
}
|
||||
|
||||
prepend(token : ParsedToken) {
|
||||
const newCommand = new ParsedCommand(this.verb)
|
||||
newCommand.tokens = [token, ...this.tokens]
|
||||
return newCommand
|
||||
}
|
||||
}
|
@ -0,0 +1,164 @@
|
||||
import ParsedCommand, {TokenType, ParsedTokenLiteral, ParsedTokenExpression} from './ParsedCommand'
|
||||
import NounPosition from './NounPosition'
|
||||
import { ObjectType } from './GameState'
|
||||
|
||||
export default class Verb {
|
||||
static expressionRegex = /^\[([a-z|]+)\]$/
|
||||
|
||||
readonly name : string
|
||||
private templates : Template[] = []
|
||||
|
||||
constructor(name : string) {
|
||||
this.name = name
|
||||
}
|
||||
|
||||
understand(templateString : string) {
|
||||
const words : string[] = templateString.split(' ')
|
||||
const tokens : TemplateToken[] = words.map((word : string) => {
|
||||
if(!word.includes('[') && !word.includes(']'))
|
||||
return new TemplateTokenLiteral(word)
|
||||
|
||||
if(!Verb.expressionRegex.test(word))
|
||||
throw new Error(`Invalid template token "${word}"`)
|
||||
|
||||
const expressionString = (word.match(Verb.expressionRegex) || [])[1]
|
||||
|
||||
const compoundExpression = expressionString.includes('|')
|
||||
|
||||
const typeExpression = compoundExpression ? expressionString.split('|')[0] : expressionString
|
||||
const positionExpression = compoundExpression ? expressionString.split('|')[1] : 'subject'
|
||||
|
||||
let type : ObjectType
|
||||
if(typeExpression === 'item')
|
||||
type = ObjectType.Item
|
||||
else if(typeExpression === 'door')
|
||||
type = ObjectType.Door
|
||||
else if(typeExpression === 'room')
|
||||
type = ObjectType.Room
|
||||
else if(typeExpression === 'direction')
|
||||
type = ObjectType.Direction
|
||||
else
|
||||
throw new Error(`Unknown object type "${typeExpression}"`)
|
||||
|
||||
let position : NounPosition
|
||||
if(positionExpression === 'subject')
|
||||
position = NounPosition.Subject
|
||||
else if (positionExpression === 'object')
|
||||
position = NounPosition.Object
|
||||
else
|
||||
throw new Error(`Unknown noun position "${positionExpression}"`)
|
||||
|
||||
return new TemplateTokenExpression(type, position)
|
||||
})
|
||||
|
||||
// Check that we do not have two object expressions with the same position
|
||||
const nounPositions = new Set<NounPosition>()
|
||||
for(const token of tokens.filter(token => token.type === TokenType.Expression)){
|
||||
const expression = token as TemplateTokenExpression
|
||||
|
||||
if(nounPositions.has(expression.nounPosition))
|
||||
throw new Error(`Error parsing command template "${templateString}" - more than one ${expression.nounPosition} expression`)
|
||||
|
||||
nounPositions.add(expression.nounPosition)
|
||||
}
|
||||
|
||||
this.templates.push(new Template(this, tokens))
|
||||
}
|
||||
|
||||
attemptParse(words : string[]) : ParsedCommand[] {
|
||||
let parsings : ParsedCommand[] = []
|
||||
|
||||
for(const template of this.templates){
|
||||
parsings = [...parsings, ...template.parse(words)]
|
||||
}
|
||||
|
||||
return parsings
|
||||
}
|
||||
}
|
||||
|
||||
class TemplateToken {
|
||||
readonly type : TokenType
|
||||
|
||||
constructor(type : TokenType) {
|
||||
this.type = type
|
||||
}
|
||||
}
|
||||
|
||||
class TemplateTokenLiteral extends TemplateToken {
|
||||
readonly word : string
|
||||
|
||||
constructor(word : string) {
|
||||
super(TokenType.Literal)
|
||||
this.word = word
|
||||
}
|
||||
}
|
||||
|
||||
class TemplateTokenExpression extends TemplateToken {
|
||||
readonly nounPosition : NounPosition
|
||||
readonly itemType : ObjectType
|
||||
|
||||
constructor(type: ObjectType, position: NounPosition) {
|
||||
super(TokenType.Expression)
|
||||
|
||||
this.nounPosition = position
|
||||
this.itemType = type
|
||||
}
|
||||
}
|
||||
|
||||
class Template {
|
||||
readonly verb : Verb
|
||||
readonly tokens : TemplateToken[]
|
||||
|
||||
constructor(verb : Verb, tokens : TemplateToken[]) {
|
||||
this.verb = verb
|
||||
this.tokens = tokens
|
||||
}
|
||||
|
||||
parse(words: string[]): ParsedCommand[] {
|
||||
return this.attemptParse(this.tokens, words)
|
||||
}
|
||||
|
||||
private attemptParse(tokens : TemplateToken[], words : string[]) : ParsedCommand[] {
|
||||
// Base case: we matched every token and every word
|
||||
if(tokens.length < 1 && words.length < 1)
|
||||
return [new ParsedCommand(this.verb)]
|
||||
|
||||
// Base case: we have unmatched tokens or words
|
||||
if(tokens.length < 1 || words.length < 1)
|
||||
return []
|
||||
|
||||
// If we reached this point we still have tokens AND words to match
|
||||
const nextToken = tokens[0]
|
||||
if(nextToken.type === TokenType.Literal) {
|
||||
const token = nextToken as TemplateTokenLiteral
|
||||
let nextWord = words[0]
|
||||
|
||||
// If it doesn't match, no valid parsings
|
||||
if(nextWord !== token.word)
|
||||
return []
|
||||
|
||||
const thisToken = new ParsedTokenLiteral(nextWord)
|
||||
return this.attemptParse(tokens.slice(1), words.slice(1)).map(parsing => parsing.prepend(thisToken))
|
||||
|
||||
} else if(nextToken.type === TokenType.Expression) {
|
||||
const token = nextToken as TemplateTokenExpression
|
||||
let parsings : ParsedCommand[] = []
|
||||
|
||||
// For all possible lengths of words we could capture in this expression
|
||||
for(let n = 1; n <= words.length; n++) {
|
||||
// Figure out what would be in the expression
|
||||
const thisToken = new ParsedTokenExpression(token.itemType, token.nounPosition, words.slice(0, n).join(' '))
|
||||
const potentialParsings = this.attemptParse(tokens.slice(1), words.slice(n)).map(parsing => parsing.prepend(thisToken))
|
||||
|
||||
// Add command parsings to the array we've been building
|
||||
parsings = [...parsings, ...potentialParsings]
|
||||
}
|
||||
|
||||
return parsings
|
||||
|
||||
} else {
|
||||
throw new Error(`Unknown token type "${nextToken.type}"`)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import './index.css';
|
||||
import App from './components/App/App';
|
||||
import Parser from './Parser'
|
||||
import RulesEngine from './RuleEngine'
|
||||
import GameState from './GameState';
|
||||
|
||||
// Rules and Parser
|
||||
const parser = new Parser()
|
||||
const engine = new RulesEngine()
|
||||
const gameState = new GameState()
|
||||
let messages = []
|
||||
|
||||
parser.setGameState(gameState)
|
||||
parser.setEngine(engine);
|
||||
parser.afterCommand(afterCommand);
|
||||
parser.start()
|
||||
|
||||
function onCommand(command) {
|
||||
messages = [...messages, {type: 'command', command}]
|
||||
parser.handleCommand(command)
|
||||
}
|
||||
|
||||
function afterCommand(newMessages) {
|
||||
messages = [...messages, ...newMessages.map(message => ({type: 'message', message}))]
|
||||
render()
|
||||
}
|
||||
|
||||
function render() {
|
||||
// Set up UI
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<App messages={messages} state={gameState.getState()} onCommand={onCommand}/>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
}
|
||||
|
||||
render()
|
@ -0,0 +1,82 @@
|
||||
import Game from './engine/Game'
|
||||
import Parser from './engine/Parser'
|
||||
import Renderer from './engine/Renderer'
|
||||
import RulesEngine from './engine/RulesEngine'
|
||||
import { ObjectType, Room, Door } from './engine/types/GameState'
|
||||
|
||||
let game = new Game()
|
||||
let rules = new RulesEngine()
|
||||
let parser = new Parser(game, rules)
|
||||
let renderer = new Renderer(parser, game)
|
||||
|
||||
parser.understand('look')
|
||||
.as('look')
|
||||
.as('describe')
|
||||
|
||||
parser.understand('lookDirection')
|
||||
.as('look [direction]')
|
||||
|
||||
parser.understand('go')
|
||||
.as('go [direction]')
|
||||
.as('[direction]')
|
||||
|
||||
parser.understand('take')
|
||||
.as('take [item]')
|
||||
.as('get [item]')
|
||||
.as('pick up [item]')
|
||||
.as('grab [item]')
|
||||
.as('snatch [item]')
|
||||
.as('steal [item]')
|
||||
|
||||
parser.understand('unlockDoor')
|
||||
.as('unlock [door|subject] with [item|object]')
|
||||
.as('unlock [door]')
|
||||
.as('use [item|object] to unlock [door|subject]')
|
||||
|
||||
parser.understand('openDoor')
|
||||
.as('open [door]')
|
||||
.as('open [door|subject] with [item|object]')
|
||||
|
||||
const entry : Room = {
|
||||
type: ObjectType.Room,
|
||||
name: 'entry',
|
||||
aliases: [],
|
||||
neighbors: new Map()
|
||||
}
|
||||
|
||||
const door : Door = {
|
||||
type: ObjectType.Door,
|
||||
name: 'door',
|
||||
aliases: ['white door'],
|
||||
neighbors: new Map(),
|
||||
locked: true,
|
||||
key: 'brass key'
|
||||
}
|
||||
|
||||
const office : Room = {
|
||||
type: ObjectType.Room,
|
||||
name: 'office',
|
||||
aliases: [],
|
||||
neighbors: new Map()
|
||||
}
|
||||
|
||||
entry.neighbors.set('east', 'door')
|
||||
office.neighbors.set('west', 'door')
|
||||
door.neighbors.set('east', 'office')
|
||||
door.neighbors.set('west', 'entry')
|
||||
|
||||
game.addRoom(entry)
|
||||
game.addRoom(office)
|
||||
game.addDoor(door)
|
||||
|
||||
game.addItem({
|
||||
type: ObjectType.Item,
|
||||
name: 'brass key',
|
||||
aliases: ['key'],
|
||||
location: 'entry'
|
||||
})
|
||||
|
||||
game.saveDraft()
|
||||
|
||||
|
||||
renderer.start()
|
@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue