From 541b103179cac2dc58962c52b5144b41f75316b0 Mon Sep 17 00:00:00 2001 From: sanine Date: Mon, 6 Nov 2023 00:57:14 -0600 Subject: refactor: agent*.js => proposal*.js --- src/world/agent.js | 170 -------------------------------------- src/world/agent.test.js | 157 ------------------------------------ src/world/proposal.js | 174 +++++++++++++++++++++++++++++++++++++++ src/world/proposal.test.js | 197 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 371 insertions(+), 327 deletions(-) delete mode 100644 src/world/agent.js delete mode 100644 src/world/agent.test.js create mode 100644 src/world/proposal.js create mode 100644 src/world/proposal.test.js diff --git a/src/world/agent.js b/src/world/agent.js deleted file mode 100644 index 95d869c..0000000 --- a/src/world/agent.js +++ /dev/null @@ -1,170 +0,0 @@ -import { pairs, deepEqual } from '../util.js'; - -/* agent structure: - * { - * id: string - * net: network - * state: network_state - * x, y: number - * flags: object - * } - */ - -/* action structure: - * { - * name: string, - * propose: (agent) => proposal[] - * } - */ - - -/* proposal structure - * all proposed world and agent changes must be included for the proposed action to be valid - * similarly, if any world or agent change creates merge conflicts, the proposal cannot be merged - * and must be removed - * { - * world_changes: proposal_world_change[]? - * agent_changes: proposal_agent_change[]? - * } - */ - -/* proposal_world_change - * { - * x, y: number - * from: string - * to: string - * flags: object? - * } - */ - -/* proposal_agent_change - * { - * agent_id: string - * x, y: number? - * flags: object? - * } - */ - - -function flags_compatible(a, b) { - const keys = [...new Set(Object.keys(a).concat(Object.keys(b)))]; - return keys.reduce( - (acc, key) => { - const eq = (a[key] === undefined || b[key] === undefined) ? true : deepEqual(a[key], b[key]); - return acc && eq; - }, - true - ); -} - - -// return a tuple [conflict, merge] -// conflict is true if the two world_changes are incompatible -// merge is true if the two world changes are identical -// otherwise they are false -function world_change_conflict(a, b) { - if (deepEqual(a, b)) { - // merge - return [false, true]; - } - if ( - a.x === b.x && - a.y === b.y && - (a.to != b.to || !flags_compatible(a.flags || {}, b.flags || {})) - ) { - // conflict! - return [true, false]; - } else { - // no conflict c: - return [false, false]; - } -} - - -// returns true as long as x & y are both defined -function pos_exists(a) { - if (a.x !== undefined && a.y !== undefined) { - return true; - } else { - return false; - } -} - - -function pos_equal(a, b) { - if (a.x !== b.x) { return false; } - if (a.y !== b.y) { return false; } - return true; -} - - -// agent changes merge if they are identical -// they conflict if two agents are trying to move to the same tile, or -// if the same agent is trying to move to two different tiles, or if an agent -// is being updated to incompatible flags -// -// -function agent_change_conflict(a, b) { - if (deepEqual(a, b)) { - // identical: merge - return [false, true]; - } else if (a.agent_id === b.agent_id) { - if ( - pos_exists(a) && pos_exists(b) && !pos_equal(a, b) - ) { - // same agent, different positions: conflict - return [true, false]; - } else if (!flags_compatible(a.flags, b.flags)) { - // same agent, incompatible flags: conflict - return [true, false]; - } else { - // no conflict c: - return [false, false]; - } - } else { - // different agents - if (pos_exists(a) && pos_exists(b) && pos_equal(a, b)) { - // different agents, same position: conflict - return [true, false]; - } else { - // no conflict c: - return [false, false]; - } - } -} - - -function proposal_conflict_merge(a, b) { - const [world_conflict, world_merge] = pairs(a.world_changes || [], b.world_changes || []).reduce( - (acc, [a, b]) => { - const [conflict, merge] = world_change_conflict(a, b); - return [acc[0] || conflict, acc[1] || merge]; - }, - [false, false] - ); - const [agent_conflict, agent_merge] = pairs(a.agent_changes || [], b.agent_changes || []).reduce( - (acc, [a, b]) => { - const [conflict, merge] = agent_change_conflict(a, b); - return [acc[0] || conflict, acc[1] || merge]; - }, - [false, false] - ); - return [world_conflict || agent_conflict, world_merge || agent_merge]; -} - - -// merge proposals -// if two sub-updates conflict, they are both omitted from the final merged proposal -export function proposal_merge(arr, proposal) { - const conflict_merge = arr.map(x => proposal_conflict_merge(x, proposal)); - - // if any conflicts are detected then don't merge - if (conflict_merge.reduce((acc, [c, m]) => acc || c, false)) { - const conflict_free = arr.filter((x, i) => !conflict_merge[i][0]); - return conflict_free; - } else { - // no conflicts, but need to merge identical actions - const no_merge = arr.filter((x, i) => !conflict_merge[i][1]); - return [...no_merge, proposal]; - } -} diff --git a/src/world/agent.test.js b/src/world/agent.test.js deleted file mode 100644 index fe73b25..0000000 --- a/src/world/agent.test.js +++ /dev/null @@ -1,157 +0,0 @@ -import { - proposal_merge, -} from './agent.js'; - - -// --===== proposal conflicts =====-- - -// tile updates - -test("proposals changing different tiles don't conflict", () => { - const a = { - world_changes: [{ x: 4, y: 3, from: 'empty', to: 'mutable' }], - }; - const b = { - world_changes: [{ x: 4, y: 4, from: 'empty', to: 'flag' }], - }; - - expect(proposal_merge([a], b)).toEqual([a, b]); -}); - - -test("proposals changing the same tile to different states conflict", () => { - const a = { - world_changes: [{ x: 4, y: 3, from: 'empty', to: 'mutable' }], - }; - const b = { - world_changes: [{ x: 4, y: 3, from: 'empty', to: 'flag' }], - }; - - expect(proposal_merge([a], b)).toEqual([]); -}); - - -test("proposals changing the same tile to the same state merge to a single proposal", () => { - const a = { - world_changes: [{ x: 4, y: 3, from: 'empty', to: 'mutable' }], - }; - const b = { - world_changes: [{ x: 4, y: 3, from: 'empty', to: 'mutable' }], - }; - - expect(proposal_merge([a], b)).toEqual([a]); -}); - - -test("proposals with identical tile updates but incompatible tile flags conflict", () => { - const a = { - world_changes: [{ x: 4, y: 3, from: 'empty', to: 'mutable', flags: { v: 'a' } }], - }; - const b = { - world_changes: [{ x: 4, y: 3, from: 'empty', to: 'mutable', flags: { v: 'b' } }], - }; - - expect(proposal_merge([a], b)).toEqual([]); -}); - - -test("proposals with identical tile updates but compatible tile flags do not conflict", () => { - const a = { - world_changes: [{ x: 4, y: 3, from: 'empty', to: 'mutable', flags: { v: 'a', r: 'd' } }], - }; - const b = { - world_changes: [{ x: 4, y: 3, from: 'empty', to: 'mutable', flags: { v: 'a', u: 'b' } }], - }; - - expect(proposal_merge([a], b)).toEqual([a, b]); -}); - - - - -test("proposals moving two agents to the same tile conflict", () => { - const a = { - agent_changes: [{ agent_id: 'aaa', x: 4, y: 3 }], - }; - const b = { - agent_changes: [{ agent_id: 'bbb', x: 4, y: 3 }], - }; - - expect(proposal_merge([a], b)).toEqual([]); -}); - - -// agent updates -test("proposals moving two agents to different tiles do not conflict", () => { - const a = { - agent_changes: [{ agent_id: 'aaa', x: 4, y: 3 }], - }; - const b = { - agent_changes: [{ agent_id: 'bbb', x: 3, y: 3 }], - }; - - expect(proposal_merge([a], b)).toEqual([a, b]); -}); - - -test("proposals moving the same agent to different tiles conflict", () => { - const a = { - agent_changes: [{ agent_id: 'aaa', x: 4, y: 3 }], - }; - const b = { - agent_changes: [{ agent_id: 'aaa', x: 3, y: 3 }], - }; - - expect(proposal_merge([a], b)).toEqual([]); -}); - - -test("proposals moving the same agent to the same tile merge", () => { - const a = { - agent_changes: [{ agent_id: 'aaa', x: 4, y: 3 }], - }; - const b = { - agent_changes: [{ agent_id: 'aaa', x: 4, y: 3 }], - }; - - expect(proposal_merge([a], b)).toEqual([a]); -}); - - -test("proposals setting flags on different agents do not conflict", () => { - const a = { - agent_changes: [{ agent_id: 'aaa', flags: { frozen: false } }], - }; - - const b = { - agent_changes: [{ agent_id: 'bbb', flags: { frozen: false } }], - }; - - expect(proposal_merge([a], b)).toEqual([a, b]); -}); - - -test("setting the same agent to compatible flags does not conflict", () => { - const a = { - agent_changes: [{ agent_id: 'aaa', flags: { frozen: false } }], - }; - - const b = { - agent_changes: [{ agent_id: 'aaa', flags: { crumpet: 'hi' } }], - }; - - expect(proposal_merge([a], b)).toEqual([a, b]); -}); - - -test("setting the same agent to incompatible flags does conflict", () => { - const a = { - agent_changes: [{ agent_id: 'aaa', flags: { frozen: false } }], - }; - - const b = { - agent_changes: [{ agent_id: 'aaa', flags: { frozen: true, crumpet: 'hi' } }], - }; - - expect(proposal_merge([a], b)).toEqual([]); -}); diff --git a/src/world/proposal.js b/src/world/proposal.js new file mode 100644 index 0000000..d1f2eb0 --- /dev/null +++ b/src/world/proposal.js @@ -0,0 +1,174 @@ +import { pairs, deepEqual } from '../util.js'; + +/* agent structure: + * { + * id: string + * net: network + * state: network_state + * x, y: number + * flags: object + * } + */ + +/* action structure: + * { + * name: string, + * propose: (agent) => proposal[] + * } + */ + + +/* proposal structure + * all proposed world and agent changes must be included for the proposed action to be valid + * similarly, if any world or agent change creates merge conflicts, the proposal cannot be merged + * and must be removed + * { + * world_changes: proposal_world_change[]? + * agent_changes: proposal_agent_change[]? + * } + */ + +/* proposal_world_change + * { + * x, y: number + * from: string + * to: string + * flags: object? + * } + */ + +/* proposal_agent_change + * { + * agent_id: string + * x, y: number? + * flags: object? + * } + */ + + +// check that two flags objects are compatible +// flags are considered compatible if they do not have any common keys with different values +function flags_compatible(a, b) { + const keys = [...new Set(Object.keys(a).concat(Object.keys(b)))]; + return keys.reduce( + (acc, key) => { + const eq = (a[key] === undefined || b[key] === undefined) ? true : deepEqual(a[key], b[key]); + return acc && eq; + }, + true + ); +} + + +// return a tuple [conflict, merge] +// conflict is true if the two world_changes are incompatible +// merge is true if the two world changes are identical +// otherwise they are false +function world_change_conflict(a, b) { + if (deepEqual(a, b)) { + // merge + return [false, true]; + } + if ( + a.x === b.x && + a.y === b.y && + (a.to != b.to || !flags_compatible(a.flags || {}, b.flags || {})) + ) { + // conflict! + return [true, false]; + } else { + // no conflict c: + return [false, false]; + } +} + + +// returns true as long as x & y are both defined +function pos_exists(a) { + if (a.x !== undefined && a.y !== undefined) { + return true; + } else { + return false; + } +} + + +// check the equality of two objects with (x,y) keys +function pos_equal(a, b) { + if (a.x !== b.x) { return false; } + if (a.y !== b.y) { return false; } + return true; +} + + +// agent changes merge if they are identical +// they conflict if two agents are trying to move to the same tile, or +// if the same agent is trying to move to two different tiles, or if an agent +// is being updated to incompatible flags +// +// +function agent_change_conflict(a, b) { + if (deepEqual(a, b)) { + // identical: merge + return [false, true]; + } else if (a.agent_id === b.agent_id) { + if ( + pos_exists(a) && pos_exists(b) && !pos_equal(a, b) + ) { + // same agent, different positions: conflict + return [true, false]; + } else if (!flags_compatible(a.flags, b.flags)) { + // same agent, incompatible flags: conflict + return [true, false]; + } else { + // no conflict c: + return [false, false]; + } + } else { + // different agents + if (pos_exists(a) && pos_exists(b) && pos_equal(a, b)) { + // different agents, same position: conflict + return [true, false]; + } else { + // no conflict c: + return [false, false]; + } + } +} + + +// combine world_change and agent_change conflict/merge tuples for a pair of proposals +function proposal_conflict_merge(a, b) { + const [world_conflict, world_merge] = pairs(a.world_changes || [], b.world_changes || []).reduce( + (acc, [a, b]) => { + const [conflict, merge] = world_change_conflict(a, b); + return [acc[0] || conflict, acc[1] || merge]; + }, + [false, false] + ); + const [agent_conflict, agent_merge] = pairs(a.agent_changes || [], b.agent_changes || []).reduce( + (acc, [a, b]) => { + const [conflict, merge] = agent_change_conflict(a, b); + return [acc[0] || conflict, acc[1] || merge]; + }, + [false, false] + ); + return [world_conflict || agent_conflict, world_merge || agent_merge]; +} + + +// merge proposals +// if two sub-updates conflict, they are both omitted from the final merged proposal +export function proposal_merge(arr, proposal) { + const conflict_merge = arr.map(x => proposal_conflict_merge(x, proposal)); + + // if any conflicts are detected then don't merge + if (conflict_merge.reduce((acc, [c, m]) => acc || c, false)) { + const conflict_free = arr.filter((x, i) => !conflict_merge[i][0]); + return conflict_free; + } else { + // no conflicts, but need to merge identical actions + const no_merge = arr.filter((x, i) => !conflict_merge[i][1]); + return [...no_merge, proposal]; + } +} diff --git a/src/world/proposal.test.js b/src/world/proposal.test.js new file mode 100644 index 0000000..a1e1203 --- /dev/null +++ b/src/world/proposal.test.js @@ -0,0 +1,197 @@ +import { + proposal_merge, +} from './proposal.js'; + + +// tile updates + +test("proposals changing different tiles don't conflict", () => { + const a = { + world_changes: [{ x: 4, y: 3, from: 'empty', to: 'mutable' }], + }; + const b = { + world_changes: [{ x: 4, y: 4, from: 'empty', to: 'flag' }], + }; + + expect(proposal_merge([a], b)).toEqual([a, b]); +}); + + +test("proposals changing the same tile to different states conflict", () => { + const a = { + world_changes: [{ x: 4, y: 3, from: 'empty', to: 'mutable' }], + }; + const b = { + world_changes: [{ x: 4, y: 3, from: 'empty', to: 'flag' }], + }; + + expect(proposal_merge([a], b)).toEqual([]); +}); + + +test("proposals changing the same tile to the same state merge to a single proposal", () => { + const a = { + world_changes: [{ x: 4, y: 3, from: 'empty', to: 'mutable' }], + }; + const b = { + world_changes: [{ x: 4, y: 3, from: 'empty', to: 'mutable' }], + }; + + expect(proposal_merge([a], b)).toEqual([a]); +}); + + +test("proposals with identical tile updates but incompatible tile flags conflict", () => { + const a = { + world_changes: [{ x: 4, y: 3, from: 'empty', to: 'mutable', flags: { v: 'a' } }], + }; + const b = { + world_changes: [{ x: 4, y: 3, from: 'empty', to: 'mutable', flags: { v: 'b' } }], + }; + + expect(proposal_merge([a], b)).toEqual([]); +}); + + +test("proposals with identical tile updates but compatible tile flags do not conflict", () => { + const a = { + world_changes: [{ x: 4, y: 3, from: 'empty', to: 'mutable', flags: { v: 'a', r: 'd' } }], + }; + const b = { + world_changes: [{ x: 4, y: 3, from: 'empty', to: 'mutable', flags: { v: 'a', u: 'b' } }], + }; + + expect(proposal_merge([a], b)).toEqual([a, b]); +}); + + + + +test("proposals moving two agents to the same tile conflict", () => { + const a = { + agent_changes: [{ agent_id: 'aaa', x: 4, y: 3 }], + }; + const b = { + agent_changes: [{ agent_id: 'bbb', x: 4, y: 3 }], + }; + + expect(proposal_merge([a], b)).toEqual([]); +}); + + +// agent updates +test("proposals moving two agents to different tiles do not conflict", () => { + const a = { + agent_changes: [{ agent_id: 'aaa', x: 4, y: 3 }], + }; + const b = { + agent_changes: [{ agent_id: 'bbb', x: 3, y: 3 }], + }; + + expect(proposal_merge([a], b)).toEqual([a, b]); +}); + + +test("proposals moving the same agent to different tiles conflict", () => { + const a = { + agent_changes: [{ agent_id: 'aaa', x: 4, y: 3 }], + }; + const b = { + agent_changes: [{ agent_id: 'aaa', x: 3, y: 3 }], + }; + + expect(proposal_merge([a], b)).toEqual([]); +}); + + +test("proposals moving the same agent to the same tile merge", () => { + const a = { + agent_changes: [{ agent_id: 'aaa', x: 4, y: 3 }], + }; + const b = { + agent_changes: [{ agent_id: 'aaa', x: 4, y: 3 }], + }; + + expect(proposal_merge([a], b)).toEqual([a]); +}); + + +test("proposals setting flags on different agents do not conflict", () => { + const a = { + agent_changes: [{ agent_id: 'aaa', flags: { frozen: false } }], + }; + + const b = { + agent_changes: [{ agent_id: 'bbb', flags: { frozen: false } }], + }; + + expect(proposal_merge([a], b)).toEqual([a, b]); +}); + + +test("setting the same agent to compatible flags does not conflict", () => { + const a = { + agent_changes: [{ agent_id: 'aaa', flags: { frozen: false } }], + }; + + const b = { + agent_changes: [{ agent_id: 'aaa', flags: { crumpet: 'hi' } }], + }; + + expect(proposal_merge([a], b)).toEqual([a, b]); +}); + + +test("setting the same agent to compatible object flags does not conflict", () => { + const a = { + agent_changes: [{ agent_id: 'aaa', flags: { emit: [0, 1, 1, 0] } }], + }; + + const b = { + agent_changes: [{ agent_id: 'aaa', flags: { emit: [0, 1, 1, 0], hi: 4 } }], + }; + + expect(proposal_merge([a], b)).toEqual([a, b]); +}); + + +test("setting the same agent to incompatible flags does conflict", () => { + const a = { + agent_changes: [{ agent_id: 'aaa', flags: { frozen: false } }], + }; + + const b = { + agent_changes: [{ agent_id: 'aaa', flags: { frozen: true, crumpet: 'hi' } }], + }; + + expect(proposal_merge([a], b)).toEqual([]); +}); + + +test("setting the same agent to incompatible object flags does conflict", () => { + const a = { + agent_changes: [{ agent_id: 'aaa', flags: { emit: [0, 1, 1, 0] } }], + }; + + const b = { + agent_changes: [{ agent_id: 'aaa', flags: { emit: [0, 1, 1, 1], hi: 4 } }], + }; + + expect(proposal_merge([a], b)).toEqual([]); +}); + + +test("setting the same agent to identical flags merges", () => { + const a = { + agent_changes: [{ agent_id: 'aaa', flags: { emit: [0, 1, 1, 0] } }], + }; + + const b = { + agent_changes: [{ agent_id: 'aaa', flags: { emit: [0, 1, 1, 0] } }], + }; + + expect(proposal_merge([a], b)).toEqual([a]); +}); + + + -- cgit v1.2.1