diff --git a/.gitignore b/.gitignore
index d08ed73..baa3a4a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -76,6 +76,7 @@ out/
examples/node/package-lock.json
examples/node/node_modules/
examples/web/www
+examples/arena/www
# Babel buiild
lib
\ No newline at end of file
diff --git a/README.md b/README.md
index 57c9bd2..7466e6c 100644
--- a/README.md
+++ b/README.md
@@ -484,6 +484,10 @@ We integrated the Game of Life simulation in a [Framework7](https://framework7.i
You find the the source code of the web application [here in the examples directory](https://github.com/TimKam/JS-son/tree/master/examples/web).
To run the example, install its dependencies with ``npm install`` and run the application in development (hot-reload) mode with ``npm run dev``.
+### Grid World
+By default, *JS-son* supports grid world environments.
+A comprehensive multi-agent grid world tutorial is provided [here in the examples section](./examples/arena/README.md).
+
## Supported Platforms
*JS-son* supports recent versions of Firefox, Chrome, and Node.js.
It is not tested for other platforms and does not use [Babel](https://babeljs.io/) to facilitate compatibility with legacy platforms.
@@ -515,4 +519,15 @@ Contributors should consider the following conventions:
## Acknowledgements
**Author**: Timotheus Kampik - [@TimKam](https://github.com/TimKam)
+**Cite as**:
+
+```
+@inproceedings{js-son,
+ title={{JS-son - A Minimalistic JavaScript BDI Agent Library}},
+ author={Kampik, Timotheus and Nieves, Juan Carlos},
+ booktitle={7th International Workshop on Engineering Multi-Agent Systems (EMAS 2019), Montreal, Canada, 13--14 May, 2019},
+ year={2019}
+}
+```
+
This work was partially supported by the Wallenberg AI, Autonomous Systems and Software Program (WASP) funded by the Knut and Alice Wallenberg Foundation.
\ No newline at end of file
diff --git a/examples/arena/README.md b/examples/arena/README.md
new file mode 100644
index 0000000..b4fcd78
--- /dev/null
+++ b/examples/arena/README.md
@@ -0,0 +1,412 @@
+# JS-son Arena - A Multi-Agent Grid World
+
+This tutorial describes how to use **JS-son** to implement a simple multi-agent grid world.
+The example application is available online at [https://people.cs.umu.se/~tkampik/demos/arena/](https://people.cs.umu.se/~tkampik/demos/arena/).
+
+## Use Case
+The tutorial describes the implementation of a 20 x 20 grid world.
+In the world, 10 agents are acting.
+Besides the agents, different static artifacts exits: *mountains* that block the agents' way, *money* fields, from which the agents can collect coins, and *repair* fields that allow the agents to restore their health.
+If agents "crash" into each other, they lose some of their health.
+
+The implemented agents are fairly primitive and follow a set of simple rules when selecting their actions.
+This means the tutorial is a good starting point for implementing more powerful agents with complex reasoning, learning, or planning abilities.
+
+## Dependencies
+*JS-son Arena* is implemented as a [Framework7](https://framework7.io/) application and additionally uses the [Material Design](https://material.io/tools/icons) icon library.
+This means, besides ``js-son-agent``, the application requires the following libraries:
+
+```json
+{
+ "dom7": "^2.1.3",
+ "framework7": "^4.0.1",
+ "framework7-icons": "^2.2.0",
+ "material-design-icons": "^3.0.1",
+ "template7": "^1.4.1"
+}
+```
+However, these libraries are only used as helpers to simplify the setup of a modern JavaScript build environment and to provide some out-of-the-box UI styles.
+Generally, *JS-son* grid worlds can be implemented in any JavaScript environment.
+
+## Boiler Plate
+As the initial boiler plate, the application uses a stripped-down version of [this Framework7 app generator](https://framework7.io/cli/).
+
+In addition, the ``home.f7.html`` page in the ``src`` directory features the following elements:
+
+* The ``arena-grid`` div will contain the grid world.
+* The ``analysis``grid will contain metrics that describe the state of the grid world.
+* When clicked, the ``restart-button`` link will trigger a restart of the grid world with a new pseudo-random initial state.
+
+```html
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/arena/src/js/Arena.js b/examples/arena/src/js/Arena.js
new file mode 100644
index 0000000..3bb8ebf
--- /dev/null
+++ b/examples/arena/src/js/Arena.js
@@ -0,0 +1,220 @@
+// import js-son and assign Belief, Plan, Agent, GridWorld, and FieldType to separate consts
+import { Belief, Desire, Plan, Agent, GridWorld, FieldType } from 'js-son-agent'
+
+/* desires */
+const desires = {
+ ...Desire('go', beliefs => {
+ if (Math.random() < 0.25) { // random exploration
+ return Object.keys(beliefs.neighborStates)[Math.floor(Math.random() * 4)]
+ }
+ const neighborsDiamond = Object.keys(beliefs.neighborStates).some(
+ key => beliefs.neighborStates[key] === 'diamond'
+ )
+ const neighborsRepair = Object.keys(beliefs.neighborStates).some(
+ key => beliefs.neighborStates[key] === 'repair'
+ )
+ const neighborsPlain = Object.keys(beliefs.neighborStates).some(
+ key => beliefs.neighborStates[key] === 'plain'
+ )
+ if (neighborsDiamond) {
+ return Object.keys(beliefs.neighborStates).find(
+ key => beliefs.neighborStates[key] === 'diamond'
+ )
+ } else if (neighborsRepair) {
+ return Object.keys(beliefs.neighborStates).find(
+ key => beliefs.neighborStates[key] === 'repair'
+ )
+ } else if (neighborsPlain) {
+ return Object.keys(beliefs.neighborStates).find(
+ key => beliefs.neighborStates[key] === 'plain'
+ )
+ } else {
+ return undefined
+ }
+ })
+}
+
+const plans = [
+ Plan(
+ desires => desires.go === 'up',
+ () => ({ go: 'up' })
+ ),
+ Plan(
+ desires => desires.go === 'down',
+ () => ({ go: 'down' })
+ ),
+ Plan(
+ desires => desires.go === 'left',
+ () => ({ go: 'left' })
+ ),
+ Plan(
+ desires => desires.go === 'right',
+ () => ({ go: 'right' })
+ )
+]
+
+/* helper function to determine the field types of an agent's neighbor fields */
+const determineNeighborStates = (position, state) => ({
+ up: position + 20 >= 400 ? undefined : state.fields[position + 20],
+ down: position - 20 < 0 ? undefined : state.fields[position - 20],
+ left: position % 20 === 0 ? undefined : state.fields[position - 1],
+ right: position % 20 === 1 ? undefined : state.fields[position + 1]
+})
+/*
+ dynamically generate agents that are aware of their own position and the types of neighboring
+ fields
+*/
+const generateAgents = initialState => initialState.positions.map((position, index) => {
+ const beliefs = {
+ ...Belief('neighborStates', determineNeighborStates(position, initialState)),
+ ...Belief('position', position),
+ ...Belief('health', 10),
+ ...Belief('coins', 0)
+ }
+ return new Agent(
+ index,
+ beliefs,
+ desires,
+ plans,
+ (beliefs, desires) => desireKey => desires[desireKey](beliefs)
+ )
+})
+
+/* generate pseudo-random initial state */
+const generateInitialState = () => {
+ const dimensions = [20, 20]
+ const positions = []
+ const fields = Array(dimensions[0] * dimensions[1]).fill(0).map((_, index) => {
+ const rand = Math.random()
+ if (rand < 0.1) {
+ return 'mountain'
+ } else if (rand < 0.15) {
+ return 'diamond'
+ } else if (rand < 0.20) {
+ return 'repair'
+ } else if (rand < 0.25 && positions.length < 10) {
+ positions.push(index)
+ return 'plain'
+ } else {
+ return 'plain'
+ }
+ })
+ return {
+ dimensions,
+ positions,
+ coins: Array(10).fill(0),
+ health: Array(10).fill(10),
+ fields
+ }
+}
+
+const generateConsequence = (state, agentId, newPosition) => {
+ switch (state.fields[newPosition]) {
+ case 'plain':
+ if (state.positions.includes(newPosition)) {
+ state.health = state.health.map((healthScore, index) => {
+ if (state.positions[index] === newPosition) {
+ if (state.health[index] <= 1) {
+ state.positions[index] = undefined
+ }
+ return --healthScore
+ } else {
+ return healthScore
+ }
+ })
+ state.health[agentId]--
+ if (state.health[agentId] <= 0) {
+ state.positions[agentId] = undefined
+ }
+ } else {
+ state.positions[agentId] = newPosition
+ }
+ break
+ case 'diamond':
+ state.coins[agentId]++
+ break
+ case 'repair':
+ if (state.health[agentId] < 10) state.health[agentId]++
+ break
+ }
+ return state
+}
+
+const trigger = (actions, agentId, state, position) => {
+ switch (actions[0].go) {
+ case 'up':
+ if (position && position + 20 < 400) {
+ state = generateConsequence(state, agentId, position + 20)
+ }
+ break
+ case 'down':
+ if (position && position - 20 >= 0) {
+ state = generateConsequence(state, agentId, position - 20)
+ }
+ break
+ case 'left':
+ if (position && position % 20 !== 0) {
+ state = generateConsequence(state, agentId, position - 1)
+ }
+ break
+ case 'right':
+ if (position && position % 20 !== 1) {
+ state = generateConsequence(state, agentId, position + 1)
+ }
+ break
+ }
+ return state
+}
+
+const stateFilter = (state, agentId, agentBeliefs) => ({
+ ...agentBeliefs,
+ coins: state.coins[agentId],
+ health: state.health[agentId],
+ neighborStates: determineNeighborStates(state.positions[agentId], state)
+})
+
+const fieldTypes = {
+ mountain: FieldType(
+ 'mountain',
+ () => 'mountain-field material-icons mountain',
+ () => '^',
+ trigger
+ ),
+ diamond: FieldType(
+ 'diamond',
+ () => 'diamond-field material-icons money',
+ () => 'v',
+ trigger
+ ),
+ repair: FieldType(
+ 'repair',
+ () => 'repair-field material-icons build',
+ () => 'F',
+ trigger
+ ),
+ plain: FieldType(
+ 'plain',
+ (state, position) => state.positions.includes(position)
+ ? 'plain-field material-icons robot'
+ : 'plain-field',
+ (state, position) => state.positions.includes(position)
+ ? 'R'
+ : '-',
+ trigger,
+ (state, position) => state.positions.includes(position)
+ ? `
${state.positions.indexOf(position)}
`
+ : ''
+ )
+}
+
+const Arena = () => {
+ const initialState = generateInitialState()
+ return new GridWorld(
+ generateAgents(initialState),
+ initialState,
+ fieldTypes,
+ stateFilter
+ )
+}
+
+export default Arena
diff --git a/examples/arena/src/js/app.js b/examples/arena/src/js/app.js
new file mode 100644
index 0000000..1830399
--- /dev/null
+++ b/examples/arena/src/js/app.js
@@ -0,0 +1,55 @@
+import $$ from 'dom7'
+import Framework7 from 'framework7/framework7.esm.bundle.js'
+import 'framework7/css/framework7.bundle.css'
+// Icons and App Custom Styles
+import '../css/icons.css'
+import '../css/app.css'
+// Import Routes
+import routes from './routes.js'
+// Game of Life
+import Arena from './Arena'
+
+var app = new Framework7({ // eslint-disable-line no-unused-vars
+ root: '#app', // App root element
+
+ name: 'JS-son: Game of Life', // App name
+ theme: 'auto', // Automatic theme detection
+ // App root data
+ data: () => {
+ $$(document).on('page:init', e => {
+ let arena = Arena()
+ let shouldRestart = false
+ $$('.restart-button').on('click', () => {
+ shouldRestart = true
+ })
+ window.setInterval(() => {
+ if (shouldRestart) {
+ shouldRestart = false
+ arena = Arena()
+ } else {
+ arena.run(1)
+ console.log(arena)
+ $$('#arena-grid').html(arena.render(arena.state))
+ $$('#analysis').html(`
+
+
+ Agent |
+ ${arena.state.positions.map((_, index) => `${index} | `).join('')}
+
+
+ Health |
+ ${arena.state.health.map(healthScore => `${healthScore} | `).join('')}
+
+
+ Coins |
+ ${arena.state.coins.map(coins => `${coins} | `).join('')}
+
+
+ `)
+ }
+ }, 2000)
+ })
+ },
+ // App routes
+ routes: routes
+})
diff --git a/examples/arena/src/js/routes.js b/examples/arena/src/js/routes.js
new file mode 100644
index 0000000..aa72d0f
--- /dev/null
+++ b/examples/arena/src/js/routes.js
@@ -0,0 +1,10 @@
+
+import HomePage from '../pages/home.f7.html'
+
+var routes = [
+ {
+ path: '/',
+ component: HomePage
+ }
+]
+export default routes
diff --git a/examples/arena/src/js/template7-helpers-list.js b/examples/arena/src/js/template7-helpers-list.js
new file mode 100644
index 0000000..4ba52ba
--- /dev/null
+++ b/examples/arena/src/js/template7-helpers-list.js
@@ -0,0 +1 @@
+module.exports = {}
diff --git a/examples/arena/src/pages/home.f7.html b/examples/arena/src/pages/home.f7.html
new file mode 100644
index 0000000..82664d2
--- /dev/null
+++ b/examples/arena/src/pages/home.f7.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+ This page shows how
JS-son agents of different types compete in a grid world arena.
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/arena/src/static/icons/128x128.png b/examples/arena/src/static/icons/128x128.png
new file mode 100644
index 0000000..32fe380
Binary files /dev/null and b/examples/arena/src/static/icons/128x128.png differ
diff --git a/examples/arena/src/static/icons/144x144.png b/examples/arena/src/static/icons/144x144.png
new file mode 100644
index 0000000..a6337ce
Binary files /dev/null and b/examples/arena/src/static/icons/144x144.png differ
diff --git a/examples/arena/src/static/icons/152x152.png b/examples/arena/src/static/icons/152x152.png
new file mode 100644
index 0000000..cc63ddd
Binary files /dev/null and b/examples/arena/src/static/icons/152x152.png differ
diff --git a/examples/arena/src/static/icons/192x192.png b/examples/arena/src/static/icons/192x192.png
new file mode 100644
index 0000000..ce56b73
Binary files /dev/null and b/examples/arena/src/static/icons/192x192.png differ
diff --git a/examples/arena/src/static/icons/256x256.png b/examples/arena/src/static/icons/256x256.png
new file mode 100644
index 0000000..f2523bd
Binary files /dev/null and b/examples/arena/src/static/icons/256x256.png differ
diff --git a/examples/arena/src/static/icons/512x512.png b/examples/arena/src/static/icons/512x512.png
new file mode 100644
index 0000000..6d27833
Binary files /dev/null and b/examples/arena/src/static/icons/512x512.png differ
diff --git a/examples/arena/src/static/icons/apple-touch-icon.png b/examples/arena/src/static/icons/apple-touch-icon.png
new file mode 100644
index 0000000..851a083
Binary files /dev/null and b/examples/arena/src/static/icons/apple-touch-icon.png differ
diff --git a/examples/arena/src/static/icons/favicon.png b/examples/arena/src/static/icons/favicon.png
new file mode 100644
index 0000000..e4c664b
Binary files /dev/null and b/examples/arena/src/static/icons/favicon.png differ
diff --git a/spec/src/agent/Agent.spec.js b/spec/src/agent/Agent.spec.js
index d7c5a77..5b262fe 100644
--- a/spec/src/agent/Agent.spec.js
+++ b/spec/src/agent/Agent.spec.js
@@ -18,7 +18,7 @@ describe('Agent / next()', () => {
it('should return plan execution result for active plans', () => {
agent.start()
- expect(agent.next(beliefs)[0]).toEqual({
+ expect(agent.next({ ...beliefs, dogHungry: false })).toContain({
actions: ['Good dog!']
})
})
diff --git a/spec/src/environment/FieldType.spec.js b/spec/src/environment/FieldType.spec.js
new file mode 100644
index 0000000..1948738
--- /dev/null
+++ b/spec/src/environment/FieldType.spec.js
@@ -0,0 +1,42 @@
+const FieldType = require('../../../src/environment/FieldType')
+
+describe('FieldType', () => {
+ const fieldType = FieldType(
+ 'plain',
+ (state, position) => state.positions.includes(position)
+ ? 'plain-field material-icons robot'
+ : 'plain-field',
+ (state, position) => state.positions.includes(position)
+ ? 'R'
+ : '-',
+ (actions, _, state, __) => ({ ...state, action: actions[0] }),
+ (state, position) => state.positions.includes(position)
+ ? `
${state.positions.indexOf(position)}
`
+ : ''
+ )
+
+ const state = {
+ positions: [10, 20],
+ health: [25, 20]
+ }
+
+ const actions = [{ go: 'up' }]
+
+ it('Should render field as specified by generateClass / generateAnnotation functions', () => {
+ expect(fieldType.render(state, undefined)).toEqual('
| ')
+ expect(fieldType.render(state, 10)).toEqual(
+ '
0 | '
+ )
+ })
+
+ it('Should render field as specified by generateCharRep function', () => {
+ expect(fieldType.render(state, 1, false)).toEqual('-')
+ expect(fieldType.render(state, 20, false)).toEqual('R')
+ })
+
+ it('Should execute the "generateConsequence" function when triggered', () => {
+ expect(fieldType.trigger(actions, 0, state, 1)).toEqual(
+ { ...state, action: { go: 'up' } }
+ )
+ })
+})
diff --git a/spec/src/environment/GridWorld.spec.js b/spec/src/environment/GridWorld.spec.js
new file mode 100644
index 0000000..6d97d49
--- /dev/null
+++ b/spec/src/environment/GridWorld.spec.js
@@ -0,0 +1,88 @@
+const Belief = require('../../../src/agent/Belief')
+const Desire = require('../../../src/agent/Desire')
+const Plan = require('../../../src/agent/Plan')
+const Agent = require('../../../src/agent/Agent')
+const GridWorld = require('../../../src/environment/GridWorld')
+const FieldType = require('../../../src/environment/FieldType')
+
+const desires = {
+ ...Desire('go', beliefs => beliefs.testBelief === 1 ? 'right' : 'left')
+}
+
+const plans = [
+ Plan(desires => desires.go === 'right', () => ({ go: 'right' })),
+ Plan(desires => desires.go === 'left', () => ({ go: 'left' }))
+]
+
+const agents = [1, 2, 3].map(value => new Agent(
+ value,
+ { ...Belief('testBelief', value) },
+ desires,
+ plans,
+ (beliefs, desires) => desireKey => desires[desireKey](beliefs)
+))
+
+const fieldType = FieldType(
+ 'plain',
+ (state, position) => state.positions.includes(position)
+ ? 'a'
+ : 'b',
+ (state, position) => state.positions.includes(position)
+ ? 'R'
+ : '-',
+ (actions, _, state, __) => ({ ...state, action: actions[0] }),
+ (state, position) => state.positions.includes(position)
+ ? `
${state.positions.indexOf(position)}
`
+ : ''
+)
+
+const gridWorld = new GridWorld(
+ agents,
+ {
+ positions: [1, 2, 3],
+ dimensions: [4, 4],
+ fields: Array(4 * 4).fill('plain')
+ },
+ { plain: fieldType }
+)
+
+const newWorldState = gridWorld.run(1)
+
+describe('GridWorld / run()', () => {
+ it('Should process agent actions as specified', () => {
+ expect(newWorldState.slice(-1)[0].action.go).toEqual('left')
+ })
+
+ it('Should terminate after the specified number of iterations', () => {
+ expect(gridWorld.history.length).toEqual(2)
+ })
+
+ it('Should be able to handle "dead"/inactive agents with ``undefined`` position', () => {
+ gridWorld.reset()
+ gridWorld.state.positions[1] = undefined
+ gridWorld.run(1)
+ expect(gridWorld.history.length).toEqual(2)
+ expect(gridWorld.render(gridWorld.state)).toEqual([
+ '****\r\n',
+ '-R-R\r\n',
+ '----\r\n',
+ '----\r\n',
+ '----\r\n',
+ '****'
+ ].join(''))
+ })
+
+ it('Should render the GridWorld correctly', () => {
+ gridWorld.reset()
+ gridWorld.state.positions[1] = 2
+ gridWorld.run(1)
+ expect(gridWorld.render(gridWorld.state)).toEqual([
+ '****\r\n',
+ '-RRR\r\n',
+ '----\r\n',
+ '----\r\n',
+ '----\r\n',
+ '****'
+ ].join(''))
+ })
+})
diff --git a/src/environment/FieldType.js b/src/environment/FieldType.js
new file mode 100644
index 0000000..c4d9980
--- /dev/null
+++ b/src/environment/FieldType.js
@@ -0,0 +1,25 @@
+/**
+ * JS-son world field type generator
+ * @param {string} id Unique identifier of field type
+ * @param {function} generateClass Determines field's table cell class based on state, position
+ * @param {function} generateCharRep Determines field's cli representation, based on state, position
+ * @param {function} generateConsequence Determines effect of action in field to agent and env
+ * @param {function} generateAnnotation Determines field's annotation, based on state, position
+ * @returns {object} JS-son grid world field type
+*/
+const FieldType = (
+ id,
+ generateClass,
+ generateCharRep,
+ generateConsequence,
+ generateAnnotation = () => ''
+) => ({
+ id,
+ render: (state, position, browser = true) => browser
+ ? `
${generateAnnotation(state, position)} | `
+ : generateCharRep(state, position),
+ trigger: (actions, agentId, state, position) =>
+ generateConsequence(actions, agentId, state, position)
+})
+
+module.exports = FieldType
diff --git a/src/environment/GridWorld.js b/src/environment/GridWorld.js
new file mode 100644
index 0000000..77d16ce
--- /dev/null
+++ b/src/environment/GridWorld.js
@@ -0,0 +1,67 @@
+const Environment = require('./Environment')
+
+/**
+ * JS-son grid world environment
+ * @param {array} agents JS-son agents to run
+ * @param {object} state Initial environment state
+ * @param {object} fieldTypes Object: all field types (id: {...properties}) the environment supports
+ * @param {function} stateFilter Function for filtering state that agents should receive
+ * @returns {object} JS-son environment object with grid world render function
+ */
+function GridWorld (
+ agents,
+ state,
+ fieldTypes,
+ stateFilter = state => state
+) {
+ const renderBrowser = (state, fieldTypes) => [].concat(
+ '
',
+ state.fields.map((field, index) => {
+ let worldSnippet = ''
+ if (index % state.dimensions[0] === 0) worldSnippet += ''
+ worldSnippet += fieldTypes[field].render(state, index)
+ if (index % state.dimensions[0] === state.dimensions[0] - 1) worldSnippet += '
'
+ return worldSnippet
+ }),
+ '
'
+ ).join('')
+
+ const renderNode = (state, fieldTypes) => [].concat(
+ '****',
+ state.fields.map((field, index) => {
+ let worldSnippet = ''
+ if (index % state.dimensions[0] === 0) worldSnippet += '\r\n'
+ worldSnippet += fieldTypes[field].render(state, index, false)
+ return worldSnippet
+ }),
+ '\r\n****'
+ ).join('')
+
+ const render = (state, fieldTypes) =>
+ typeof window === 'undefined'
+ ? renderNode(state, fieldTypes)
+ : renderBrowser(state, fieldTypes)
+
+ const update = (actions, agentId, state, fieldTypes) => {
+ const agentPosition = state.positions[agentId]
+ if (agentPosition) {
+ const fieldType = fieldTypes[state.fields[agentPosition]]
+ return {
+ ...state,
+ ...fieldType.trigger(actions, agentId, state, agentPosition)
+ }
+ } else { // inactive or "dead" agents may have undefined position
+ return state
+ }
+ }
+
+ return new Environment(
+ agents,
+ state,
+ (actions, agentId, currentState) => update(actions, agentId, state, fieldTypes),
+ state => render(state, fieldTypes),
+ stateFilter
+ )
+}
+
+module.exports = GridWorld
diff --git a/src/js-son.js b/src/js-son.js
index 7b03074..ff1c252 100644
--- a/src/js-son.js
+++ b/src/js-son.js
@@ -4,7 +4,9 @@ const JSson = {
Intentions: require('./agent/Intentions'),
Plan: require('./agent/Plan'),
Agent: require('./agent/Agent'),
- Environment: require('./environment/Environment')
+ Environment: require('./environment/Environment'),
+ GridWorld: require('./environment/GridWorld'),
+ FieldType: require('./environment/FieldType')
}
module.exports = JSson