agentscape
Version:
Agentscape is a library for creating agent-based simulations. It provides a simple API for defining agents and their behavior, and for defining the environment in which the agents interact. Agentscape is designed to be flexible and extensible, allowing
1,120 lines (958 loc) • 42 kB
text/typescript
import { v4 as uuidv4 } from 'uuid'
import { AgentSet, CellGrid } from '../structures'
import { RandomGenerator, Vector2, Angle, Color } from '../numbers'
import type Cell from './Cell'
import { Runtime } from '../runtime'
import { BoundaryCondition } from '../structures/CellGrid'
export enum AgentStyle {
CIRCLE = 'circle',
SQUARE = 'square',
TRIANGLE = 'triangle',
}
export type TraitOptions = {
min?: number,
max?: number
}
/**
* Traits represent some custom property of the agent.
* Traits are inherited by children via the Agent's `reproduce` method
* and are initialized with the parent's current value. Traits are constants
* and do not change independently over time or by reproduction.
*
* For genetic traits that are inherited with mutation, see {@link GeneticTraitValue}.
* For dynamic traits that are a function of the agent's current state, see {@link DynamicTrait}.
*/
export const Trait = <T extends Agent>(options?: TraitOptions) => {
return (target: T, propertyKey: string) => {
const clamp = (value: number) => {
const { min = -Infinity, max = Infinity } = options ?? {}
return Math.min(Math.max(value, min), max)
}
Object.defineProperty(target, propertyKey, {
get: function () {
const value = (this as T).inheritedTraits.get(propertyKey)
if (typeof value === 'number') {
return clamp(value)
} else {
return value
}
},
set: function (value: number|boolean) {
// return (this as T).inheritedTraits.set(propertyKey, value)
if (typeof value === 'number') {
return (this as T).inheritedTraits.set(propertyKey, clamp(value))
} else {
return (this as T).inheritedTraits.set(propertyKey, value)
}
}
})
}
}
export type GeneticTraitValue<T extends number|string|boolean|Color> = T|Array<T>
export type GeneticTraitOptions<T extends number|string|boolean|Color> = {
min?: number,
max?: number,
/**
* A function that mutates the trait value slightly.
* @param value The current value of the trait
* @param rng A reference to the agent's random number generator
* @param traitMutationRate The agent's average mutation rate for genetic traits
* @returns New value of the trait
*/
mutationFunction?: (value: GeneticTraitValue<T>, rng: RandomGenerator, traitMutationRate: number) => GeneticTraitValue<T>,
/**
* A function that recombines the trait value with another agent's trait value.
* @param value1 The current value of the trait
* @param value2 The other agent's value of the trait
* @param rng A reference to this agent's random number generator
* @returns The new value of the trait
*/
recombinantFunction?: (value1: GeneticTraitValue<T>, value2: GeneticTraitValue<T>, rng: RandomGenerator) => GeneticTraitValue<T>
}
/**
* Genetic traits are inherited by children and initialized
* with the parent's current value plus some mutation.
* This can be used with new properties or to override existing properties.
*
* Default mutation functions are provided for numbers, booleans, and colors.
*
* - Numbers: The value is mutated by a random amount between `-traitMutationRate` and `traitMutationRate`.
* - Booleans: The value has a `traitMutationRate` chance of flipping.
* - Colors: The color's RGB values are mutated by a normally distributed random amount with mean `traitMutationRate`.
*
* Default recombinant functions are provided for numbers, booleans, and colors.
*
* - Numbers: The value is the average of the two parent values.
* - Booleans: The value is randomly chosen from the two parent values.
* - Colors: The color is a blend of the two parent colors.
*
* These defaults can be overridden by providing custom mutation and recombinant functions.
* See {@link GeneticTraitOptions} for more information.
*
* See also: {@link Trait}
*/
export const GeneticTrait = <T extends Agent, V extends number|string|boolean|Color>(options?: GeneticTraitOptions<V> ) => {
return (target: T, propertyKey: string) => {
Object.defineProperty(target, propertyKey, {
get: function () {
return (this as T).geneticTraits.get(propertyKey).value
},
set: function (value: GeneticTraitValue<V>) {
const defaultOptions = {
mutationFunction: (value: GeneticTraitValue<V>, rng: RandomGenerator, traitMutationRate: number): GeneticTraitValue<V> => {
if ( typeof value === 'boolean' ) {
// there is a traitMutationRate chance that the trait will flip
return rng.uniformFloat(0, 1) < traitMutationRate ? !value as GeneticTraitValue<V> : value
} else if (typeof value === 'number') {
// mutate the value slightly, plus or minus traitMutationRate of the value
const modifier = rng.normalFloat(0, traitMutationRate) - rng.normalFloat(0, traitMutationRate)
return value + modifier as GeneticTraitValue<V>
} else if (typeof value === 'object' && value instanceof Color) {
// mutate the color by a random amount
const r = Math.min(Math.max(value.r + rng.normalFloat(0, traitMutationRate) * 1000, 0), 255)
const g = Math.min(Math.max(value.g + rng.normalFloat(0, traitMutationRate) * 1000, 0), 255)
const b = Math.min(Math.max(value.b + rng.normalFloat(0, traitMutationRate) * 1000, 0), 255)
return Color.fromRGB(r, g, b) as GeneticTraitValue<V>
} else if (typeof value === 'object' && value instanceof Array) {
// what type does the array contain?
// type can be bool, string or number
const type = typeof value[0]
if (type === 'number') {
// each number in the array is mutated by a random amount
return (value as number[]).map( (v: number) => {
const modifier = rng.normalFloat(0, traitMutationRate) - rng.normalFloat(0, traitMutationRate)
return v + modifier
}) as GeneticTraitValue<V>
} else if (type === 'boolean') {
// there is a `traitMutationRate` chance for each array value to flip
return (value as boolean[]).map( (v: boolean) => rng.uniformFloat(0, 1) < traitMutationRate ? !v : v) as GeneticTraitValue<V>
} else {
throw new Error(`Array<${type}> is not supported by the default mutation function.`)
}
}
},
recombinantFunction: (value: GeneticTraitValue<V>, otherValue: GeneticTraitValue<V>, rng: RandomGenerator) => {
if (typeof value === 'number' && typeof otherValue === 'number') {
return (value + otherValue) / 2
} else if (typeof value === 'boolean' && typeof otherValue === 'boolean') {
return rng.uniformFloat(0, 1) < 0.5 ? value : otherValue
} else if (typeof value === 'object' && value instanceof Color && otherValue instanceof Color) {
return value.blend(otherValue, 0.5)
} else if (typeof value === 'object' && value instanceof Array && otherValue instanceof Array) {
// what type does the array contain?
// type can be bool, string or number
const type = typeof value[0]
const otherType = typeof otherValue[0]
if (type === 'number' && otherType === 'number') {
// each number in the array is the average of the two parent values
return (value as number[]).map( (v: number, i: number) => (v + (otherValue as number[])[i]) / 2)
} else if (type === 'boolean' && otherType === 'boolean') {
// each boolean in the array is randomly chosen from the two parent values
return (value as boolean[]).map( (v: boolean, i: number) => rng.uniformFloat(0, 1) < 0.5 ? v : otherValue[i])
} else {
throw new Error(`Array<${type}> and Array<${otherType}> is not supported by the default recombinant function.`)
}
}
}
}
const traitObject = {
value,
min: options?.min ?? undefined,
max: options?.max ?? undefined,
mutationFunction: options?.mutationFunction ?? defaultOptions.mutationFunction,
recombinantFunction: options?.recombinantFunction ?? defaultOptions.recombinantFunction,
}
return (this as T).geneticTraits.set(propertyKey, traitObject)
}
})
}
}
/**
* Dynamic traits are `Traits` which are a function of the agent's current state.
* This can be used with new properties or to override existing properties.
*
* See also: {@link Trait}
*/
export function DynamicTrait<V, T extends Agent>(f: (agent: T) => V) {
return function(target: T, propertyKey: string) {
Object.defineProperty(target, propertyKey, {
get: function() {
return f(this as T)
},
set: function(value) {
return value
}
})
}
}
/**
* An Agent-class decorator that enables the agent's energy to decrease as it moves.
* By default, the agent's energy decreases by `metabolism` units per cell moved.
* If the agent's energy drops below 0, the agent dies.
*
* Can also be enabled by setting `this.enableHunger = true` in the agent's constructor.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-object-type
export function Hungry<T extends { new (...args: any[]): {} }>(constructor: T) {
return class extends constructor {
enableHunger = true
}
}
export interface AgentConstructor {
initialPosition: [number, number]
rotation?: Angle,
initialEnergy?: number,
maxEnergy?: number,
metabolism?: number,
reproductionThreshold?: number,
sightRange?: number
fov?: Angle
randomSeed?: number
radius?: number
id?: string
color?: Color
strokeColor?: Color
style?: AgentStyle
traitMutationRate?: number
traitCrossoverRate?: number
enableHunger?: boolean
}
/**
* Base class for all agents in the simulation.
* The agent comes with the following defaults:
*
* - energy = 1
* - maxEnergy = 5
* - metabolism = 0.01
* - reproductionThreshold = maxEnergy / 2
* - sightRange = 10
* - fov = 90 degrees
* - radius = 1
* - color = blue
* - strokeColor = black
* - style = circle
* - traitMutationRate = 0.01
* - traitCrossoverRate = 0.5
*
* By default, all traits are stable and do not change by reproduction.
* This behavior can be overridden by using the `GeneticTrait` decorator.
* Moreover, the agent's energy will not decrease as it moves.
* This behavior can be enabled using the `Hungry` class decorator.
*
* No agent should be instantiated directly. Instead, create a subclass that implements the `act` method.
* Any custom fields which are to be inherited by children using the `reproduce` method
* should be decorated with the `Trait` decorator.
*/
export default abstract class Agent {
/**
* The agent's position in the world.
* Setting this value directly instead of using the agent's movement methods
* will not update the agent's energy level, rotation, and other side effects
* applied by the movement methods.
*
* See also: {@link moveTo()}, {@link move()}, {@link wander()}
*/
public position: Vector2
/**
* The agent's previous position in the world
*/
public previousPosition: Vector2
/**
* The agent's rotation in radians
*/
public rotation: Angle
/**
* The agent's energy level. If the energy level drops below 0,
* the agent is considered dead.
*/
public energy: number
/**
* The agent's maximum energy level.
* The agent's energy level will not increase beyond this value.
*/
public maxEnergy: number
/**
* A boolean flag indicating whether the agent is alive or dead.
*/
public isAlive: boolean
/**
* The amount of energy the agent loses per cell of distance moved.
*/
public metabolism: number
/**
* The energy level at which the agent can reproduce.
*/
public reproductionThreshold: number
/**
* The range of the agent's vision measured in cells.
*/
public sightRange: number
/**
* The agent's field of view.
*/
public fov: Angle
/**
* The radius of the agent, measured in cells.
*/
public radius: number
/**
* The agent's color when rendered.
*/
public color: Color
/**
* The agent's stroke color when rendered.
*/
public strokeColor: Color
/**
* The shape of the agent when rendered.
*/
public style: AgentStyle
/**
* The average mutation rate for genetic traits
* passed down to children.
*/
public traitMutationRate: number
/**
* The probability that a child inherits a
* new genetic trait from a parent.
*/
public traitCrossoverRate: number
/**
* The random number generator used by the agent.
*/
public rng: RandomGenerator
/**
* The unique identifier for the agent
*/
public readonly id: string = uuidv4()
/**
* Used as a time step for the agent's behavior.
*/
public dt: number = 0.1
/**
* The generation of the agent.
* Children created by this agent will have their `generation`
* value incremented by 1.
*/
public generation: number = 0
/**
* The agent's genetic traits. These traits are inherited by children with some mutation.
* Genetic traits should be decorated with the `GeneticTrait` decorator.
* See also: {@link GeneticTraitValue}
*/
public geneticTraits: Map<
string,
GeneticTraitOptions<string|number|boolean|Color> & { value: GeneticTraitValue<string|number|boolean|Color> }
> = new Map()
/**
* The agent's inherited traits. These traits are inherited by children without mutation.
* Inherited traits should be decorated with the `Trait` or `DynamicTrait` decorator.
* See also: {@link Trait}, {@link DynamicTrait}
*/
public inheritedTraits: Map<string, GeneticTraitValue<string|number|boolean|Color>> = new Map()
public className: string = 'Agent'
public enableHunger: boolean
/**
* Agent Constructor
* @constructor
* @param {AgentConstructor} opts - The options for the agent
*/
constructor(opts: AgentConstructor) {
this.className = this.constructor.name
// position is the only required input
this.position = new Vector2(opts.initialPosition)
this.previousPosition = this.position
this.rotation = opts.rotation ?? new Angle(0, 'rad')
this.rng = new RandomGenerator(opts.randomSeed)
this.isAlive = true
this.radius = opts.radius ?? 1
this.energy = opts.initialEnergy ?? 1
this.maxEnergy = opts.maxEnergy ?? 5
this.metabolism = opts.metabolism ?? 0.01
this.reproductionThreshold = opts.reproductionThreshold ?? this.maxEnergy / 2
this.sightRange = opts.sightRange ?? 10
this.fov = opts.fov ?? new Angle(90, 'deg')
this.traitMutationRate = opts.traitMutationRate ?? 0.01
this.traitCrossoverRate = opts.traitCrossoverRate ?? 0.5
this.color = opts.color ?? Color.fromName('blue')
this.strokeColor = opts.strokeColor ?? Color.fromName('black')
this.style = opts.style ?? AgentStyle.CIRCLE
this.enableHunger = opts.enableHunger ?? false
}
/**
* The agent's main behavior. This method should be implemented by subclasses.
* It is used to update the agent's state and interact with the world.
*/
public abstract act(
grid: CellGrid<Cell>,
agentSets: { [key: string]: AgentSet<Agent> },
tick: number
): void
/**
* Moves the agent to a specified location while updating related properties
* such as the agent's energy level and rotation.
*
* If the next location is outside the world bounds:
* - **FINITE** An error will be thrown.
* - **PERIODIC** The next location will be recalculated to wrap the agent around to the other side of the world.
*/
public moveTo<U extends Cell>(
world: CellGrid<U>,
nextLocation: [number, number],
): void {
// assert the next location is within the world bounds
if (world.boundaryCondition === 'finite' && !world.isInBounds(nextLocation)) {
throw new Error(`Agent cannot move to location ${nextLocation} because it is outside the world bounds: (X: ${world.width}, Y: ${world.height})`)
}
let adjustedLocation = nextLocation
if (world.boundaryCondition === 'periodic') {
adjustedLocation = [
(nextLocation[0] + world.width) % world.width,
(nextLocation[1] + world.height) % world.height
]
}
// remove the agent from the current cell
world.getCellNearest(this.position.components).tenantAgents.delete(this)
// calculate the displacement based on the world's boundary condition
let displacement: number
if (world.boundaryCondition === 'finite') {
displacement = Math.sqrt((adjustedLocation[0] - this.position.x) ** 2 + (adjustedLocation[1] - this.position.y) ** 2)
} else if (world.boundaryCondition === 'periodic') {
const dx = Math.abs(adjustedLocation[0] - this.position.x)
const dy = Math.abs(adjustedLocation[1] - this.position.y)
const x = Math.min(dx, world.width - dx)
const y = Math.min(dy, world.height - dy)
displacement = Math.sqrt(x ** 2 + y ** 2)
}
// update the agent's position
this.previousPosition = this.position
this.position = new Vector2(adjustedLocation)
// update angle as the agent's current position and the last position
// the rotation is normalized to the range [0, 2π)
let newRotation = Math.atan2(this.position.y - this.previousPosition.y, this.position.x - this.previousPosition.x)
if (newRotation < 0) {
newRotation += 2 * Math.PI
}
this.rotation.set(newRotation, 'rad')
// add the agent to the new cell
world.getCellNearest(this.position.components).tenantAgents.add(this)
// update the agent's energy level if hunger is enabled
if (this.enableHunger) {
this.energy -= (this.metabolism * displacement)
}
if (this.energy < 0) {
this.die()
}
}
/**
* Moves the agent in the direction it is facing.
* The agent's default time step is used to determine the distance moved. Else, a specified distance may be used.
*
* Depending on the world's boundary condition, the agent will behave differently:
* - **FINITE** If the next location is outside the world bounds, the agent will move to the edge of the world instead.
* - **PERIODIC** If the next location is outside the world bounds, the agent will wrap around to the other side of the world.
*
* See {@link moveTo()} for Agent side effects.
*/
public move<U extends Cell>(world: CellGrid<U>, distance: number = this.dt): void {
const dx = distance * Math.cos(this.rotation.asRadians())
const dy = distance * Math.sin(this.rotation.asRadians())
const nextLocation = [this.position.x + dx, this.position.y + dy] as [number, number]
if (world.boundaryCondition === 'finite') {
const x = Math.min(Math.max(nextLocation[0], 0), world.width - 1)
const y = Math.min(Math.max(nextLocation[1], 0), world.height - 1)
this.moveTo(world, [x, y])
return
}
if (world.boundaryCondition === 'periodic') {
this.moveTo(world, nextLocation)
return
}
// else, throw an error
throw new Error(`Boundary condition '${world.boundaryCondition}' is not recognized.`)
}
/**
* Swaps the agent's position with another agent without effecting their energy level or rotation.
*/
public swap<U extends Cell, T extends Agent>(world: CellGrid<U>, other: T): void {
if (!other) {
throw new Error('Agent cannot swap with undefined agent')
}
// remove each agents from their current cells
this.getCell(world).tenantAgents.delete(this)
other.getCell(world).tenantAgents.delete(other)
// swap the agents' positions
const tempPosition = this.position
this.position = other.position
other.position = tempPosition
// add the agents to their new cells
this.getCell(world).tenantAgents.add(this)
other.getCell(world).tenantAgents.add(other)
}
/**
* The agent moves in a random direction with a specified wiggle angle (default 90 deg).
* The agent's `dt` property is used to determine the default distance moved.
*/
public wander<U extends Cell>(
world: CellGrid<U>,
options?: {
wiggle?: Angle,
strideLength?: number
} ): void {
const {
wiggle = new Angle(90, 'deg'),
strideLength = this.dt
} = options ?? {}
const theta = this.rng.uniformFloat(0, wiggle.asDegrees()) - this.rng.uniformFloat(0, wiggle.asDegrees())
this.rotation.increment(theta, 'deg')
this.move(world, strideLength)
}
/**
* Rotates the agent to face a specified cell.
*/
public faceCell<U extends Cell>(world: CellGrid<Cell>, cell: U): void {
if (world.boundaryCondition === 'finite') {
this.rotation.set(Math.atan2(cell.location[1] - this.position.y, cell.location[0] - this.position.x), 'rad')
return
}
if (world.boundaryCondition === 'periodic') {
const dx = Math.abs(this.position.x - cell.location[0])
const dy = Math.abs(this.position.y - cell.location[1])
const wrappedDistance_x = Math.min(dx, world.width - dx)
const wrappedDistance_y = Math.min(dy, world.height - dy)
const wrappedDistanceToCell = Math.sqrt(wrappedDistance_x ** 2 + wrappedDistance_y ** 2)
const directDistanceToCell = Math.sqrt(dx ** 2 + dy ** 2)
if (directDistanceToCell <= wrappedDistanceToCell) {
this.rotation.set(Math.atan2(cell.location[1] - this.position.y, cell.location[0] - this.position.x), 'rad')
return
} else {
return
}
}
throw new Error('Boundary condition not recognized')
}
/**
* Rotates the agent to face a specified agent.
*/
public faceAgent<T extends Agent>(world: CellGrid<Cell>, agent: T): void {
if (world.boundaryCondition === 'finite') {
this.rotation.set(Math.atan2(agent.position.y - this.position.y, agent.position.x - this.position.x), 'rad')
return
}
if (world.boundaryCondition === 'periodic') {
const dx = Math.abs(this.position.x - agent.position.x)
const dy = Math.abs(this.position.y - agent.position.y)
const wrappedDistance_x = Math.min(dx, world.width - dx)
const wrappedDistance_y = Math.min(dy, world.height - dy)
const wrappedDistanceToCell = Math.sqrt(wrappedDistance_x ** 2 + wrappedDistance_y ** 2)
const directDistanceToCell = Math.sqrt(dx ** 2 + dy ** 2)
if (directDistanceToCell <= wrappedDistanceToCell) {
this.rotation.set(Math.atan2(agent.position.y - this.position.y, agent.position.x - this.position.x), 'rad')
return
} else {
return
}
}
throw new Error('Boundary condition not recognized')
}
/**
* The agent consumes an environmental resource and gains energy. The source of the energy can be another agent or a cell.
* The agent's net energy gain is determined by the source's (Agent or Cell) energy level and the `efficiencyFunction` and `greed` options.
* An agent eats by following these rules in order:
* 1. Agents are greedy and will take 100% of the energy of the source. This can be modified by the `greed` option.
* 2. Agents are perfectly efficient and will add 100% of the taken energy to their energy. This can be modified by the `efficiencyFunction` option.
*
* Both options are functions that take an energy level at a stage in the process and return a new energy level. By default, both functions are the identity function
* of their input.
*/
public eat(
source: Agent | Cell,
options?: {
greedFunction?: (sourceEnergy: number) => number,
efficiencyFunction?: (energyTakenFromSource: number) => number,
}
): void {
const greedFunction = options?.greedFunction ?? ((sourceEnergy: number) => sourceEnergy)
const efficiencyFunction = options?.efficiencyFunction ?? ((energyTakenFromSource: number) => energyTakenFromSource)
const energyTakenFromSource = greedFunction(source.energy)
const energyConsumed = efficiencyFunction(energyTakenFromSource)
source.energy -= energyTakenFromSource
this.energy = Math.min(this.energy + energyConsumed, this.maxEnergy)
if ( source instanceof Agent ) {
source.die()
}
}
/**
* Sets `isAlive` to `false`.
* An agent dies when it runs out of energy or is eaten by another agent.
*/
public die(): void {
this.isAlive = false
}
/**
* Gives the distance between the agent and another agent or cell.
*/
public distanceTo<T extends Agent | Cell>(
world: CellGrid<Cell>,
other: T,
options?: {
metric?: 'euclidean' | 'manhattan',
boundaryCondition?: BoundaryCondition
}
): number {
const {
metric = 'euclidean',
boundaryCondition = world.boundaryCondition
} = options ?? {}
const otherPosition = other instanceof Agent ? other.position : new Vector2(other.location)
if (boundaryCondition === 'finite') {
if (metric === 'euclidean') {
return Math.sqrt((this.position.x - otherPosition.x) ** 2 + (this.position.y - otherPosition.y) ** 2)
} else {
return Math.abs(this.position.x - otherPosition.x) + Math.abs(this.position.y - otherPosition.y)
}
}
if (boundaryCondition === 'periodic') {
const dx = Math.abs(this.position.x - otherPosition.x)
const dy = Math.abs(this.position.y - otherPosition.y)
const distance_x = Math.min(dx, world.width - dx)
const distance_y = Math.min(dy, world.height - dy)
if (metric === 'euclidean') {
return Math.sqrt(distance_x ** 2 + distance_y ** 2)
} else {
return distance_x + distance_y
}
}
throw new Error('Boundary condition not recognized')
}
/**
* Reproduces the agent by creating a new agent with half the energy of the parent.
* The parent agent loses half of its energy in the process.
*
* If a maximum population is set and the current population is at the maximum, the agent will not reproduce
* and this function will return `undefined`.
*
* The new agent inherits all of the parent's traits. Genetic traits are mutated according to their `mutationFunction`.
* If another agent is provided, the new agent will inherit traits that both agents share and a
* `traitCrossoverRate` chance of inheriting new traits that the other agent does not have.
* Genetic traits are recombined according to their `recombinantFunction`.
*
* A new random seed is generated for the new agent using the parent's random number generator.
*/
public reproduce<T extends Agent>(
opts?: { childPosition?: [number, number], other?: T}
): T | undefined {
if (Runtime.currentPopulation >= Runtime.maxPopulation) {
return undefined
}
const {
childPosition = this.position.components,
other = undefined
} = opts ?? {}
this.energy /= 2
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const child = new (this.constructor as any)({
initialPosition: childPosition,
initialEnergy: this.energy / 2,
randomSeed: this.rng.uniformInt(0, 1000000),
}) as T
this.inheritedTraits.forEach( (value, key) => {
child.inheritedTraits.set(key, value)
})
// if another agent is provided, apply crossover to traits they share
if (other) {
for (const [key, value] of this.geneticTraits) {
if (!other.geneticTraits.has(key)) {
// if the other agent does not have the trait,
// there is a small chance the child will inherit the trait
if (this.rng.uniformFloat(0, 1) < this.traitCrossoverRate) {
child.geneticTraits.set(key, value)
}
continue
}
const otherValue = other.geneticTraits.get(key).value
const newValue = value.recombinantFunction(value.value, otherValue, this.rng)
child.geneticTraits.set(key, {
value: newValue,
min: value.min,
max: value.max,
mutationFunction: value.mutationFunction,
recombinantFunction: value.recombinantFunction
})
}
}
// apply mutation to all genetic traits
this.geneticTraits.forEach((value, key) => {
let newValue = value.mutationFunction(value.value, this.rng, this.traitMutationRate)
// clamp mutated value to min and max if they exist
if (typeof value.value === 'number' && typeof newValue === 'number') {
newValue = Math.min(Math.max(newValue, value.min ?? -Infinity), value.max ?? Infinity)
}
child.geneticTraits.set(key, {
value: newValue,
min: value.min,
max: value.max,
mutationFunction: value.mutationFunction,
recombinantFunction: value.recombinantFunction
})
})
child.generation = this.generation + 1
return child
}
/**
* Gets the cell the agent is currently occupying.
* If the agent's position is not an integer, it will be rounded to the nearest integer.
* If the agent's position is not a number, an error will be thrown.
* If the agent's position is outside the world bounds, undefined will be returned.
*/
public getCell<T extends Cell>(world: CellGrid<T>): T | undefined {
// the agent's location may not be an integer
const x = Math.floor(this.position.x)
const y = Math.floor(this.position.y)
// check if x & y are numbers
if (isNaN(x) || isNaN(y)) {
throw new Error(`Agent position is not a number: ${this.position.x}, ${this.position.y}`)
}
return world.getCell([x, y])
}
/**
* Gets the cells adjacent to the agent. Optionally include diagonal cells.
* Excludes the agent's location cell by default.
*/
public getNeighborCells<T extends Cell>(
world: CellGrid<T>,
options?: {
includeDiagonals?: boolean,
includeOwnCell?: boolean
}
): T[] {
const {
includeDiagonals = false,
includeOwnCell = false
} = options ?? {}
const thisCell = this.getCell(world)
if (!thisCell) {
throw new Error(`Agent is outside the world bounds: ${this.position.x}, ${this.position.y}`)
}
return thisCell.getNeighbors(world, {includeDiagonals, includeSelf: includeOwnCell})
}
/**
* Gets the neighboring cells within the agent's vision range.
* Uses the agent's `sightRange` property by default.
* Excludes the agent's own cell by default.
*
* If the agent is outside the world bounds, an empty array will be returned.
*/
public getCellsWithinRange<T extends Cell>(
world: CellGrid<T>,
options?: {
range?: number,
includeOwnCell?: boolean
}
): T[] {
if (!world.isInBounds(this.position.components)) {
return []
}
const { range = this.sightRange, includeOwnCell = false } = options ?? {}
const thisCell = this.getCell(world)
if (!thisCell) {
throw new Error(`Agent is outside the world bounds: ${this.position.x}, ${this.position.y}`)
}
return thisCell.getNeighborsInRadius(world, { radius: range, includeSelf: includeOwnCell })
}
/**
* Gets agents within this agent's adjacent cells.
*/
public getNeighbors<T extends this, U extends Cell>(
world: CellGrid<U>,
options?: {
includeDiagonals?: boolean,
includeSelf?: boolean,
}
): AgentSet<T> {
return AgentSet.fromArray(
this.getNeighborCells(world, options).flatMap(cell => [...cell.tenantAgents] as T[])
)
}
/**
* Gets the agents within the some range of the agent.
* Uses the agent's `sightRange` property by default.
*/
public getAgentsWithinRange<T extends Agent>(
world: CellGrid<Cell>,
agents: AgentSet<T>,
options?: {
range?: number,
includeSelf?: boolean,
metric?: 'euclidean' | 'manhattan'
}
): AgentSet<T> {
const {
includeSelf = false,
range = this.sightRange,
} = options ?? {}
const neighbors = agents.getWithinRange(world, this.position.components, range, options)
if (!includeSelf) {
neighbors.remove(this as unknown as T)
}
return neighbors
}
/**
* Gets the agents within the agent's field of view and range.
* Uses agent's `fov` and `sightRange` properties by default.
*/
public getAgentsWithinCone<T extends Agent>(
world: CellGrid<Cell>,
options?: {
fov?: Angle,
range?: number,
includeSelf?: boolean,
metric?: 'euclidean' | 'manhattan'
}
): AgentSet<T> {
const cells = this.getCellsWithinCone(world, options)
const agents = cells.flatMap(cell => [...cell.tenantAgents]) as T[]
return AgentSet.fromArray(agents)
}
/**
* Gets any cells within the agent's field of view.
* Uses agent's `fov` and `sightRange` properties by default.
*/
public getCellsWithinCone<T extends Cell>(
world: CellGrid<T>,
options?: {
fov?: Angle,
range?: number,
}
): T[] {
const {
fov = this.fov,
range = this.sightRange
} = options ?? {}
const prelimNeighbors = this.getCellsWithinRange(world, { range })
const isPointInArc = (x, y, xc, yc, r, theta1, theta2, L) => {
// Normalize the point and center for periodic boundary conditions
const dx = ((x - xc + L) % L + L) % L // Wrap x-distance
const dy = ((y - yc + L) % L + L) % L // Wrap y-distance
// Adjust dx and dy to account for the shortest periodic distance
const adjustedDx = dx > L / 2 ? dx - L : dx
const adjustedDy = dy > L / 2 ? dy - L : dy
// Calculate the distance from the center
const distance = Math.sqrt(adjustedDx ** 2 + adjustedDy ** 2)
// Check if the point lies within the radius of the arc
if (distance > r) return false
// Calculate the angle of the point relative to the arc center
const pointAngle = Math.atan2(adjustedDy, adjustedDx)
// Normalize angles to [0, 2π]
const normalizeAngle = (angle) => (angle + 2 * Math.PI) % (2 * Math.PI)
const normalizedTheta1 = normalizeAngle(theta1)
const normalizedTheta2 = normalizeAngle(theta2)
const normalizedPointAngle = normalizeAngle(pointAngle)
// Check if the point angle lies within the arc's angle range
if (normalizedTheta1 <= normalizedTheta2) {
return (
normalizedTheta1 <= normalizedPointAngle &&
normalizedPointAngle <= normalizedTheta2
)
} else {
// Handle arcs that cross the 0 angle (e.g., 350° to 10°)
return (
normalizedPointAngle >= normalizedTheta1 ||
normalizedPointAngle <= normalizedTheta2
)
}
}
return prelimNeighbors.filter(cell =>
isPointInArc(
cell.location[0],
cell.location[1],
this.position.x,
this.position.y,
range,
this.rotation.asRadians() - (fov.asRadians() / 2),
this.rotation.asRadians() + (fov.asRadians() / 2),
world.width
)
)
}
/**
* Gets the cell in front of the agent according to its current
* position and rotation, and world boundary conditions.
*/
public getCellInFront<T extends Cell>(world: CellGrid<T>): T | undefined {
const dx = Math.cos(this.rotation.asRadians())
const dy = Math.sin(this.rotation.asRadians())
const x = Math.round(this.position.x + dx)
const y = Math.round(this.position.y + dy)
return world.getCellNearest([x, y])
}
/**
* Gets the cell in front and to the left of the agent according to its current
* position and rotation, and world boundary conditions.
*/
public getCellInFrontAndLeft<T extends Cell>(world: CellGrid<T>): T | undefined {
const dx = Math.cos(this.rotation.asRadians() + Math.PI / 4)
const dy = Math.sin(this.rotation.asRadians() + Math.PI / 4)
const x = Math.round(this.position.x + dx)
const y = Math.round(this.position.y + dy)
return world.getCellNearest([x, y])
}
/**
* Gets the cell in front and to the right of the agent according to its current
* position and rotation, and world boundary conditions.
*/
public getCellInFrontAndRight<T extends Cell>(world: CellGrid<T>): T | undefined {
const dx = Math.cos(this.rotation.asRadians() - Math.PI / 4)
const dy = Math.sin(this.rotation.asRadians() - Math.PI / 4)
const x = Math.round(this.position.x + dx)
const y = Math.round(this.position.y + dy)
return world.getCellNearest([x, y])
}
/**
* Serializes an agent as a JSON object.
*/
public toJSON(): object {
const propNames = Object.getOwnPropertyNames(this)
const json = {}
propNames.forEach((key) => {
if (this[key] instanceof Agent) {
return
}
if (this[key] instanceof Set) {
json[key] = Array.from(this[key])
return
}
if (this[key] instanceof Vector2) {
json[key] = this[key].components
return
}
if (this[key] instanceof RandomGenerator) {
return this[key].seed
}
if (this[key] instanceof Map) {
json[key] = Array.from(this[key].entries())
return
}
if (this[key] instanceof Angle) {
json[key] = `${this[key].asDegrees()} deg`
return
}
if (this[key] instanceof Color) {
json[key] = this[key].toRGB()
return
}
json[key] = this[key]
})
return json
}
}