From 3faf5b329851ca6695b83e96cb86480b827aab90 Mon Sep 17 00:00:00 2001 From: kumavis Date: Sat, 25 Nov 2023 21:31:46 -1000 Subject: [PATCH 1/6] feat(1kce): persist deck via petnames --- packages/cli/demo/1kce/deck.js | 29 +++++++++++++++++++++++++++-- packages/cli/demo/1kce/ui/app.js | 6 +----- packages/cli/demo/1kce/weblet.js | 26 +++++++++++++++++++++++--- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/packages/cli/demo/1kce/deck.js b/packages/cli/demo/1kce/deck.js index 685ab9d478..5aac56657c 100644 --- a/packages/cli/demo/1kce/deck.js +++ b/packages/cli/demo/1kce/deck.js @@ -1,10 +1,35 @@ -import { Far } from '@endo/far'; +import { E, Far } from '@endo/far'; import { makeIteratorRef } from '@endo/daemon/reader-ref.js'; import { makeSyncArrayGrain } from '@endo/grain'; import { makeRemoteGrain } from '@endo/grain/captp.js'; +import { makeRefIterator } from '@endo/daemon/ref-reader.js'; -export const make = () => { +const cardPrefix = 'card-'; + +export const make = (powers) => { const cards = makeSyncArrayGrain(); + + // cards already saved in petstore + const followNames = async () => { + for await (const change of makeRefIterator(E(powers).followNames())) { + if (change.add === undefined) continue + const name = change.add + if (!name.startsWith(cardPrefix)) continue + const card = await E(powers).lookup(name); + cards.push(card); + } + } + // incomming cards + const followMessages = async () => { + for await (const message of makeRefIterator(E(powers).followMessages())) { + if (message.type !== 'package') continue + await E(powers).adopt(message.number, 'card', `${cardPrefix}${cards.getLength()}`); + } + } + + followNames() + followMessages() + return Far('Deck', { add (card) { cards.push(card); diff --git a/packages/cli/demo/1kce/ui/app.js b/packages/cli/demo/1kce/ui/app.js index 58bdd5d57d..867ae59213 100644 --- a/packages/cli/demo/1kce/ui/app.js +++ b/packages/cli/demo/1kce/ui/app.js @@ -22,12 +22,8 @@ export const App = ({ inventory }) => { const deck = await inventory.makeNewDeck() setDeck(deck) }, - async addCardToDeck (card) { - await E(deck).add(card); - }, async addCardToDeckByName (cardName) { - const card = await inventory.lookup(cardName) - await E(deck).add(card); + return inventory.addCardToDeckByName(cardName) }, } diff --git a/packages/cli/demo/1kce/weblet.js b/packages/cli/demo/1kce/weblet.js index 25dd0aebe2..7b436e3e83 100644 --- a/packages/cli/demo/1kce/weblet.js +++ b/packages/cli/demo/1kce/weblet.js @@ -4,10 +4,10 @@ import { make as makeApp } from './ui/index.js'; // no way of resolving relative paths from the weblet const projectRootPath = './demo/1kce'; +const deckGuestName = 'guest-deck'; -const makeThing = async (powers, importFullPath, resultName) => { +const makeThing = async (powers, importFullPath, resultName, powersName = 'NONE') => { const workerName = 'MAIN'; - const powersName = 'NONE'; const deck = await E(powers).importUnsafeAndEndow( workerName, importFullPath, @@ -32,7 +32,27 @@ export const make = (powers) => { async makeNewDeck () { const importFullPath = `${projectRootPath}/deck.js`; const resultName = 'deck'; - return await makeThing(powers, importFullPath, resultName) + const powersName = deckGuestName + // delete existing guest, its petstore is what stores the cards + if (await E(powers).has(powersName)) { + await E(powers).remove(powersName) + } + // make new guest + await E(powers).provideGuest(powersName) + // make deck + return await makeThing(powers, importFullPath, resultName, powersName) + }, + async addCardToDeckByName (cardName) { + await E(powers).send( + // destination guest + deckGuestName, + // description + [`add card to deck: "${cardName}"`], + // name inside send envelope + ['card'], + // my petname for the obj + [cardName], + ); }, async makeGame () { const importFullPath = `${projectRootPath}/game.js`; From 906644ba774631a7e18a0fb500617342665356e3 Mon Sep 17 00:00:00 2001 From: kumavis Date: Sun, 26 Nov 2023 09:24:54 -1000 Subject: [PATCH 2/6] fix(cli): fix "endo cat" when invoked via "yarn endo" --- packages/cli/src/cat.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/src/cat.js b/packages/cli/src/cat.js index c56d06f01c..4d653a8be5 100644 --- a/packages/cli/src/cat.js +++ b/packages/cli/src/cat.js @@ -13,4 +13,6 @@ export const cat = async ({ name, partyNames }) => for await (const chunk of reader) { process.stdout.write(chunk); } + // "yarn endo" will not display output unless we write a newline + process.stdout.write('\n'); }); From f91d23d1eac080d9de177a745eaba48ab3d3c378 Mon Sep 17 00:00:00 2001 From: kumavis Date: Sun, 26 Nov 2023 09:27:14 -1000 Subject: [PATCH 3/6] feat(daemon): guest gets "store" and "has" methods --- packages/daemon/src/daemon.js | 1 + packages/daemon/src/guest.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/packages/daemon/src/daemon.js b/packages/daemon/src/daemon.js index 96fa30a5b7..8d77e83081 100644 --- a/packages/daemon/src/daemon.js +++ b/packages/daemon/src/daemon.js @@ -615,6 +615,7 @@ const makeEndoBootstrap = ( const makeIdentifiedGuestController = makeGuestMaker({ provideValueForFormulaIdentifier, provideControllerForFormulaIdentifier, + storeReaderRef, makeMailbox, }); diff --git a/packages/daemon/src/guest.js b/packages/daemon/src/guest.js index ef6da3eca5..cf64255979 100644 --- a/packages/daemon/src/guest.js +++ b/packages/daemon/src/guest.js @@ -1,10 +1,12 @@ // @ts-check import { Far } from '@endo/far'; +import { assertPetName } from './pet-name.js'; export const makeGuestMaker = ({ provideValueForFormulaIdentifier, provideControllerForFormulaIdentifier, + storeReaderRef, makeMailbox, }) => { /** @@ -69,10 +71,39 @@ export const makeGuestMaker = ({ terminator, }); + /** + * @param {import('@endo/eventual-send').ERef>} readerRef + * @param {string} [petName] + */ + const store = async (readerRef, petName) => { + if (petName !== undefined) { + assertPetName(petName); + } + + const formulaIdentifier = await storeReaderRef(readerRef); + + if (petName !== undefined) { + await petStore.write(petName, formulaIdentifier); + } + }; + + /** + * @param {string} petName + */ + const has = async petName => { + try { + await lookup(petName); + return true; + } catch (error) { + return false; + } + } + const { list, follow: followNames } = petStore; /** @type {import('@endo/eventual-send').ERef} */ const guest = Far('EndoGuest', { + has, lookup, reverseLookup, request, @@ -87,6 +118,7 @@ export const makeGuestMaker = ({ adopt, remove, rename, + store, }); const internal = harden({ From f6decb7bd95c50a8f86dce10c11f24a53b1a06b3 Mon Sep 17 00:00:00 2001 From: kumavis Date: Mon, 27 Nov 2023 20:09:18 -1000 Subject: [PATCH 4/6] fix(daemon): implement "has" in mailbox to differentiate between lookup failures --- packages/daemon/src/guest.js | 13 +------------ packages/daemon/src/host.js | 13 +------------ packages/daemon/src/mail.js | 12 ++++++++++++ 3 files changed, 14 insertions(+), 24 deletions(-) diff --git a/packages/daemon/src/guest.js b/packages/daemon/src/guest.js index cf64255979..29151d7697 100644 --- a/packages/daemon/src/guest.js +++ b/packages/daemon/src/guest.js @@ -47,6 +47,7 @@ export const makeGuestMaker = ({ } const { + has, lookup, reverseLookup, followMessages, @@ -87,18 +88,6 @@ export const makeGuestMaker = ({ } }; - /** - * @param {string} petName - */ - const has = async petName => { - try { - await lookup(petName); - return true; - } catch (error) { - return false; - } - } - const { list, follow: followNames } = petStore; /** @type {import('@endo/eventual-send').ERef} */ diff --git a/packages/daemon/src/host.js b/packages/daemon/src/host.js index dc2faa7f82..c46c6e7b7f 100644 --- a/packages/daemon/src/host.js +++ b/packages/daemon/src/host.js @@ -37,6 +37,7 @@ export const makeHostMaker = ({ ); const { + has, lookup, reverseLookup, lookupFormulaIdentifierForName, @@ -417,18 +418,6 @@ export const makeHostMaker = ({ return value; }; - /** - * @param {string} petName - */ - const has = async petName => { - try { - await lookup(petName); - return true; - } catch (error) { - return false; - } - } - const { list, follow: followNames } = petStore; /** @type {import('./types.js').EndoHost} */ diff --git a/packages/daemon/src/mail.js b/packages/daemon/src/mail.js index c41c4d8d5f..f6a0bbc92c 100644 --- a/packages/daemon/src/mail.js +++ b/packages/daemon/src/mail.js @@ -40,6 +40,17 @@ export const makeMailboxMaker = ({ return petStore.lookup(petName); }; + /** + * @param {string} petName + */ + const has = async petName => { + const formulaIdentifier = lookupFormulaIdentifierForName(petName); + if (formulaIdentifier === undefined) { + return false + } + return true + }; + /** * @param {string} petName */ @@ -469,6 +480,7 @@ export const makeMailboxMaker = ({ }; return harden({ + has, lookup, reverseLookup, reverseLookupFormulaIdentifier, From 53baa792a8fb3532f8ddd8ff6a61b49b499d72dc Mon Sep 17 00:00:00 2001 From: kumavis Date: Mon, 27 Nov 2023 20:10:30 -1000 Subject: [PATCH 5/6] fix(grain): protect against inf sub loops and accidental grainMap.set --- packages/grain/src/index.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/grain/src/index.js b/packages/grain/src/index.js index 50e3ae9ebd..ead9eafc67 100644 --- a/packages/grain/src/index.js +++ b/packages/grain/src/index.js @@ -89,14 +89,20 @@ export const makeSyncGrain = initValue => { return _get() } // set a new value and notify subscribers + let updateInProgress = false const set = (newValue) => { if (lifecycle.isDestroyed()) { throw new Error('grain is destroyed') } + if (updateInProgress) { + throw new Error('grain set while processing subscriptions, possible infinite loop') + } + updateInProgress = true value = newValue for (const handler of subscriptionHandlers) { handler(value) } + updateInProgress = false } const update = (update) => { set(update(value)) @@ -367,6 +373,9 @@ export const makeSyncGrainMap = (grains = {}) => { // composed grain const grainMap = makeSyncGrain({}) + const set = () => { + throw new Error('cannot set grain map') + } const hasGrain = (key) => { if (lifecycle.isDestroyed()) { throw new Error('grain is destroyed') @@ -423,6 +432,7 @@ export const makeSyncGrainMap = (grains = {}) => { ...grainMap, // overrides and additions ...readonly(), + set, destroy, setGrain, } From 95592201f07299818e4d7bc119539be2c630b196 Mon Sep 17 00:00:00 2001 From: kumavis Date: Mon, 27 Nov 2023 20:16:06 -1000 Subject: [PATCH 6/6] feat(1kce): daemon persistence --- packages/cli/demo/1kce/cards/deja-vu.js | 8 +- .../demo/1kce/cards/library-of-alexandria.js | 21 +- .../cli/demo/1kce/cards/pokemon-outrage.js | 3 +- packages/cli/demo/1kce/deck.js | 24 +- packages/cli/demo/1kce/game.js | 383 ++++++++++++------ packages/cli/demo/1kce/ui/app.js | 14 +- packages/cli/demo/1kce/ui/game.js | 45 +- packages/cli/demo/1kce/util.js | 43 ++ packages/cli/demo/1kce/weblet.js | 50 ++- 9 files changed, 420 insertions(+), 171 deletions(-) create mode 100644 packages/cli/demo/1kce/util.js diff --git a/packages/cli/demo/1kce/cards/deja-vu.js b/packages/cli/demo/1kce/cards/deja-vu.js index ba507e616c..5e8ffb6d67 100644 --- a/packages/cli/demo/1kce/cards/deja-vu.js +++ b/packages/cli/demo/1kce/cards/deja-vu.js @@ -3,13 +3,15 @@ import { E, Far } from '@endo/far' export const make = () => { return Far('deja vu', { async play (gameController) { - const cards = await E(gameController).getDeckCards() - await E(gameController).addCardsToDeck(cards) + // duplicate the cards in the draw stack, in the same order. + const cards = await E(gameController).getDrawStackCards() + const cardIds = cards.map(({ id }) => id) + await E(gameController).addCardsByIdToDrawStack(cardIds) }, getDetails () { return { name: 'deja vu', - description: 'duplicate the cards in the deck, in the same order.\n\n-25 points', + description: 'duplicate the cards in the draw stack, in the same order.\n\n-25 points', pointValue: -25, } }, diff --git a/packages/cli/demo/1kce/cards/library-of-alexandria.js b/packages/cli/demo/1kce/cards/library-of-alexandria.js index 1217547ea9..c14436901b 100644 --- a/packages/cli/demo/1kce/cards/library-of-alexandria.js +++ b/packages/cli/demo/1kce/cards/library-of-alexandria.js @@ -1,18 +1,19 @@ import { E, Far } from '@endo/far' export const make = () => { + return Far('library of alexandria', { async play (controller) { - await E(controller).setScoreFn(Far('scoreFn container', { - scoreFn: async ({ cards }) => { - let score = 0 - for (const card of cards) { - const { name } = await E(card).getDetails() - score += name.length * 10 - } - return score - }, - })) + // tell the game to call "scoreFunction" on this card when it wants to calculate scores + await E(controller).setScoreFn('scoreFunction') + }, + async scoreFunction ({ cardsData }) { + let score = 0 + for (const cardData of cardsData) { + const { name } = await E(cardData.remote).getDetails() + score += name.length * 10 + } + return score }, getDetails () { return { diff --git a/packages/cli/demo/1kce/cards/pokemon-outrage.js b/packages/cli/demo/1kce/cards/pokemon-outrage.js index 345c5776c4..b99a3f7a9d 100644 --- a/packages/cli/demo/1kce/cards/pokemon-outrage.js +++ b/packages/cli/demo/1kce/cards/pokemon-outrage.js @@ -3,8 +3,7 @@ import { E, Far } from '@endo/far' export const make = () => { return Far('pokemon-outrage', { async play (gameController) { - const cards = await E(gameController).getDeckCards() - await E(gameController).addCardsToDeck(cards) + }, getDetails () { return { diff --git a/packages/cli/demo/1kce/deck.js b/packages/cli/demo/1kce/deck.js index 5aac56657c..0b068d84ac 100644 --- a/packages/cli/demo/1kce/deck.js +++ b/packages/cli/demo/1kce/deck.js @@ -6,29 +6,33 @@ import { makeRefIterator } from '@endo/daemon/ref-reader.js'; const cardPrefix = 'card-'; -export const make = (powers) => { +export const make = async (powers) => { const cards = makeSyncArrayGrain(); // cards already saved in petstore - const followNames = async () => { - for await (const change of makeRefIterator(E(powers).followNames())) { - if (change.add === undefined) continue - const name = change.add + const loadExistingNames = async () => { + for await (const name of await E(powers).list()) { if (!name.startsWith(cardPrefix)) continue + const indexString = name.slice(cardPrefix.length) const card = await E(powers).lookup(name); - cards.push(card); + cards.setAtIndex(indexString, card); } } // incomming cards - const followMessages = async () => { + const listenForIncommingCards = async () => { for await (const message of makeRefIterator(E(powers).followMessages())) { if (message.type !== 'package') continue - await E(powers).adopt(message.number, 'card', `${cardPrefix}${cards.getLength()}`); + const petName = `${cardPrefix}${cards.getLength()}` + await E(powers).adopt(message.number, 'card', petName); + const card = await E(powers).lookup(petName); + cards.push(card); } } - followNames() - followMessages() + // ensure all cards are loaded before continuing + await loadExistingNames() + // listen for new cards, but dont await as it will never resolve + listenForIncommingCards() return Far('Deck', { add (card) { diff --git a/packages/cli/demo/1kce/game.js b/packages/cli/demo/1kce/game.js index 5f4e2c16ce..a1afe8f44e 100644 --- a/packages/cli/demo/1kce/game.js +++ b/packages/cli/demo/1kce/game.js @@ -1,47 +1,72 @@ import { E, Far } from '@endo/far'; -import { makeIteratorRef } from '@endo/daemon/reader-ref.js'; -import { makeSyncArrayGrain, makeSyncGrain, makeSyncGrainArrayMap, makeSyncGrainMap, makeDerivedSyncGrain, composeGrainsAsync, composeGrains } from '@endo/grain'; import { makeRemoteGrain, makeRemoteGrainMap } from '@endo/grain/captp.js'; +import { makeReaderRef } from '@endo/daemon/reader-ref.js'; +import { makeRefReader } from '@endo/daemon/ref-reader.js'; +import { + makeSyncArrayGrain, + makeSyncGrain, + makeSyncGrainArrayMap, + makeSyncGrainMap, + makeDerivedSyncGrain, + composeGrainsAsync, + composeGrains, +} from '@endo/grain'; +import { makeMutex } from './util.js'; + +const textEncoder = new TextEncoder() +const textDecoder = new TextDecoder() const playerRemoteToLocal = new Map() -class Player { - constructor (name) { - const localPlayer = this - this.name = name - this.hand = makeSyncArrayGrain() - this.remoteInterface = Far(`Player "${name}"`, { - async getName () { - return localPlayer.name - }, - async getHandGrain () { - return makeRemoteGrain(localPlayer.hand) - }, - async removeCard (card) { - localPlayer.removeCard(card) - }, - }) - playerRemoteToLocal.set(this.remoteInterface, localPlayer) +const makePlayer = (getCardDataById, initialState = {}) => { + const name = initialState.name || 'player' + const handIds = makeSyncArrayGrain(initialState.handIds || []) + const hand = makeDerivedSyncGrain( + handIds, + handIds => handIds.map(id => getCardDataById(id)), + ) + const addCardById = (cardId) => { + handIds.push(cardId) } - - addCard (card) { - this.hand.push(card) + const removeCardById = (cardId) => { + const index = handIds.get().indexOf(cardId) + if (index === -1) return + handIds.splice(index, 1) } - removeCard (card) { - this.hand.splice(this.hand.get().indexOf(card), 1) + const remoteInterface = Far(`Player "${name}"`, { + async getName () { + return name + }, + async getHandGrain () { + return makeRemoteGrain(hand) + }, + async removeCardById (cardId) { + removeCardById(cardId) + }, + }) + const localPlayer = { + name, + handIds, + hand, + addCardById, + removeCardById, + remoteInterface, } + playerRemoteToLocal.set(remoteInterface, localPlayer) + return localPlayer } -export function makeGame () { +export function makeGame (initialState = {}, deck, persistState) { // logging - const logGrain = makeSyncArrayGrain() + const logGrain = makeSyncArrayGrain(initialState.log || []) const log = (message) => { logGrain.push(message) } // players const localPlayers = makeSyncArrayGrain() + const playerHandIds = makeSyncGrainMap() const remotePlayers = makeDerivedSyncGrain( localPlayers, localPlayers => localPlayers.map(localPlayer => localPlayer.remoteInterface), @@ -54,10 +79,11 @@ export function makeGame () { } const addPlayer = (localPlayer) => { localPlayers.push(localPlayer) + playerHandIds.setGrain(localPlayer.name, localPlayer.handIds) } // current player - const currentPlayerIndex = makeSyncGrain(0) + const currentPlayerIndex = makeSyncGrain(initialState.currentPlayerIndex || 0) const currentLocalPlayer = composeGrains( { localPlayers, currentPlayerIndex }, ({ localPlayers, currentPlayerIndex }) => localPlayers[currentPlayerIndex], @@ -79,12 +105,12 @@ export function makeGame () { } // turn phases - const turnPhases = makeSyncArrayGrain([ + const turnPhases = makeSyncArrayGrain(initialState.turnPhases || [ 'draw', 'play', 'end', ]) - const currentTurnPhase = makeSyncGrain(0) + const currentTurnPhase = makeSyncGrain(initialState.currentTurnPhase || 0) const currentTurnPhaseName = makeDerivedSyncGrain( currentTurnPhase, currentTurnPhase => turnPhases.getAtIndex(currentTurnPhase), @@ -105,29 +131,63 @@ export function makeGame () { } // locations - const locations = makeSyncGrainArrayMap() - const getCardsAtLocation = (location) => { + const initialLocations = Object.fromEntries(Object.entries(initialState.locations || {}).map(([location, cardIds]) => { + return [location, makeSyncArrayGrain(cardIds)] + })) + // const initialLocations = undefined + const locations = makeSyncGrainArrayMap(initialLocations) + const getLocationGrain = (location) => { return locations.getGrain(location) } + const getCardsDataAtLocation = (location) => { + // if we dont guard here we trigger inf-loop protections on + // locations grainMap bc a read triggers a write + // problematic when using this function to derive a grain + if (!locations.hasGrain(location)) { + return [] + } + const cardIds = getLocationGrain(location).get() + return cardIds.map(id => getCardDataById(id)) + } + // TODO: consider automatically checking if in a location and removing + const setLocationForCardId = (cardId, to, from) => { + if (from) { + locations.remove(from, cardId) + } + locations.push(to, cardId) + } // scoring - const defaultScoreFn = async ({ cards }) => { + const scoreFnCard = makeSyncGrain(initialState.scoreFnCard || undefined) + const defaultScoreFn = async ({ cardsData }) => { let score = 0 - for (const card of cards) { - const { pointValue } = await E(card).getDetails() + for (const cardData of cardsData) { + const { pointValue } = await E(cardData.remote).getDetails() score += pointValue } return score } - const scoreFn = makeSyncGrain(defaultScoreFn) - const setScoreFn = (newScoreFn) => scoreFn.set(newScoreFn) + const scoreFn = makeDerivedSyncGrain( + scoreFnCard, + scoreFnCard => { + if (scoreFnCard) { + return async ({ cardsData }) => { + const { cardId, methodName } = scoreFnCard + const cardData = getCardDataById(cardId) + if (!cardData) return 0 + return E(cardData.remote)[methodName]({ cardsData }) + } + } + return defaultScoreFn + }, + ) const scoresGrain = composeGrainsAsync( { localPlayers, locations, scoreFn }, - async ({ localPlayers, locations, scoreFn }) => { + async ({ localPlayers, scoreFn }) => { const scores = [] for (const localPlayer of localPlayers) { - const cards = locations[localPlayer.name] || [] - const score = await scoreFn({ cards }) + const cardsData = getCardsDataAtLocation(localPlayer.name) + const score = await scoreFn({ cardsData }) scores.push(score) } return scores @@ -135,41 +195,74 @@ export function makeGame () { [], ) - // deck + // deck - the deck is the local copy of the deck cards we will play with + // a card's id is its index in the deck const deckGrain = makeSyncArrayGrain() - const deckCardsCount = makeDerivedSyncGrain( - deckGrain, - deckGrain => deckGrain.length, - ) - const addCardToDeck = (card) => { - deckGrain.push(card) + const getCardRemoteById = (id) => { + return deckGrain.getAtIndex(id) + } + const getCardDataById = (id) => { + const remote = getCardRemoteById(id) + if (!remote) { + return undefined + } + return { + id, + remote, + } } - const importDeck = async (deck) => { + const importDeck = async () => { for await (const card of await E(deck).getCards()) { - addCardToDeck(card) + deckGrain.push(card) } } - const shuffleDeck = () => { - const deck = deckGrain.get().slice() - for (let i = deck.length - 1; i > 0; i--) { + + // draw stack - this is the stack of cards the players draw from + const drawStackIds = makeSyncArrayGrain(initialState.drawStackIds || []) + const drawStackCount = makeDerivedSyncGrain( + drawStackIds, + drawStackIds => drawStackIds.length, + ) + // draw stack cards are { id, remote } + const drawStack = makeDerivedSyncGrain( + drawStackIds, + drawStackIds => drawStackIds.map(id => getCardDataById(id)), + ) + const addCardByIdToDrawStack = (cardId) => { + drawStackIds.push(cardId) + } + const populateDrawStackFromDeck = () => { + deckGrain.get().forEach((_card, index) => { + const id = index + addCardByIdToDrawStack(id) + }) + } + const shuffleDrawStack = () => { + const cards = drawStackIds.get().slice() + for (let i = cards.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); - [deck[i], deck[j]] = [deck[j], deck[i]]; + [cards[i], cards[j]] = [cards[j], cards[i]]; } - deckGrain.set(deck) + drawStackIds.set(cards) } // cards const drawCard = () => { - return deckGrain.pop() + const id = drawStackIds.pop() + if (id === undefined) { + return undefined + } + return getCardDataById(id) } const playerDrawsCards = (localPlayer, numCards) => { for (let i = 0; i < numCards; i++) { - const card = drawCard() - if (!card) { + const cardData = drawCard() + if (!cardData) { + log(`${localPlayer.name} tried to draw a card, but none remain`) return } log(`${localPlayer.name} drew a card`) - localPlayer.addCard(card) + localPlayer.addCardById(cardData.id) } } const drawInitialCards = () => { @@ -204,13 +297,14 @@ export function makeGame () { } } // game controller is exposed to cards when played - const makeGameController = () => { + const makeGameController = (cardData) => { return Far('GameController', { // i think you need to wrap the scoreFn in a Far, so i did - async setScoreFn (scoreFnWrapper) { - setScoreFn(({ cards }) => { - return E(scoreFnWrapper).scoreFn({ cards }) - }) + async setScoreFn (methodName) { + // set scoreFn card + const { name: cardName } = await E(cardData.remote).getDetails() + log(`${cardName} overwrote the scoring function`) + scoreFnCard.set({ cardId: cardData.id, methodName }) }, async prependTurnPhase (phase) { prependTurnPhase(phase) @@ -218,112 +312,171 @@ export function makeGame () { async appendTurnPhase (phase) { appendTurnPhase(phase) }, - async getDeckCards () { - return deckGrain.get() + async getDrawStackCards () { + return drawStack.get() }, - async addCardsToDeck (cards) { - for (const card of cards) { - addCardToDeck(card) + async addCardsByIdToDrawStack (cardIds) { + for (const cardId of cardIds) { + addCardByIdToDrawStack(cardId) } }, }) } - const playCard = async (card) => { - const controller = makeGameController() - await E(card).play(controller) + const playCard = async (cardData) => { + const controller = makeGameController(cardData) + await E(cardData.remote).play(controller) } - const playCardFromHand = async (localSourcePlayer, card, localDestinationPlayer = localSourcePlayer) => { + const playCardByIdFromHand = async (localSourcePlayer, cardId, localDestinationPlayer = localSourcePlayer) => { + const cardData = getCardDataById(cardId) // move card from hand to destination - localSourcePlayer.removeCard(card) - locations.push(localDestinationPlayer.name, card) + localSourcePlayer.removeCardById(cardData.id) + setLocationForCardId(cardData.id, localDestinationPlayer.name) + // note in log const isPlayedToSelf = localSourcePlayer === localDestinationPlayer log(`${localSourcePlayer.name} played card to ${isPlayedToSelf ? 'self' : localDestinationPlayer.name}`) // trigger card effect - await playCard(card) + await playCard(cardData) + // continue turn, dont await completion advanceTurnPhase() - // dont await completion continueTurn() } - const start = async (deck) => { + // to be called at boot of game + const initialize = async () => { + // get a local copy of the deck cards await importDeck(deck) - shuffleDeck() + // create players + const aliceData = { + name: 'alice', + handIds: initialState.playerHandIds?.alice || [], + } + const bobData = { + name: 'bob', + handIds: initialState.playerHandIds?.bob || [], + } + addPlayer(makePlayer(getCardDataById, aliceData)) + addPlayer(makePlayer(getCardDataById, bobData)) + } + // to be called at the start of a new game + const start = async () => { + // populate the draw stack + populateDrawStackFromDeck() + shuffleDrawStack() + // draw initial cards drawInitialCards() - // dont await completion + // start turn, dont await completion continueTurn() } - // game state, aggregated for remote subscribers - const state = makeSyncGrainMap({ + // remote observable game state, aggregated for remote subscribers + const statePublic = makeSyncGrainMap({ log: logGrain, currentPlayer: currentRemotePlayer, currentTurnPhase: currentTurnPhaseName, locations, scores: scoresGrain, - deckCardsCount, + drawStackCount, players: remotePlayers, }) - const followState = (canceled) => { - return state.follow(canceled) - } + + // persisted game state + const statePersist = makeSyncGrainMap({ + log: logGrain, + drawStackIds, + playerHandIds, + locations, + currentPlayerIndex, + currentTurnPhase, + turnPhases, + scoreFnCard, + }) + statePersist.subscribe(newState => { + persistState(newState).catch(err => { + log(`Error persisting state: ${err.message}`) + }) + }) // Far const game = { - state, - addPlayer, + state: statePublic, + initialize, start, - playCardFromHand, + getCardDataById, + playCardByIdFromHand, followRemotePlayers, getRemotePlayersGrain, - followState, followCurrentRemotePlayer, getCurrentRemotePlayerGrain, - getCardsAtLocation, + getLocationGrain, } return game } -export const make = () => { - const game = makeGame() - game.addPlayer(new Player('alice')) - game.addPlayer(new Player('bob')) +export const make = async (powers) => { + + const loadState = async () => { + if (!await E(powers).has('state')) { + return undefined + } + const readable = await E(powers).lookup('state'); + const readerRef = E(readable).stream(); + const reader = makeRefReader(readerRef); + let stateBlob = '' + for await (const chunk of reader) { + stateBlob += textDecoder.decode(chunk) + } + return JSON.parse(stateBlob) + } + const mutex = makeMutex() + const persistState = async (state) => { + await mutex.enqueue(async () => { + const stateBlob = JSON.stringify(state) + const encoded = textEncoder.encode(stateBlob) + const reader = makeReaderRef([encoded]) + await E(powers).store(reader, 'state') + }) + } + + const deck = await E(powers).request( + // recipient + 'HOST', + // description + 'game/deck', + // my petname + 'deck', + ) + + const gameState = await loadState() + const game = makeGame(gameState, deck, persistState) + await game.initialize() + return Far('Game', { - async start (deck) { - await game.start(deck) + async start () { + return game.start() }, - async playCardFromHand (remoteSourcePlayer, card, remoteDestinationPlayer) { + async playCardByIdFromHand (remoteSourcePlayer, cardId, remoteDestinationPlayer) { const localSourcePlayer = playerRemoteToLocal.get(remoteSourcePlayer) const localDestinationPlayer = playerRemoteToLocal.get(remoteDestinationPlayer) - await game.playCardFromHand(localSourcePlayer, card, localDestinationPlayer) - }, - - async followPlayers (canceled) { - return makeIteratorRef(game.followRemotePlayers(canceled)) + await game.playCardByIdFromHand(localSourcePlayer, cardId, localDestinationPlayer) }, async getPlayersGrain () { return makeRemoteGrain(game.getRemotePlayersGrain()) }, - - async followState (canceled) { - return makeIteratorRef(game.followState(canceled)) - }, async getStateGrain () { return makeRemoteGrainMap(game.state) }, - - async followCurrentPlayer (canceled) { - return makeIteratorRef(game.followCurrentRemotePlayer(canceled)) - }, async getCurrentPlayerGrain () { return makeRemoteGrain(game.getCurrentRemotePlayerGrain()) }, - - async followCardsAtLocation (location, canceled) { - return makeIteratorRef(game.getCardsAtLocation(location).follow(canceled)) - }, - async getCardsAtPlayerLocationGrain (remotePlayer) { const { name } = playerRemoteToLocal.get(remotePlayer) - return makeRemoteGrain(game.getCardsAtLocation(name)) + const locationCardIdsGrain = game.getLocationGrain(name) + // map location cardIds to CardData + // TODO: mem leak because we never unsubscribe / subscriptions are not lazy + const locationCardDataGrain = makeDerivedSyncGrain( + locationCardIdsGrain, + locationCardIds => locationCardIds.map(id => game.getCardDataById(id)), + ) + return makeRemoteGrain(locationCardDataGrain) }, }); }; \ No newline at end of file diff --git a/packages/cli/demo/1kce/ui/app.js b/packages/cli/demo/1kce/ui/app.js index 867ae59213..93cbaf66bc 100644 --- a/packages/cli/demo/1kce/ui/app.js +++ b/packages/cli/demo/1kce/ui/app.js @@ -28,6 +28,15 @@ export const App = ({ inventory }) => { } const gameMgmt = { + async fetchGame () { + // has-check is workaround for https://github.com/endojs/endo/issues/1843 + if (await inventory.has('game')) { + const game = await inventory.lookup('game') + setDeck(game) + const stateGrain = makeReadonlyGrainMapFromRemote(E(game).getStateGrain()) + setGame({ game, stateGrain }) + } + }, async start () { // make game const game = await inventory.makeGame() @@ -35,14 +44,15 @@ export const App = ({ inventory }) => { setGame({ game, stateGrain }) await E(game).start(deck) }, - async playCardFromHand (player, card, destinationPlayer) { - await E(game).playCardFromHand(player, card, destinationPlayer) + async playCardByIdFromHand (player, cardId, destinationPlayer) { + await E(game).playCardByIdFromHand(player, cardId, destinationPlayer) }, } // on first render React.useEffect(() => { deckMgmt.fetchDeck() + gameMgmt.fetchGame() }, []); return ( diff --git a/packages/cli/demo/1kce/ui/game.js b/packages/cli/demo/1kce/ui/game.js index 94782c9931..33310072dc 100644 --- a/packages/cli/demo/1kce/ui/game.js +++ b/packages/cli/demo/1kce/ui/game.js @@ -21,13 +21,13 @@ const getCardRenderer = async (card) => { export const CardComponent = ({ card }) => { const { value: cardDetails } = useAsync(async () => { - return await E(card).getDetails() - }, [card]); + return await E(card.remote).getDetails() + }, [card.remote]); const mouseData = useMouse() const canvasRef = React.useRef(null); const { value: render } = useAsync(async () => { - return await getCardRenderer(card) - }, [card]); + return await getCardRenderer(card.remote) + }, [card.remote]); useRaf((timeElapsed) => { if (!render) return const canvas = canvasRef.current; @@ -135,7 +135,7 @@ export const CardComponent = ({ card }) => { export const CardAndControlsComponent = ({ card, cardControlComponent }) => { return ( h('div', { - key: keyForItems(card), + key: keyForItems(card.remote), }, [ h(CardComponent, { card }), cardControlComponent && h(cardControlComponent, { card }), @@ -157,23 +157,33 @@ export const CardsDisplayComponent = ({ cards, cardControlComponent, emptyMessag ) } -const getCardDuplicateCount = (cards) => { - const countForCard = new Map() - for (const card of cards) { - const count = countForCard.get(card) || 0 - countForCard.set(card, count + 1) +const getCardDuplicateCount = (cardsData) => { + const countForCardRemote = new Map() + for (const card of cardsData) { + const count = countForCardRemote.get(card.remote) || 0 + countForCardRemote.set(card.remote, count + 1) } - return countForCard + // map back to card data format + const countForCards = new Map( + [...countForCardRemote.entries()].map(([cardRemote, count]) => { + return [cardsData.find(card => card.remote === cardRemote), count] + }), + ) + return countForCards } export const DeckCardsComponent = ({ deck }) => { - const cards = useGrainGetter( + const cardRemotes = useGrainGetter( () => makeReadonlyArrayGrainFromRemote( E(deck).getCardsGrain(), ), [deck], ) + // cards as CardData format + const cards = cardRemotes.map((remote, index) => ({ id: index, remote })) const cardsCount = getCardDuplicateCount(cards) + // map back to CardData format + // we dont use the card id for anything when building the deck, so we set it to null const uniqueCards = [...cardsCount.keys()] // specify a component to render under the cards @@ -201,7 +211,7 @@ export const DeckCardsComponent = ({ deck }) => { margin: '2px', }, onClick: async () => { - await E(deck).remove(card) + await E(deck).remove(card.remote) }, }, ['Remove from Deck']), ]) @@ -241,7 +251,6 @@ const PlayCardButtonComponent = ({ card, gameMgmt, sourcePlayer, destPlayer }) = const { value: destPlayerName } = useAsync(async () => { return await E(destPlayer).getName() }, [destPlayer]); - const playLabel = sourcePlayer === destPlayer ? `Play on self` : `Play on ${destPlayerName}` return h('button', { key: keyForItems(sourcePlayer, destPlayer), @@ -249,7 +258,7 @@ const PlayCardButtonComponent = ({ card, gameMgmt, sourcePlayer, destPlayer }) = margin: '2px', }, onClick: async () => { - await gameMgmt.playCardFromHand(sourcePlayer, card, destPlayer) + await gameMgmt.playCardByIdFromHand(sourcePlayer, card.id, destPlayer) }, }, [playLabel]) } @@ -332,8 +341,8 @@ export const ActiveGameComponent = ({ game, gameMgmt, stateGrain }) => { () => stateGrain.getGrain('scores'), [stateGrain], ) - const deckCardsCount = useGrainGetter( - () => stateGrain.getGrain('deckCardsCount'), + const drawStackCount = useGrainGetter( + () => stateGrain.getGrain('drawStackCount'), [stateGrain], ) const log = useGrainGetter( @@ -354,7 +363,7 @@ export const ActiveGameComponent = ({ game, gameMgmt, stateGrain }) => { }, }, [ h('h3', { key: 'title'}, ['Game']), - h('div', { key: 'deck-count' }, [`Cards remaining in deck: ${deckCardsCount}`]), + h('div', { key: 'draw-stack-count' }, [`Cards remaining in draw stack: ${drawStackCount}`]), // log h('h3', { key: 'subtitle' }, ['Players']), h('div', { key: 'players' }, players && players.map((player, index) => { diff --git a/packages/cli/demo/1kce/util.js b/packages/cli/demo/1kce/util.js new file mode 100644 index 0000000000..105449b959 --- /dev/null +++ b/packages/cli/demo/1kce/util.js @@ -0,0 +1,43 @@ +import { makePromiseKit } from '@endo/promise-kit'; + +export const makeQueue = () => { + let { promise: tailPromise, resolve: tailResolve } = makePromiseKit(); + return { + put(value) { + const next = makePromiseKit(); + const promise = next.promise; + tailResolve({ value, promise }); + tailResolve = next.resolve; + }, + get() { + const promise = tailPromise.then(next => next.value); + tailPromise = tailPromise.then(next => next.promise); + return promise; + }, + }; +}; + +export const makeMutex = () => { + const queue = makeQueue(); + const lock = () => { + return queue.get() + } + const unlock = () => { + queue.put() + } + unlock() + + return { + lock, + unlock, + // helper for correct usage + enqueue: async (asyncFn) => { + await lock() + try { + return await asyncFn() + } finally { + unlock() + } + }, + }; +} \ No newline at end of file diff --git a/packages/cli/demo/1kce/weblet.js b/packages/cli/demo/1kce/weblet.js index 7b436e3e83..a986c9fa3e 100644 --- a/packages/cli/demo/1kce/weblet.js +++ b/packages/cli/demo/1kce/weblet.js @@ -4,10 +4,21 @@ import { make as makeApp } from './ui/index.js'; // no way of resolving relative paths from the weblet const projectRootPath = './demo/1kce'; +const deckName = 'deck'; const deckGuestName = 'guest-deck'; +const gameGuestName = 'guest-game'; const makeThing = async (powers, importFullPath, resultName, powersName = 'NONE') => { const workerName = 'MAIN'; + if (powersName !== 'NONE') { + // delete existing guest, its petstore is what stores all the persistence + if (await E(powers).has(powersName)) { + await E(powers).remove(powersName) + } + // make new guest + await E(powers).provideGuest(powersName) + } + // make const deck = await E(powers).importUnsafeAndEndow( workerName, importFullPath, @@ -18,6 +29,27 @@ const makeThing = async (powers, importFullPath, resultName, powersName = 'NONE' } export const make = (powers) => { + const followRequests = async () => { + const requestIterator = makeRefIterator(await E(powers).followMessages()) + for await (const request of requestIterator) { + if (request.type !== 'request') continue + switch (request.what) { + case 'game/deck': + if (request.who !== gameGuestName) continue + await E(powers).resolve( + request.number, + deckName, + ); + break; + default: + console.log('unhandled request', request) + continue; + } + } + } + + followRequests() + // form inventory from powers const inventory = { subscribeToNames () { @@ -27,20 +59,14 @@ export const make = (powers) => { return E(powers).has(name) }, async lookup (name) { - return await E(powers).lookup(name) + return E(powers).lookup(name) }, + async makeNewDeck () { const importFullPath = `${projectRootPath}/deck.js`; - const resultName = 'deck'; + const resultName = deckName; const powersName = deckGuestName - // delete existing guest, its petstore is what stores the cards - if (await E(powers).has(powersName)) { - await E(powers).remove(powersName) - } - // make new guest - await E(powers).provideGuest(powersName) - // make deck - return await makeThing(powers, importFullPath, resultName, powersName) + return makeThing(powers, importFullPath, resultName, powersName) }, async addCardToDeckByName (cardName) { await E(powers).send( @@ -54,10 +80,12 @@ export const make = (powers) => { [cardName], ); }, + async makeGame () { const importFullPath = `${projectRootPath}/game.js`; const resultName = 'game'; - return await makeThing(powers, importFullPath, resultName) + const powersName = gameGuestName + return makeThing(powers, importFullPath, resultName, powersName) }, }