AO code common to all AO projects (ao-server, ao-svelt, ao-3, ao-cli), included on both client and server
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

798 lines
24 KiB

import { isString } from './calculations.js'
import { v1 } from 'uuid'
import {
Task,
Signature,
UserSeen,
CardZone,
Coords,
CardLocation,
Pinboard,
PinboardStyle,
CardPass,
} from './types.js'
// With this set to 1, actions will occur immediately, as if they were not potential-based actions
export const POTENTIALS_TO_EXECUTE = 1
// Default style when a new pinboard is created without specifiying the style
const defaultPinboardStyle: PinboardStyle = 'pyramid'
// Grid squares have a width measure in ems. The default number for this is 9 and the +/- buttons increase or decrease this by 1.
const defaultSquareSizeInEms = 9
// The one and only function to create a new blank card, please use it everywhere cards are created for standardization and searchability.
export function blankCard(
taskId,
name,
color,
created,
deck = [],
parents = [],
height = undefined,
width = undefined
): Task {
let newCard = {
taskId,
color,
deck,
name: typeof name !== 'string' ? 'invalid filename' : name.trim(),
address: '', // Could be left undefined by default?
bolt11: '', // Could be left undefined by default?
book: undefined, // Could be left undefined by default?
boost: 0,
priorities: [],
subTasks: [],
completed: [],
pinboard:
height && width && height >= 1 && width >= 1
? blankPinboard(height, width)
: null,
pins: [],
parents: parents,
claimed: [],
passed: [],
signed: [], // Could be left undefined by default?
guild: false,
created: created,
lastClaimed: 0,
payment_hash: '', // Could be left undefined by default?
highlights: [],
seen: deck.length >= 1 ? [{ memberId: deck[0], timestamp: created }] : [],
time: [],
allocations: [],
}
return newCard
}
// Returns a blank pinboard of the default style
export function blankPinboard(
height = 3,
width = 3,
spread = defaultPinboardStyle
): Pinboard {
const newGrid = {
spread: spread,
height: height,
width: width,
size: defaultSquareSizeInEms,
}
return newGrid
}
// Version of this function for the server, merge with above
export function allReachableHeldParentsServer(tasks, origin, memberId) {
if (!origin?.hasOwnProperty('taskId')) {
return []
}
let queue = [origin]
let reachableCards = []
let visited = {}
visited[origin.taskId] = true
let i = 0
while (queue.length >= 1) {
let task = queue.pop()
if (
task === undefined ||
task.subTasks === undefined ||
task.priorities === undefined ||
task.completed === undefined
) {
console.log('Invalid task found during returned cards search, skipping.')
continue
}
if (task.deck.indexOf(memberId) < 0 && task.taskId !== memberId) {
continue
}
reachableCards.push(task)
if (task.hasOwnProperty('parents') && task.parents.length >= 1) {
let parents = tasks.filter(taskItem =>
task.parents.includes(taskItem.taskId)
)
parents.forEach(st => {
if (!st.hasOwnProperty('taskId')) {
console.log('Missing parent found during returned cards search.')
return
}
if (!visited.hasOwnProperty(st.taskId)) {
visited[st.taskId] = true
queue.push(st)
}
})
}
}
return reachableCards
}
let dupesGlossary = {}
// Adds a synonym taskId for another taskId to the duplicates glossary
export function registerDuplicateTaskId(originalTaskId, duplicateTaskId) {
if (!dupesGlossary[duplicateTaskId]) {
dupesGlossary[duplicateTaskId] = originalTaskId
console.log(Object.keys(dupesGlossary).length, 'cards with duplicates')
}
}
// Returns the task with the given taskId from the given list of tasks, or null
// Uses the duplicates glossary to return synonymous tasks that were created by junk task-created events
export function getTask(tasks, taskId) {
// Look up duplicate tasks in the duplicates glossary to politely overlook duplicate task-created mutations
let loops = 0
while (dupesGlossary[taskId] && loops < 4) {
taskId = dupesGlossary[taskId]
//console.log("Looked up duplicate task:", taskId, " (", Object.keys(dupesGlossary).length, " duplicated)")
loops++
}
if (loops >= 4) {
console.log(
'Woah, four or more redirects in the duplicates glossary, something weird'
)
}
return tasks.find(task => {
if (task.taskId === taskId) {
return task
}
})
}
// Returns the first task that exactly matches the given value of the given property
// Todo: move property to be the first argument
export function getTaskBy(tasks, value, property) {
return tasks.find(task => {
if (task[property] === value) {
return task
}
})
}
// Returns true if the given taskId matches an existing task/card in the state/database
// The state should match the database exactly,
// because it is sourced from the database via the deterministic event mutations in mutations.js
export function taskExists(tasks, taskId) {
return tasks.some(task => task.taskId === taskId)
}
// Marks the task as seen by the given memberId
export function seeTask(task, memberId) {
if (!task.seen) {
task.seen = []
}
task.seen = task.seen.filter(seenObject => seenObject.memberId !== memberId)
task.seen.push({ memberId: memberId, timestamp: Date.now() })
}
// Clears any pending passes to the specified memberId from the task
// This is done whenever a member accepts a pass or grabs or handles a card
export function clearPassesTo(tasks, task, memberId, alsoClearFrom = false) {
const lengthBefore = task.passed.length
task.passed = task.passed.filter(
d => d[1] !== memberId || (alsoClearFrom ? d[0] !== memberId : false)
)
if (lengthBefore != task.passed.length) {
const memberCard = getTask(tasks, memberId)
changeGiftCount(memberCard, task.passed.length - lengthBefore)
}
}
// Takes a member card and increases or decreases its .giftCount property, adding it if necessary
export function changeGiftCount(memberCard, amount) {
if (!memberCard) {
return
}
if (!memberCard.hasOwnProperty('giftCount')) {
memberCard.giftCount = 0
}
memberCard.giftCount += amount
if (memberCard.giftCount < 0) {
memberCard.giftCount = 0
}
}
// Grabs a task, adding it to the member's deck
// The associated pending pass on the card, if any, will be removed
// You cannot grab your own member card
export function grabTask(tasks, task, memberId) {
clearPassesTo(tasks, task, memberId)
if (memberId && task.deck.indexOf(memberId) === -1) {
if (task.taskId !== memberId) {
task.deck.push(memberId)
}
}
}
// Drops the task, removing it from the member's deck
export function dropTask(task, memberId) {
task.deck = task.deck.filter(d => d !== memberId)
}
// Adds the given taskId to list of the card's parents (this is a cache)
export function addParent(task, parentId) {
if (!task.hasOwnProperty('parents') || !Array.isArray(task.parents)) {
task.parents = []
}
if (!task.parents.some(pId => pId === parentId)) {
task.parents.push(parentId)
}
}
// Removes the given taskId from the list of the card's parents
// This function seems to make no sense
export function removeParent(task, parentId) {
if (!task.hasOwnProperty('parents') || !Array.isArray(task.parents)) {
return
}
let gridCells = []
if (task.pins && task.pins.length >= 1) {
gridCells = task.pins.map(pin => pin.taskId)
}
let stashItems: string[] = []
if (task.stash && Object.keys(task.stash).length >= 1) {
stashItems = [...Object.values<string>(task.stash)]
}
const allSubTasks = [
...task.priorities,
...task.subTasks,
...gridCells,
...task.completed,
...stashItems,
]
if (!allSubTasks.some(stId => stId === parentId)) {
task.parents = task.parents.filter(tId => tId !== parentId)
}
}
// Removes the second card from the first card's list of parents,
// unless the card is actuall still a parent
export function removeParentIfNotParent(task, parent) {
if (
!task.hasOwnProperty('parents') ||
!Array.isArray(task.parents) ||
task.parents.length < 1
) {
return
}
let gridCells = []
if (parent.pins && parent.pins.length >= 1) {
gridCells = parent.pins.map(pin => pin.taskId)
}
let stashItems: string[] = []
if (parent.stash && Object.keys(parent.stash).length >= 1) {
stashItems = [...Object.values<string>(parent.stash)]
}
const allSubTasks = [
...parent.priorities,
...parent.subTasks,
...gridCells,
...parent.completed,
...stashItems,
]
if (!allSubTasks.some(stId => stId === task.taskId)) {
task.parents = task.parents.filter(tId => tId !== parent.taskId)
}
}
// Removes the given taskId from the priorities, subTasks, and completed of the given task
// Does NOT remove the taskId from the grid
export function filterFromSubpiles(task, taskId) {
const start = [
task?.priorities?.length || null,
task?.subTask?.length || null,
task?.completed?.length || null,
]
discardPriority(task, taskId)
discardSubTask(task, taskId)
discardCompletedTask(task, taskId)
if (
(start[0] !== null && start[0] - task?.priorities?.length > 0) ||
(start[1] !== null && start[1] - task?.subTasks?.length > 0) ||
(start[2] !== null && start[2] - task?.completed?.length > 0)
) {
return true
}
return false
}
// Marks as unseen (clears seen from) the given task, unless it's on the list
// Unseens bubble-up one level, but if you've seen the child, it doesn't affect you
export function clearSeenExcept(task, exceptionMemberIds: UserSeen[] = []) {
if (task?.seen?.length >= 1) {
task.seen = task.seen.filter(userseen =>
exceptionMemberIds.includes(userseen.memberId)
)
}
}
// Re-adds the given taskId to the given card's subTasks (moving it to the end)
// This will move it to the top/front of the list of cards in the GUI
// Precondition: The subtask referred to by subTaskId exists (otherwise it will create a broken card link / missing reference)
export function addSubTask(task, subTaskId) {
if (!task) {
console.log(
'Attempting to add a subtask to a missing task, this should never happen'
)
return
}
discardSubTask(task, subTaskId)
task.subTasks.push(subTaskId)
}
// Removes the given discardTaskId from the given task's subtasks
function discardSubTask(task, discardTaskId) {
if (!task || !discardTaskId || !task.subTasks || task.subTasks.length <= 0)
return
task.subTasks = task.subTasks.filter(stId => stId !== discardTaskId)
}
// Removes the given discardTaskId from the given task's completed tasks list
function discardCompletedTask(task, discardTaskId) {
if (!task || !discardTaskId || !task.completed || task.completed.length <= 0)
return
task.completed = task.completed.filter(stId => stId !== discardTaskId)
}
// Adds a completed task to the completed list in a card or moves it to the top of the list
function addCompletedTask(task, completedTaskId) {
discardCompletedTask(task, completedTaskId)
task.completed.push(completedTaskId)
}
// Adds the subTask to the given new parent task and adds the parent as a parent to the new subTask
export function putTaskInTask(subTask, inTask) {
addSubTask(inTask, subTask.taskId)
addParent(subTask, inTask.taskId)
}
// Re-adds the given taskId to the given card's priorities (moving it to the end)
// This will move it to the top/front of the list of cards in the GUI
export function addPriority(task, taskId) {
discardPriority(task, taskId)
task.priorities.push(taskId)
}
// Removes the given discardTaskId from the given task's subtasks
function discardPriority(task, discardTaskId) {
if (
!task ||
!discardTaskId ||
!task.priorities ||
task.priorities.length <= 0
)
return
task.priorities = task.priorities.filter(stId => stId !== discardTaskId)
}
export function unpinTasksOutOfBounds(tasks, task) {
if (!tasks || !task || !task.pins || task.pins.length <= 0) {
return
}
const vertLimit = task.pinboard.spread === 'rune' ? 1 : task.pinboard.height
for (let i = task.pins.length - 1; i >= 0; i--) {
const pin = task.pins[i]
const { taskId, y, x } = pin
const horizLimit =
task.pinboard.spread === 'pyramid' ? y + 1 : task.pinboard.width
if (x >= horizLimit || y >= vertLimit) {
const theSubTask = getTask(tasks, taskId)
unpinTaskFromTask(task, { y: y, x: x })
if (theSubTask) {
putTaskInTask(theSubTask, task)
} else {
console.log('A missing card was removed from the pinboard:', taskId)
}
}
}
}
// Unpins the card from the given coordinates in a card and returns its taskId
function unpinTaskFromTask(task, coords) {
let result
if (!task.pins || task.pins.length <= 0) {
return null
}
task.pins.some((pin, i) => {
const { pinId, y, x } = pin
if (y == coords.y && x == coords.x) {
result = task.pins.splice(i, 1)[0]
}
})
return result
}
// Precondition: The spec should validate whether this is a legal move based upon the current gridStyle of the card
// In other words, this function does not check if the coordinates are off the side of the pinboard
// Unlike the functions to add subtasks, this function does NOT attempt to filter the pinboard before adding a card,
// so duplicates will occur unless you unpin first from the origin coords
// However, it WILL check where the card is going to be placed, and if a card is already there, that card will drop into .subTasks
function pinTaskToTask(task, taskId, coords) {
if (!task.hasOwnProperty('pins') || !Array.isArray(task.pins)) {
task.pins = []
}
// If this taskId is already at this location, do nothing
if (
task.pins.some(
pin => pin.taskId === taskId && pin.y === coords.y && pin.x === coords.x
)
) {
return
}
// If there is already something pinned there, drop it into subTasks
const previousPinnedTaskId = unpinTaskFromTask(task, coords)?.taskId
if (previousPinnedTaskId) {
addSubTask(task, previousPinnedTaskId)
}
task.pins.push({ taskId, y: coords.y, x: coords.x })
}
function putTaskInTaskZone(task, inTask, toLocation) {
switch (toLocation.zone) {
case 'priorities':
// Move the card to the .priorities
//filterFromSubpiles(inTask, task.taskId)
addPriority(inTask, task.taskId)
addParent(task, inTask.taskId)
break
case 'grid':
// Move the card to the .pins using coordinates in the current gridStyle, or fail if not possible
// If there isn't a grid on this card, add a grid large enough for the new coordinates to fit on
if (!inTask.pinboard) {
inTask.pinboard = blankPinboard(
Math.max(toLocation.coords.y, 3),
Math.max(toLocation.coords.x, 3)
)
}
pinTaskToTask(inTask, task.taskId, toLocation.coords)
addParent(task, inTask.taskId)
break
case 'completed':
// Move the card to the .completed
addCompletedTask(inTask, task.taskId)
break
case 'discard':
// Remove the card from its inId, or save in .completed if it's
// Could replace task-de-sub-tasked
filterFromSubpiles(inTask, task.taskId)
break
case 'context':
case 'panel':
// These don't do anything on the server, it's a zone only on the client
break
case 'gifts':
// Deprecated?
break
case 'stash':
// the .level on the toLocation.level tells what stash level to put the card in
// Rethink this
break
case 'card':
case 'subTasks':
default:
// Move the card to the .subTasks (replaces task-sub-tasked)
putTaskInTask(task, inTask)
break
}
}
// Removes the specified discardTaskId from the specified zone of the given task
// If zone argument is 'card' or empty, tries to discard from priorities, subTasks, and completed (but not grid)
export function discardTaskFromZone(task, fromLocation) {
switch (fromLocation.zone) {
case 'grid':
unpinTaskFromTask(task, fromLocation.coords)
return
case 'priorities':
discardPriority(task, fromLocation.taskId)
return
case 'completed':
discardCompletedTask(task, fromLocation.taskId)
return
case 'subTasks':
discardSubTask(task, fromLocation.taskId)
return
}
}
// Moves a card from one location to another location.
// fromLocation defines the card to be unplayed from somewhere, and toLocation defines a card to be placed somewhere.
// fromLocation and toLocation are CardLocation objects defining a taskId in a location.
// The fromLocation is an optional CardLocation that, if provided, requires a taskId and zone at minimum
// If null, no card will be unplayed.
// fromLocation.taskId and toLocation.taskId can be different,
// so it is possible to play a different card than was unplayed in one move (i.e., swap out a card)
// Right now the card being played must exist; card creation and modification is separate since it includes color etc.
// Maybe toLocation should be option also, simplifying discards and further decomposing a play.
export function atomicCardPlay(
tasks,
fromLocation: CardLocation,
toLocation: CardLocation,
memberId
) {
const taskId =
fromLocation && fromLocation.taskId
? fromLocation.taskId
: toLocation.taskId
const theCard = getTask(tasks, taskId)
if (!theCard && fromLocation?.zone !== 'grid') {
return
}
const theCardMovedTo = getTask(tasks, toLocation.inId)
if (
!theCardMovedTo &&
!['discard', 'context', 'panel'].includes(toLocation.zone)
) {
console.log(
'Attempting to move a card to a missing card, this should never happen. Missing card:',
toLocation.inId,
'and zone:',
toLocation.zone
)
return
}
if (theCard && memberId) {
// memberId should be required, but temporarily for debugging
// You cannot play a card without having seen it
seeTask(theCard, memberId)
// You cannot play a card without first grabbing it
grabTask(tasks, theCard, memberId)
}
// Remove the card from wherever it was moved from
const fromInId = fromLocation?.inId
const theCardMovedFrom = getTask(tasks, fromInId)
if (theCardMovedFrom) {
discardTaskFromZone(theCardMovedFrom, fromLocation)
if (fromLocation.inId !== toLocation.inId) {
removeParentIfNotParent(theCard, theCardMovedFrom)
}
// Save the card to the completed cards if it has at least one checkmark
if (
fromLocation.zone != 'completed' &&
toLocation.zone === 'discard' &&
theCard &&
theCard.claimed &&
theCard.claimed.length >= 1
) {
addCompletedTask(theCardMovedFrom, taskId)
}
// If removing from priorities, remove allocations that were on the card from theCardMovedFrom
if (
fromLocation?.zone === 'priorities' &&
toLocation.zone !== 'priorities' &&
theCardMovedFrom.allocations &&
Array.isArray(theCardMovedFrom.allocations)
) {
theCardMovedFrom.allocations = theCardMovedFrom.allocations.filter(al => {
if (al.allocatedId === taskId) {
theCardMovedFrom.boost += al.amount
return false
}
return true
})
}
}
// Move card to wherever it was moved to
if (theCard) {
putTaskInTaskZone(theCard, theCardMovedTo, toLocation)
}
}
// Adds the given taskId to the given card's stash of the specified level
// Each membership level added to a card has a corresponding stash level
export function stashTask(task, taskId, level) {
if (
!task.hasOwnProperty('stash') ||
!(
typeof task.stash === 'object' &&
task.stash !== null &&
!Array.isArray(task.stash)
)
) {
task.stash = {}
}
if (!task.stash.hasOwnProperty('level')) {
task.stash[level] = []
}
task.stash[level] = task.stash[level].filter(tId => tId !== taskId)
task.stash[level].push(taskId)
}
// Removes the given taskId from the given card's stash of the specified level
export function unstashTask(task, taskId, level) {
if (
!task.hasOwnProperty('stash') ||
!(
typeof task.stash === 'object' &&
task.stash !== null &&
!Array.isArray(task.stash)
)
) {
return
}
if (!task.stash.hasOwnProperty('level')) {
return
}
task.stash[level] = task.stash[level].filter(tId => tId !== taskId)
}
// A potentials list is a list of signatures, each signature endorsing a specific task event-type
// When POTENTIALS_TO_EXECUTE potentials accrue for a given event-type it is executed, like an action potential
// This allows built-in AO mutations to be voted upon by members before execution
// Because it is a vote, duplicate potentials for the same event-type are prevented
export function addPotential(member, signature) {
if (!member.potentials) {
member.potentials = []
}
member.potentials = member.potentials.filter(
pot =>
!(
pot.opinion === signature.opinion && pot.memberId === signature.memberId
)
)
member.potentials.push(signature)
}
// Returns true if there are POTENTIALS_TO_EXECUTE or more potentials of the specified event-type on the object
export function checkPotential(member, eventType) {
return (
member.potentials.filter(pot => pot.opinion === eventType).length >=
POTENTIALS_TO_EXECUTE
)
}
// Clears all potentials of the specified event-type from the given card
export function clearPotential(member, eventType) {
member.potentials = member.potentials.filter(pot => pot.opinion !== eventType)
}
// Sets the lastUsed property of the given object to the given timestamp
export function updateLastUsed(member, timestamp) {
member.lastUsed = timestamp
}
export function safeMerge(cardA, cardZ) {
if (!cardA || !cardZ) {
console.log('attempt to merge nonexistent card')
return
}
if (!cardZ.taskId || !isString(cardZ.taskId)) {
console.log('attempt to merge card with a missing or invalid taskId')
return
}
if (!cardZ.color) {
console.log('attempt to merge card without a color')
return
}
if (isString(cardZ.color) && cardZ.color.trim().length >= 1) {
cardA.color = cardZ.color
}
if (isString(cardZ.guild) && cardZ.color.trim().length >= 1) {
cardA.guild = cardZ.guild
}
const filterNull = tasks => {
return tasks.filter(task => task !== null && task !== undefined)
}
cardA.book = cardZ.book
cardA.address = cardZ.address
cardA.bolt11 = cardZ.bolt11
cardA.priorities = [
...new Set(cardA.priorities.concat(filterNull(cardZ.priorities))),
]
cardA.subTasks = [
...new Set(cardA.subTasks.concat(filterNull(cardZ.subTasks))),
]
cardA.completed = [
...new Set(cardA.completed.concat(filterNull(cardZ.completed))),
]
// Replace the pinboard (maybe they could merge? or at least drop existing pins down to subTasks)
if (
cardZ.pinboard &&
cardZ.pinboard.height >= 1 &&
cardZ.pinboard.width >= 1
) {
if (!cardA.pinboard) {
cardA.pinboard = blankPinboard()
}
cardA.pinboard.height = Math.max(
cardA.pinboard.height,
cardZ.pinboard.height
)
cardA.pinboard.width = Math.max(cardA.pinboard.width, cardZ.pinboard.width)
cardA.pinboard.spread = cardZ.pinboard.spread
if (cardZ.pins && Array.isArray(cardZ.pins)) {
cardA.pins = cardZ.pins
}
}
cardA.passed = [...new Set([...cardA.passed, ...filterNull(cardZ.passed)])]
// Remove duplicate passes
let passesNoDuplicates: CardPass[] = []
cardA.passed.forEach(pass => {
if (
!passesNoDuplicates.some(
pass2 => pass[0] === pass2[0] && pass[1] === pass2[1]
)
) {
passesNoDuplicates.push(pass)
}
})
cardA.passed = passesNoDuplicates
// XXX only add in merge for now
// XXX bolt11 / address need to clearly indicate origin ao
// XXX book should be a list?
}
// A card's .signed is an append-only list of all signing events.
// This function reduces it to just each member's current opinion
// signed is type Signature[]
export function mostRecentSignaturesOnly(signed) {
let mostRecentSignaturesOnly = signed.filter((signature, index) => {
let lastIndex
for (let i = signed.length - 1; i >= 0; i--) {
if (signed[i].memberId === signature.memberId) {
lastIndex = i
break
}
}
return lastIndex === index
})
return mostRecentSignaturesOnly
}
// Signed is type Signature[]
export function countCurrentSignatures(signed) {
return mostRecentSignaturesOnly(signed).filter(
signature => signature.opinion >= 1
).length
}