Browse Source

initial commit

main
deicidus 2 years ago
commit
aab65a0daf
  1. 122
      calculations.ts
  2. 798
      cards.ts
  3. 30
      crypto.ts
  4. 100
      members.ts
  5. 1549
      mutations.ts
  6. 132
      semantics.ts
  7. 351
      types.ts
  8. 44
      utils.ts

122
calculations.ts

@ -0,0 +1,122 @@
const satsPerBtc = 100000000 // one hundred million per btc
import { createHash } from './crypto.js'
import { Task } from './types.js'
export function crawlerHash(tasks: Task[], taskId: string) {
const crawlerResults = crawler(tasks, taskId)
let buffers: number[] = []
crawlerResults.forEach(tId => {
buffers.push(...Buffer.from(tId))
})
const bufferResult = Buffer.from(buffers)
return createHash(bufferResult)
}
export function crawler(tasks: Task[], taskId: string): string[] {
let history: string[] = []
tasks.forEach(task => {
if (task.taskId === taskId) {
let crawler: string[] = [taskId]
do {
let newCards: string[] = []
crawler.forEach(t => {
if (history.indexOf(t) >= 0) return
history.push(t)
let subTask = tasks.filter(pst => pst.taskId === t)[0]
if (subTask) {
let gridCells =
subTask.pins && subTask.pins.length >= 1
? subTask.pins.map(pin => pin.taskId)
: []
newCards = newCards
.concat(subTask.subTasks)
.concat(subTask.priorities)
.concat(subTask.completed)
.concat(gridCells)
}
})
crawler = newCards
} while (crawler.length > 0)
}
})
return history
}
export function shortName(name) {
let limit = 280
let shortened = name.substring(0, limit)
if (name.length > limit) {
shortened += '…'
}
return shortened
}
export function cardColorCSS(color) {
return {
redwx: color == 'red',
bluewx: color == 'blue',
greenwx: color == 'green',
yellowwx: color == 'yellow',
purplewx: color == 'purple',
blackwx: color == 'black',
}
}
export function isString(x) {
return Object.prototype.toString.call(x) === '[object String]'
}
export function cadToSats(cadAmt, spot) {
let sats = (parseFloat(cadAmt) / parseFloat(spot)) * satsPerBtc
return Math.floor(sats)
}
export function satsToCad(sats, spot) {
let cad = sats * (spot / satsPerBtc)
return cad.toFixed(2)
}
export function calculateMsThisMonth() {
let today = new Date()
let daysThisMonth = new Date(
today.getFullYear(),
today.getMonth(),
0
).getDate()
return daysThisMonth * 24 * 60 * 60 * 1000
}
export function getMeridienTime(ts) {
let d = new Date(parseInt(ts))
let hour24 = d.getHours()
let rollover = 0
if (hour24 >= 24) {
rollover = 1
hour24 %= 24
}
let hour, meridien
if (hour24 > 12) {
meridien = 'pm'
hour = hour24 - 12
} else {
meridien = 'am'
hour = hour24
}
let date = d.getDate() + rollover
let month = d.getMonth() + 1
let minute = d.getMinutes()
let year = d.getFullYear()
let weekday = d.toString().slice(0, 3)
return { weekday, year, month, date, hour, minute, meridien }
}
export function toTitleCase(str) {
return str.replace(/\w\S*/g, function (txt) {
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()
})
}

798
cards.ts

@ -0,0 +1,798 @@
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
}

30
crypto.ts

@ -0,0 +1,30 @@
import crypto from 'crypto' // Does not work on client because this is a Node library, but works for below server-only functions
// These libraries are old but they work and can be included on both server and client
import shajs from 'sha.js'
import hmac from 'hash.js/lib/hash/hmac.js'
import sha256 from 'hash.js/lib/hash/sha/256.js' // Only works for shorter hashes, not in createHash used for hashing meme files
export function createHash(payload) {
return shajs('sha256').update(payload).digest('hex')
}
export function hmacHex(data, signingKey) {
return hmac(sha256, signingKey).update(data).digest('hex')
}
export function derivePublicKey(p) {
return crypto.createPublicKey(p).export({
type: 'spki',
format: 'pem',
})
}
export function encryptToPublic(pub, info) {
return crypto.publicEncrypt(pub, new Buffer(info)).toString('hex')
}
export function decryptFromPrivate(priv, hiddenInfo) {
return crypto
.privateDecrypt(priv, Buffer.from(hiddenInfo, 'hex'))
.toString('latin1')
}

100
members.ts

@ -0,0 +1,100 @@
import { getTask } from './cards.js'
// DUPLICATED function from client/cardActions.ts
// Returns the number of vouches a member has. Member card must exist for each voucher. Members do not have to be active to vouch.
// This function can be used either on the client or the server. If on the server, server state must be passed as the second arg.
export function countVouches(memberId, state): number | null {
let card
card = getTask(state.tasks, memberId)
if (!card || !card.hasOwnProperty('deck')) return null
let count = 0
const memberCards = card.deck
.map(memberId => state.members.find(m => m.memberId === memberId))
.forEach(memberCard => {
if (memberCard !== undefined) {
count++
}
})
return count
}
// Returns true if the senpai memberId is ahead of the kohai memberId in the members list order
export function isAheadOf(senpaiId, kohaiId, state, errRes?) {
if (errRes === undefined) {
errRes = []
}
let senpaiRank = state.members.findIndex(m => m.memberId === senpaiId)
let kohaiRank = state.members.findIndex(m => m.memberId === kohaiId)
if (senpaiRank < kohaiRank) {
return 1
} else if (kohaiRank < senpaiRank) {
return -1
}
errRes.push('member is not ahead of other member in order of member list')
return 0
}
// Returns true if the senpai has more attack than the kohai has defense
// A member's defense is their number of vouches, or the highest attack score out of anyone who vouches for them (whichever is greater)
// This method does not check if vouchers exist, therefore it depends on the mutations being perfect
// and there not being any invalid members leftover in the .deck / vouchers list of the other member
export function isDecidedlyMorePopularThan(senpaiId, kohaiId, state, errRes?) {
if (errRes === undefined) {
errRes = []
}
const senpaiCard = state.tasks.find(t => t.taskId === senpaiId)
if (!senpaiCard) {
errRes.push('invalid member detected')
return null
}
const kohaiCard = state.tasks.find(t => t.taskId === kohaiId)
if (!kohaiCard) {
errRes.push('invalid member detected')
return null
}
const senpaiVouches = countVouches(senpaiId, state)
let kohaiVouchCards = state.tasks.filter(
t => kohaiCard.deck.indexOf(t.taskId) >= 0
)
let kohaiVouches = kohaiVouchCards.length
kohaiVouchCards.forEach(card => {
if (card.taskId !== senpaiCard.taskId) {
kohaiVouches = Math.max(kohaiVouches, countVouches(card.taskId, state))
}
})
if (senpaiVouches > kohaiVouches) {
return 1
} else if (kohaiVouches > senpaiVouches) {
return -1
}
errRes.push('member does not have more vouches than other member')
return 0
}
// Returns true if the senpaiId member is both isAheadOf and isDecidedlyMorePopularThan the kohaiId member
export function isSenpaiOf(senpaiId, kohaiId, state, errRes?) {
if (errRes === undefined) {
errRes = []
}
const rank = isAheadOf(senpaiId, kohaiId, state, errRes)
const vouches = isDecidedlyMorePopularThan(senpaiId, kohaiId, state, errRes)
if (rank === 1 && vouches === 1) {
return 1
} else if (rank === -1 && vouches === -1) {
return -1
}
errRes.push('member is not a senpai of the other member')
return 0
}

1549
mutations.ts

File diff suppressed because it is too large Load Diff

132
semantics.ts

@ -0,0 +1,132 @@
const defaultSemantics = {
glossary: {
card: 'card',
user: 'member',
username: 'hackername',
proposal: 'proposition',
avatar: 'avatar',
},
levels: {
0: 'guest',
1: 'member',
2: 'elite member',
},
}
let loadedGlossary = {}
/* Too complex for .env, need a new solution, maybe use fs here to import custom JSON or YAML file
if (config.semantics && config.semantics.glossary) {
loadedGlossary = config.semantics.glossary
}*/
const serverGlossary = { ...defaultSemantics.glossary, ...loadedGlossary }
function pluralize(word) {
let plural = word
if (Array.isArray(plural)) {
plural = plural[1]
} else {
if (plural[plural.length - 1] === 's') {
plural = plural + 'es'
} else {
plural = plural + 's'
}
}
return plural
}
export function capitalize(word) {
if (word.length < 1) {
return ''
}
return word[0].toUpperCase() + word.substring(1)
}
// Returns the given word or string, with all instances of words in the glossary in configuration.js replaced with their gloss.
// In a multi-word string, it will correctly distinguish between all-lowercase keywords and those
// with their first letter capitalized, and replace them correctly. Original hardcoded keywords must be typed (in this codebase)
// in either all lowercase or with the first letter capitalized to be caught be the word replacement.
export function gloss(wordOrSentence, plural = false) {
let result
if (wordOrSentence.indexOf(' ') < 0) {
const word = wordOrSentence
result = word
const lowercase = word.toLowerCase()
const pluralEntry = Object.entries(serverGlossary).find(
([keyword, synonym]) => {
return (
(Array.isArray(keyword) && keyword[1] === lowercase) ||
pluralize(keyword) === lowercase
)
}
)
const singularEntry = Object.entries(serverGlossary).find(
([keyword, synonym]) =>
(Array.isArray(keyword) && keyword[0] === lowercase) ||
keyword === lowercase
)
if (pluralEntry || singularEntry) {
result = pluralEntry ? pluralize(pluralEntry[1]) : singularEntry[1]
if (Array.isArray(result)) {
result = result[0]
}
if (word[0].toLowerCase() !== word[0]) {
result = result[0].toUpperCase() + result.substring(1)
}
}
} else {
result = wordOrSentence
Object.entries(serverGlossary).forEach(([keyword, synonym]) => {
// replace lowercase plural version of the keyword
const pluralKeyword = pluralize(keyword)
const pluralSynonym = pluralize(synonym)
let regexp = new RegExp('\\b' + pluralKeyword + '\\b', 'g')
result = result.replace(regexp, pluralSynonym)
// replace capitalized plural version of the keyword
const pluralKeywordUppercase = capitalize(pluralKeyword)
const pluralSynonymUppercase = capitalize(pluralSynonym)
regexp = new RegExp('\\b' + pluralKeywordUppercase + '\\b', 'g')
result = result.replace(regexp, pluralSynonymUppercase)
// replace lowercase singular version of the keyword
regexp = new RegExp('\\b' + keyword + '\\b', 'g')
const singularSynonym = Array.isArray(synonym) ? synonym[0] : synonym
result = result.replace(regexp, singularSynonym)
// replace capitalized singular version of the keyword
const singularKeywordUppercase = capitalize(keyword)
const singularSynonymUppercase = capitalize(singularSynonym)
regexp = new RegExp('\\b' + singularKeywordUppercase + '\\b', 'g')
result = result.replace(regexp, singularSynonymUppercase)
})
}
return result
}
let loadedLevels = {}
/*if (config.semantics && config.semantics.levels) {
loadedLevels = config.semantics.levels
}*/
const serverLevels = { ...defaultSemantics.levels, ...loadedLevels }
export function glossLevel(level) {
if (level < 0) {
return null
}
let highestMatchingWord
Object.entries(serverLevels).some(([index, word]) => {
if (index <= level) {
highestMatchingWord = word
}
if (index >= level) {
return true
}
})
return highestMatchingWord || 'member'
}
export function getSemantics() {
return { glossary: serverGlossary, levels: serverLevels }
}

351
types.ts

@ -0,0 +1,351 @@
// Every card has a color, right now only five preset colors exist (they can be renamed with the glossary and recolored with CSS)
export type Color = 'red' | 'yellow' | 'green' | 'purple' | 'blue' | 'black'
// The regions or areas within a card
// There are four main zones within a card: the priorities,
// the optional pinboard (grid/pyramid/rune),the subTasks (main pile), and completed cards
export type CardZone =
| 'card' // The card itself, the whole card
| 'priorities' // The card's priorities section (right card drawer)
| 'grid' // A pinboard that can be added to the card (shows on card)
| 'subTasks' // The main pile of subcards within this card (bottom card drawer)
| 'completed' // Checked-off tasks archived when discarded (viewable in priorities card drawer)
| 'context' // The context area above a card that contains the card history (cards can be dragged from here)
| 'discard' // The background of the page behind the card, where cards can be dropped to discard
| 'panel' // Any other unspecified side panel where cards can be dragged out of (to copy, not move, the card)
| 'gifts' // The gifts area at the top left of the member card where cards you receive accumulate.
| 'stash' // The stashed cards area of the card (right card drawer)
// A card's pinboard can be in one of three styles
export type PinboardStyle = 'grid' | 'pyramid' | 'rune'
// The global left sidebar can open and display one of these tabs at a time
export type LeftSidebarTab =
| 'hub'
| 'gifts'
| 'guilds'
| 'members'
| 'calendar'
| 'bounties'
| 'manual'
| 'search'
| 'deck'
// The global right sidebar can display one of these Bull tabs
export type RightSidebarTab = 'resources' | 'p2p' | 'crypto' | 'membership'
// The right side of a card displays these tabs, which can be clicked to open the corresponding card drawer
export type CardTab = 'priorities' | 'timecube' | 'lightning'
// When a member gifts/sends/passes a card to another member, the cards .pass array holds an array of passes
// The 0th element holds the memberId of the sender, and the 1st element holds the memberId of the recipient
export type CardPass = string[2]
// Definition of an AO
export interface AoState {
session: string
token: string
loggedIn: boolean
user: string
ao: ConnectedAo[]
sessions: Session[]
members: Member[]
tasks: Task[]
resources: Resource[]
memes: Meme[]
socketState?: string
protectedRouteRedirectPath?: string
bookings?: Booking[] // Used on server to track calendar events on a timer
cash: {
address: string
alias: string
currency: string
spot: number
rent: number
cap: number
quorum: number
pay_index: number
usedTxIds: number[]
outputs: Output[]
channels: Channel[]
info: SatInfo
}
loader?: {
token: string
session: string
connected: string
connectionError: string
reqStatus: string
lastPing: number
}
}
// An AO serves its members, who each have an account on the AO server.
export interface Member {
type: 'member-created' // The event is added directly to the database so it has this as an artifact, could filter on member-created and remove here
name: string // The name of the member
memberId: string // The unique UUID of the member
address: string // ???
active: number // The member's active status. Number increases each consecutive active month.
balance: number // Member's point balance
badges: [] // Badges that the member has collected
tickers: Ticker[] // Customizable list of crypto tickers on the member's right sidebar
info: {} // ???
timestamp: number // When the member was created
lastUsed: number // Last time the member logged in, used a resource, or barked
muted: boolean // Whether the member has sound effects turned on or off (sound effects not currently implemented)
priorityMode: boolean // Whether the member has activated Priority Mode, which shows the first priority above its parent card
fob: string // The member can enter a fob ID number from a physical fob, saved here for when they tap
potentials: Signature[] // List of potential actions built up on the member (not currently in use)
banned: boolean // True if the member is currently banned (member continues to exist)
draft: string // The member's currently-saved draft (also saved on client)
tutorial?: boolean // Whether the member has completed the initial interactive tour of the AO
p0wned?: boolean // Whether the member has had their password reset (changes to false when they set it themselves)
phone?: string // Phone number used for Signal notifications
}
// A member can create and collect cards. The words 'card' and 'task' are used as synonyms, because the AO is meant for action.
export interface Task {
taskId: string // ao-react: Random UUID | ao-3: CRC-32 hash of the content
name: string // The text of the card, the main content. Can be plain text, Markdown, or HTML and JavaScript (optimized for injection).
color: Color // Color of the card as a word | Future: Could be any color word or hex code paired with a word naming the color
deck: string[] // *Array of memberIds of members who grabbed and are holding the card in their deck
guild: string | boolean // Optional guild / pin / tag title for the card. This is editable (unlike cards currently). Guild cards are indexed in Guilds sidebar on left. (Value of 'true' could mean that guild name equals card text [not implemented yet].)
address: string // Mainnet bitcoin address for this card (generated by calling address)
bolt11?: string // Lightning network bitcoin address for this carde (generated by calling invoice-created)
payment_hash: string //
book?: Booking // Book/schedule this card as an event on the calendar
priorities: string[] // *Array of taskIds of cards prioritized within this card
subTasks: string[] // *Array of taskIds of cards within this card
completed: string[] // *Array of taskIds of checked-off completed cards within this cards. Cards saved here when discarded with any checkmarks on them.
pinboard?: Pinboard | null // *Pinboard object containing pinboard properties
pins?: Pin[] // *New way of doing the Grid, Pyramid, and upcoming Rune layouts for the card
parents: string[] // *List of this cards parents, ought to be kept updated by mutations.
claimed: string[] // Lists of taskIds who have checked this card (thus claiming the bounty)
claimInterval?: number // Automatic uncheck timer in milliseconds [this feature will change to uncheck the cards within]
//uncheckInterval // Rename of claimInterval to be rolled out
uncheckThisCard?: boolean // Unchecks this card every uncheckInterval if true
uncheckPriorities?: boolean // Unchecks prioritized cards every uncheckInterval if true
uncheckPinned?: boolean // Unchecks pinned cards every uncheckInterval if true (maybe could combine with uncheckPriorities)
dimChecked?: boolean // If true, checked cards on the pinboard and in the priorities list will display visually dimmed to make tasking easier
signed: Signature[] // Members can explicitly sign cards to endorse them (future option to counter-sign as -1 is already built-in)
passed: string[][] // Array of [senderMemberId, receiverMemberId] pairs of pending gifts sent to the receiving member. Cleared when opened.
giftCount?: number // Count of unopened gift cards that ought to be kept automatically updated, for showing this number ot other members
lastClaimed: number // The last time someone checked this card off (Unix timestamp)
allocations: Allocation[] // List of points temporarily allocated to this card from parent cards, making this card a claimable bounty
boost: number // Bonus points on the card (?)
goal?: number // Optional points goal shows after the current number of points on a card, e.g., 8/10 points raised in the crowdfund.
highlights: number[]
seen: UserSeen[] // Array of events marking the first (?) or most recent (?) time they looked at the card. Used for unread markers.
timelog?: LabourTime[] // Arary of timelog events on the card
created: number // When the card was created (Unix timestamp)
showChatroom?: boolean // Whether or not to show the chatroom tab. Only allowed on cards with a .guild set for simplicity and transparency's sake.
avatars?: AvatarLocation[] // When a member joins a chatroom, it shows they are "at" that card. | Future: Little avator icons that can be moved from card to card or clicked to follow.
memberships?: Membership[] // Members can "join" a card as members. The first member is automatically Level 2 and can boss the Level 1's around. You can decrease your level and lose your power.
showStash?: boolean // Whether or not to show the stash tab. Only allowed on cards with a .guild set for simplicity and transparency's sake.
stash?: {
// *Stash of more cards associated with this card. Members have access to stashes of their level and below.
[key: number]: string[] // Each numbered stash level contains a list of taskIds of cards stored in that stash level.
}
unionHours?: number // Number of estimated hours for the task
unionSkill?: number // Skill level required for the task (0-5)
unionHazard?: number // Hazard level for the task (0-5)
loadedFromServer?: boolean // True if the card has been loaded from the server, false if empty placeholder taskId object
stars?: number // Can be simple number or later a Rating[]
touches?: number // New feature to count number of time a card was handled, to identify popular cards and personal hotspots.
aoGridToolDoNotUpdateUI?: boolean // Rendering hack, maybe this can be improved and removed
// *These properties contain taskIds of cards that are within or closely associated with this card, for the purposes of search, content buffering, etc.
}
// A booked/scheduled event or resource
export interface Booking {
memberId: string // The member that scheduled the event (?)
startTs: number // The start of the event (Unix timestamp)
endTs: number // The end time of the event. Optional—but if omitted behavior is undefined. (Unix timestamp)
}
// An AO can connect to another AO over tor to send cards
export interface ConnectedAo {
name?: string
address: string
outboundSecret: false | string
inboundSecret: string
lastContact: number
links: string[]
}
// Hardware devices can be connected to the AO as resources over LAN. Resources can be activated in the Bull (right sidebar).
export interface Resource {
resourceId: string // UUID of the resource
name: string
charged: number // How many points it costs to use the resource each time
secret: string // ???
trackStock: boolean // If true, the resource will track its inventory
stock: number // Simple numeric tracking of inventory stock, works for most things
}
// Files detected in the ~/.ao/memes folder are each loaded as a Meme
export interface Meme {
memeId: string // UUID that matches the corresponding taskId of the card that is created in lockstep with the Meme object.
filename: string // Just the filename and extension, not the path
hash: string // Hash of the file
filetype: string
}
// Cordinates of a card on a pinboard or in a list of cards
export interface Coords {
x?: number
y: number
}
// Specifies a card taskId at a given location at a specific location within another card.
// For pinboard locations, .coords must coordinates for the current pinboard type
// For stashed cards, .level specifies which stash level the card is stored in
export interface CardLocation {
taskId?: string // Optional because sometimes a CardLocation is used to described a location where a card will be played/placed
inId?: string
zone?: CardZone // Optional because sometimes a CardLocation describes a card that is not in any location yet
level?: number
coords?: Coords
}
// An atomic card play, defining a card to remove (from) and a card to place (to)
export interface CardPlay {
from: CardLocation
to: CardLocation
}
// Defines the dimensions and other properties of a spread or layout of cards.
// Could be expanded or inherited from to create new types of spreads such as a freeform canvas or non-euclidian pinboard.
export interface Pinboard {
spread: PinboardStyle
height: number
width?: number
size: number // Size of squares, roughly in ems
}
// A card pinned to a pinboard
// Pinboards set to rune layout only use x (y always 0)
export interface Pin {
taskId: string
y: number
x?: number
}
// A guild can temporarily allocate some of its points to a task in its priorities
// Anyone who checks off the task will claim the points on it, including all its allocations
// Allocated points are actually moved from the parent card at the time (in state) and moved back if the card is deprioritized
// All mutations related to moving points around must be perfect to prevent double spend issues
export interface Allocation {
type?: string
taskId: string
allocatedId: string
amount: number
blame?: string
}
// A member may sign a card with a positive (1, default), neutral (0), or opposing (-1) opinion, or instead sign with a note/comment
// This can be used for votes, upvotes/downvotes (and maybe reaction emojis? reactions could replace signing)
export interface Signature {
memberId: string
timestamp: Date
opinion: number | string
}
// A member may rate a card 0-5 stars
export interface Rating {
memberId: string
stars: number
}
// A card is marked when a member sees it for the first time. Used to mark unread cards (feature currently inactive).
export interface UserSeen {
memberId: string
timestamp: Date
}
// Log of one duration of time to the timelog on a card
export interface LabourTime {
memberId: string
taskId: string
inId: string
start: number
stop: number
}
// Each member has an "avatar" (currently no icon yet) that can be placed on a card to show your location to other members.
// When you join a chatroom on a card, you automatically hop your avatar to that card (task-visited event).
export interface AvatarLocation {
memberId: string
timestamp: number
area: number
}
// Members can join guilds. Members of a guild have a numeric level.
// Each level has a stash of cards that only members of that level or above can edit.
// Members of higher levels can change the level of members with the same or lower levels.
// The system is stupid and you can increase your own level too low and lose your power, or too high and mess up the stash.
export interface Membership {
memberId: string
level: number
}
// Members can add tickers in the sidebar, which show the exchange rate between a 'from' and 'to' currency (three-letter currency abbreviation)
export interface Ticker {
from: string
to: string
}
export interface Output {
value: number
}
export interface Channel {
channel_sat: number
channel_total_sat: number
}
// A browser session object
export interface Session {
type: 'session-created' // Event is added directly to the database, this is an artifact of that
session: string // Session string?
ownerId: string // MemeberId of the session owner
timestamp: Date // When the session was created
}
export interface LightningChannel {
peer_id?: any
funding_txid?: any
state?: any
connected?: boolean
channel_total_sat: number
channel_sat: number
}
export interface SatInfo {
channels?: LightningChannel[]
mempool?: { sampleTxns: any[]; size: any; bytes: any }
blockheight?: number
blockfo?: any
id?: any
outputs?: any[]
address?: { address: string }[]
}
export interface SearchResults {
query: string
page: number
missions: Task[]
members: Task[]
tasks: Task[]
all: Task[]
length: number
}
export const emptySearchResults = {
missions: [],
members: [],
tasks: [],
all: [],
length: 0,
}

44
utils.ts

@ -0,0 +1,44 @@
export const cancelablePromise = promise => {
let isCanceled = false
const wrappedPromise = new Promise((resolve, reject) => {
promise.then(
value => (isCanceled ? reject({ isCanceled, value }) : resolve(value)),
error => reject({ isCanceled, error })
)
})
return {
promise: wrappedPromise,
cancel: () => (isCanceled = true),
}
}
export const noop = () => {}
export const delay = n => new Promise(resolve => setTimeout(resolve, n))
export const isObject = obj => {
return Object.prototype.toString.call(obj) === '[object Object]'
}
export const convertToDuration = (milliseconds: number) => {
const stringifyTime = (time: number): string => String(time).padStart(2, '0')
const seconds = Math.floor(milliseconds / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
return `${stringifyTime(hours)}:${stringifyTime(
minutes % 60
)}:${stringifyTime(seconds % 60)}`
}
export const convertToTimeWorked = (milliseconds: number) => {
const seconds = Math.floor(milliseconds / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
return `${hours}h, ${minutes % 60}m`
} else {
return `${minutes % 60}m`
}
}
Loading…
Cancel
Save