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(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(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 }