diff --git a/packages/contracts/src/DeckAirdrop.sol b/packages/contracts/src/DeckAirdrop.sol index 344d05f2..af0b333e 100644 --- a/packages/contracts/src/DeckAirdrop.sol +++ b/packages/contracts/src/DeckAirdrop.sol @@ -61,6 +61,6 @@ contract DeckAirdrop is Ownable { cards[i] = first + i; } - inventory.addDeck(msg.sender, Inventory.Deck(cards)); + inventory.addDeck(msg.sender, Inventory.Deck("Starter Deck", cards)); } } diff --git a/packages/contracts/src/Game.sol b/packages/contracts/src/Game.sol index 4518f405..01d991dc 100644 --- a/packages/contracts/src/Game.sol +++ b/packages/contracts/src/Game.sol @@ -527,6 +527,8 @@ contract Game { revert Errors.NotAllowedToJoin(); } + inventory.checkDeck(msg.sender, deckID); + // Update gdata.players, but not gdata.livePlayers, which is used to determine if the // game is ready to start (all players have joined & drawn their initial hand). gdata.players.push(msg.sender); @@ -547,7 +549,7 @@ contract Game { uint256[] storage cards = gdata.cards; pdata.deckStart = uint8(cards.length); inventory.checkDeck(msg.sender, deckID); - uint256[] memory deck = inventory.getDeck(msg.sender, deckID); + uint256[] memory deck = inventory.getDeckCards(msg.sender, deckID); for (uint256 i = 0; i < deck.length; i++) { cards.push(deck[i]); diff --git a/packages/contracts/src/Inventory.sol b/packages/contracts/src/Inventory.sol index ca8b0bc0..39d9c190 100644 --- a/packages/contracts/src/Inventory.sol +++ b/packages/contracts/src/Inventory.sol @@ -78,6 +78,7 @@ contract Inventory is Ownable { // We need a struct because Solidity is unable to copy an array from memory to storage // directly, but can do it when the array is embedded in a struct. struct Deck { + string name; uint256[] cards; } @@ -188,6 +189,9 @@ contract Inventory is Ownable { // --------------------------------------------------------------------------------------------- function checkDeckSize(Deck storage deck) internal view { + if (deck.cards.length < MIN_DECK_SIZE) { + revert SmallDeckEnergy(); + } if (deck.cards.length > MAX_DECK_SIZE) { revert BigDeckEnergy(); } @@ -215,8 +219,7 @@ contract Inventory is Ownable { revert OutOfDeckIDs(); } deckID = uint8(longDeckID); - decks[player].push(); - _addDeck(player, deckID, deck); + decks[player].push(deck); emit DeckAdded(player, deckID); } @@ -243,7 +246,7 @@ contract Inventory is Ownable { exists(player, deckID) notInGame(player) { - _addDeck(player, deckID, deck); + decks[player][deckID] = deck; emit DeckRemoved(player, deckID); emit DeckAdded(player, deckID); } @@ -328,7 +331,7 @@ contract Inventory is Ownable { // --------------------------------------------------------------------------------------------- // Returns the list of cards in the given deck of the given player. - function getDeck(address player, uint8 deckID) + function getDeckCards(address player, uint8 deckID) external view exists(player, deckID) @@ -339,6 +342,20 @@ contract Inventory is Ownable { // --------------------------------------------------------------------------------------------- + // Returns the list of cards in the given deck of the given player. + function getDeck(address player, uint8 deckID) external view exists(player, deckID) returns (Deck memory) { + return decks[player][deckID]; + } + + // --------------------------------------------------------------------------------------------- + + // Returns the decks of a given player. + function getAllDecks(address player) external view returns (Deck[] memory) { + return decks[player]; + } + + // --------------------------------------------------------------------------------------------- + // Returns the number of deck a player has created. function getNumDecks(address player) external view returns (uint8) { return uint8(decks[player].length); @@ -346,6 +363,20 @@ contract Inventory is Ownable { // --------------------------------------------------------------------------------------------- + // Returns the names of all decks for a given player. + function getDeckNames(address player) external view returns (string[] memory) { + uint256 deckCount = decks[player].length; + string[] memory deckNames = new string[](deckCount); + + for (uint256 i = 0; i < deckCount; i++) { + deckNames[i] = decks[player][i].name; + } + + return deckNames; + } + + // --------------------------------------------------------------------------------------------- + function getCardTypes(uint256[] memory cardIDArr) public view returns (uint256[] memory) { uint256 len = cardIDArr.length; uint256[] memory cardTypeArr = new uint256[](len); diff --git a/packages/contracts/src/test/Integration.t.sol b/packages/contracts/src/test/Integration.t.sol index a9faa00e..942e2415 100644 --- a/packages/contracts/src/test/Integration.t.sol +++ b/packages/contracts/src/test/Integration.t.sol @@ -71,8 +71,8 @@ contract Integration is Test { game.createGame(2); // IDs don't start at 1 because the deploy script currently airdrops some addresses, might change. - inventory.getDeck(player2, 0); // player1 has card id of 49-72 inclusive - inventory.getDeck(player2, 0); // player2 has card id of 73-96 inclusive + inventory.getDeckCards(player2, 0); // player1 has card id of 49-72 inclusive + inventory.getDeckCards(player2, 0); // player2 has card id of 73-96 inclusive vm.startPrank(player1); game.joinGame(gameID, DECK_ID, SALT_HASH, JOIN_DATA); diff --git a/packages/contracts/src/test/Inventory.t.sol b/packages/contracts/src/test/Inventory.t.sol index 4f69de8a..cee36d17 100644 --- a/packages/contracts/src/test/Inventory.t.sol +++ b/packages/contracts/src/test/Inventory.t.sol @@ -33,7 +33,7 @@ contract InventoryTest is Test { // expect revert if player's deck contains a card with more than `MAX_CARD_COPY` copies. function testCheckDeckExceedsMaxCopy() public { uint8 deckId = 0; - uint256 randomCard = inventory.getDeck(player1, deckId)[2]; + uint256 randomCard = inventory.getDeckCards(player1, deckId)[2]; // increase card `randomCard` copies to 4 vm.startPrank(player1); diff --git a/packages/webapp/src/actions/getDeck.ts b/packages/webapp/src/actions/getDeck.ts new file mode 100644 index 00000000..74df826a --- /dev/null +++ b/packages/webapp/src/actions/getDeck.ts @@ -0,0 +1,158 @@ +import { defaultErrorHandling } from "src/actions/errors" +import { contractWriteThrowing } from "src/actions/libContractWrite" +import { Address } from "src/chain" +import { deployment } from "src/deployment" +import { inventoryABI } from "src/generated" + +// ================================================================================================= + +export type GetDeckArgs = { + playerAddress: Address + onSuccess: () => void +} + +export type GetDeckAtArgs = { + playerAddress: Address + onSuccess: () => void + index: number +} + +// ------------------------------------------------------------------------------------------------- + +/** + * Fetches all decks of the given player by sending the `getAllDecks` transaction. + * + * Returns `true` iff the transaction is successful. + */ +export async function getAllDecks(args: GetDeckArgs): Promise { + try { + return await getAllDecksImpl(args) + } catch (err) { + defaultErrorHandling("getAllDecks", err) + return false + } +} + +/** + * Fetches the deck of the given player of a given ID by sending the `getDeck` transaction. + * + * Returns `true` iff the transaction is successful. + */ +export async function getDeck(args: GetDeckAtArgs): Promise { + try { + return await getDeckImpl(args) + } catch (err) { + defaultErrorHandling("getDeck", err) + return false + } +} + +// ------------------------------------------------------------------------------------------------- + +/** + * Fetches deck count of the given player by sending the `getNumDecks` transaction. + * + * Returns `true` iff the transaction is successful. + */ +export async function getNumDecks(args: GetDeckArgs): Promise { + try { + return await getNumDecksImpl(args) + } catch (err) { + defaultErrorHandling("getNumDecks", err) + return false + } +} + +// ------------------------------------------------------------------------------------------------- + +/** + * Fetches deck count of the given player by sending the `getNumDecks` transaction. + * + * Returns `true` iff the transaction is successful. + */ +export async function getDeckNames(args: GetDeckArgs): Promise { + try { + return await getDeckNamesImpl(args) + } catch (err) { + defaultErrorHandling("getDeckNames", err) + return false + } +} + +// ------------------------------------------------------------------------------------------------- + +async function getAllDecksImpl(args: GetDeckArgs): Promise { + try { + const result = await contractWriteThrowing({ + contract: deployment.Inventory, + abi: inventoryABI, + functionName: "getAllDecks", + args: [args.playerAddress], + }) + + args.onSuccess() + return result + } catch (error) { + console.error("Error fetching decks:", error) + return null + } +} + +// ------------------------------------------------------------------------------------------------- + +async function getDeckImpl(args: GetDeckAtArgs): Promise { + try { + const result = await contractWriteThrowing({ + contract: deployment.Inventory, + abi: inventoryABI, + functionName: "getDeck", + args: [args.playerAddress, args.index], + }) + + args.onSuccess() + return result + } catch (error) { + console.error("Error fetching deck:", error) + return null + } +} + +// ------------------------------------------------------------------------------------------------- + +async function getNumDecksImpl(args: GetDeckArgs): Promise { + try { + const result = await contractWriteThrowing({ + contract: deployment.Inventory, + abi: inventoryABI, + functionName: "getNumDecks", + args: [args.playerAddress], + }) + + args.onSuccess() + return result + } catch (error) { + console.error("Error fetching decks:", error) + return null + } +} + +// ------------------------------------------------------------------------------------------------- + +async function getDeckNamesImpl(args: GetDeckArgs): Promise { + try { + const result = await contractWriteThrowing({ + contract: deployment.Inventory, + abi: inventoryABI, + functionName: "getDeckNames", + args: [args.playerAddress], + }) + + args.onSuccess() + return result + } catch (error) { + console.error("Error fetching decks:", error) + return null + } +} + +// ================================================================================================= diff --git a/packages/webapp/src/actions/setDeck.ts b/packages/webapp/src/actions/setDeck.ts new file mode 100644 index 00000000..c114aeae --- /dev/null +++ b/packages/webapp/src/actions/setDeck.ts @@ -0,0 +1,90 @@ +import { defaultErrorHandling } from "src/actions/errors" +import { contractWriteThrowing } from "src/actions/libContractWrite" +import { Address } from "src/chain" +import { deployment } from "src/deployment" +import { inventoryABI } from "src/generated" +import { checkFresh, freshWrap } from "src/store/checkFresh" +import { Deck } from "src/store/types" + +// ================================================================================================= + +export type SaveArgs = { + deck: Deck + playerAddress: Address + onSuccess: () => void +} + +export type ModifyArgs = { + deck: Deck + playerAddress: Address + index: number + onSuccess: () => void +} + +// ------------------------------------------------------------------------------------------------- + +/** + * Saves a deck created by the player by sending the `saveDeck` transaction. + * + * Returns `true` iff the transaction is successful. + */ +export async function save(args: SaveArgs): Promise { + try { + return await saveImpl(args) + } catch (err) { + return defaultErrorHandling("save", err) + } +} + +/** + * Modifies a deck owned by the player by sending the `modifyDeck` transaction. + * + * Returns `true` iff the transaction is successful. + */ +export async function modify(args: ModifyArgs): Promise { + try { + return await modifyImpl(args) + } catch (err) { + return defaultErrorHandling("modify", err) + } +} + +// ------------------------------------------------------------------------------------------------- + +async function saveImpl(args: SaveArgs): Promise { + const cardBigInts = args.deck.cards.map((card) => card.id) + + checkFresh( + await freshWrap( + contractWriteThrowing({ + contract: deployment.Inventory, + abi: inventoryABI, + functionName: "addDeck", + args: [args.playerAddress, { name: args.deck.name, cards: cardBigInts }], + }) + ) + ) + + args.onSuccess() + return true +} + +async function modifyImpl(args: ModifyArgs): Promise { + const cardBigInts = args.deck.cards.map((card) => card.id) + console.log("INDEX: " + args.index) + checkFresh( + await freshWrap( + contractWriteThrowing({ + contract: deployment.Inventory, + abi: inventoryABI, + functionName: "replaceDeck", + args: [args.playerAddress, args.index, { name: args.deck.name, cards: cardBigInts }], + }) + ) + ) + + args.onSuccess() + return true +} + +// ================================================================================================= diff --git a/packages/webapp/src/components/collection/deckList.tsx b/packages/webapp/src/components/collection/deckList.tsx index 89abdaa3..d357174a 100644 --- a/packages/webapp/src/components/collection/deckList.tsx +++ b/packages/webapp/src/components/collection/deckList.tsx @@ -1,37 +1,74 @@ -import React from "react" +import React, { useCallback, useEffect, useState } from "react" +import { getDeckNames } from "src/actions/getDeck" import Link from "src/components/link" import { Button } from "src/components/ui/button" -import { Deck } from "src/store/types" +import * as store from "src/store/hooks" interface DeckCollectionDisplayProps { - decks: Deck[] onDeckSelect: (deckID: number) => void } -const DeckCollectionDisplay: React.FC = ({ decks, onDeckSelect }) => { +const DeckCollectionDisplay: React.FC = ({ onDeckSelect }) => { + const playerAddress = store.usePlayerAddress() + const [deckNames, setDeckNames] = useState([]) + const [isLoadingDecks, setIsLoadingDecks] = useState(false) + + const loadDeckNames = useCallback(() => { + if (playerAddress) { + setIsLoadingDecks(true) + getDeckNames({ + playerAddress: playerAddress, + onSuccess: () => {}, + }) + .then((response) => { + if (!response.simulatedResult) return + const receivedDecks = response.simulatedResult as string[] + setDeckNames(receivedDecks) + setIsLoadingDecks(false) + }) + .catch((error) => { + console.error("Error fetching decks:", error) + }) + } + }, [playerAddress]) + + useEffect(() => { + loadDeckNames() + }, [loadDeckNames]) + return (
{/* New Deck Button */} -
+ + + {/* Loading Button */} + {isLoadingDecks && ( -
+ )} {/* Deck Buttons */} - {decks.map((deck, deckID) => ( + {deckNames.map((deckname, deckID) => ( ))}
diff --git a/packages/webapp/src/components/collection/deckPanel.tsx b/packages/webapp/src/components/collection/deckPanel.tsx index 20ced2c4..9699d065 100644 --- a/packages/webapp/src/components/collection/deckPanel.tsx +++ b/packages/webapp/src/components/collection/deckPanel.tsx @@ -20,8 +20,11 @@ const DeckConstructionPanel: React.FC = ({ onSave, onCancel, }) => { - const [deckName, setDeckName] = useState(deck.name) - const [deckNameValid, setIsDeckNameValid] = useState(false) + const MAX_CARDS = 40 + const MIN_CARDS = 10 + + const [deckName, setDeckName] = useState(deck.name || "My Deck") + const [deckNameValid, setIsDeckNameValid] = useState(true) const nameValid = (name: string) => name.trim().length > 0 @@ -56,21 +59,36 @@ const DeckConstructionPanel: React.FC = ({ /> + {/* Counter Row */} +
+
+
+
+
+
+
+ {selectedCards.length}/{MAX_CARDS} +
+
+ {/* Save and Cancel Buttons */}
diff --git a/packages/webapp/src/pages/collection.tsx b/packages/webapp/src/pages/collection.tsx index 0cbeb5ec..fb0a292e 100644 --- a/packages/webapp/src/pages/collection.tsx +++ b/packages/webapp/src/pages/collection.tsx @@ -6,17 +6,20 @@ import debounce from "lodash/debounce" import { navigate } from "utils/navigate" import { useAccount } from "wagmi" +import { getDeck } from "src/actions/getDeck" +import { modify, save } from "src/actions/setDeck" import { Address } from "src/chain" import CardCollectionDisplay from "src/components/collection/cardCollectionDisplay" import DeckList from "src/components/collection/deckList" import DeckPanel from "src/components/collection/deckPanel" import FilterPanel from "src/components/collection/filterPanel" import jotaiDebug from "src/components/lib/jotaiDebug" +import { LoadingModal } from "src/components/modals/loadingModal" import { Navbar } from "src/components/navbar" import { deployment } from "src/deployment" import { useInventoryCardsCollectionGetCollection } from "src/generated" import { FablePage } from "src/pages/_app" -import { Card, Deck } from "src/store/types" +import * as store from "src/store/hooks" // NOTE(norswap & geniusgarlic): Just an example, when the game actually has effects & types, // fetch those from the chain instead of hardcoding them here. @@ -30,21 +33,23 @@ const initialTypeMap = Object.assign({}, ...types.map((name) => ({ [name]: false const Collection: FablePage = ({ isHydrated }) => { const router = useRouter() const { address } = useAccount() - const [isEditing, setIsEditing] = useState(false) // Filter Panel / Sorting Panel const [searchInput, setSearchInput] = useState("") const [effectMap, setEffectMap] = useState(initialEffectMap) const [typeMap, setTypeMap] = useState(initialTypeMap) const [selectedCard, setSelectedCard] = useState(null) - - // Deck Collection Display - const [editingDeckIndex, setEditingDeckIndex] = useState(null) - const [decks, setDecks] = useState([]) + const playerAddress = store.usePlayerAddress() // Deck Construction Panel const [currentDeck, setCurrentDeck] = useState(null) const [selectedCards, setSelectedCards] = useState([]) + const [isSaving, setIsSaving] = useState(false) + const [isEditing, setIsEditing] = useState(false) + + // Deck Collection Display + const [editingDeckIndex, setEditingDeckIndex] = useState(null) + const [isLoadingDeck, setIsLoadingDeck] = useState(false) const activeEffects = Object.keys(effectMap).filter((key) => effectMap[key]) const activeTypes = Object.keys(typeMap).filter((key) => typeMap[key]) @@ -85,29 +90,49 @@ const Collection: FablePage = ({ isHydrated }) => { setTypeMap({ ...typeMap, [type]: !typeMap[type] }) } - const handleDeckSelect = (deckID: number) => { - const selectedDeck = decks[deckID] - setCurrentDeck(selectedDeck) - setEditingDeckIndex(deckID) - setIsEditing(true) - setSelectedCards(selectedDeck.cards) - } + const handleSaveDeck = async (updatedDeck: Deck) => { + setIsSaving(true) - const handleSaveDeck = (updatedDeck: Deck) => { - const updatedDecks = [...(decks || [])] if (editingDeckIndex !== null) { // Update existing deck - updatedDecks[editingDeckIndex] = updatedDeck + await modifyOnchain(updatedDeck, editingDeckIndex) } else { // Add the new deck to the list - updatedDecks.push(updatedDeck) + await saveOnchain(updatedDeck) } - setDecks(updatedDecks) + + setIsSaving(false) + setIsEditing(false) setSelectedCards([]) void navigate(router, "/collection") } + function saveOnchain(deck: Deck): Promise { + return new Promise((resolve) => { + save({ + deck, + playerAddress: playerAddress!, + onSuccess: () => { + resolve() + }, + }) + }) + } + + function modifyOnchain(deck: Deck, editingDeckIndex: number): Promise { + return new Promise((resolve) => { + modify({ + deck, + playerAddress: playerAddress!, + index: BigInt(editingDeckIndex), + onSuccess: () => { + resolve() + }, + }) + }) + } + const handleCancelEditing = () => { setIsEditing(false) setSelectedCards([]) @@ -138,6 +163,44 @@ const Collection: FablePage = ({ isHydrated }) => { }) } + const handleDeckSelect = (deckID: number) => { + if (isLoadingDeck) return + if (playerAddress) { + setIsLoadingDeck(true) + getDeck({ + playerAddress: playerAddress, + index: deckID, + onSuccess: () => {}, + }) + .then((response) => { + if (!response.simulatedResult) return + + const cardsReceived = response.simulatedResult.cards + const cardObjects: Card[] = [] + cardsReceived.forEach((card) => { + const cID = Number(card) + const co = cards.find((c) => Number(c.id) === cID) + if (co) { + cardObjects.push(co) + } + }) + + setSelectedCards(cardObjects) + + const deckName = response.simulatedResult.name + setCurrentDeck({ name: deckName, cards: cardObjects }) + setEditingDeckIndex(deckID) + setIsEditing(true) + }) + .catch((error) => { + console.error("Error fetching deck:", error) + }) + .finally((_) => { + setIsLoadingDeck(false) + }) + } + } + // Sets up an event listener for route changes when deck editor is rendered. useEffect(() => { const handleRouteChange = () => { @@ -162,53 +225,58 @@ const Collection: FablePage = ({ isHydrated }) => { 0xFable: My Collection {jotaiDebug()} -
- -
- {/* Left Panel - Search and Filters */} -
- -
- - {/* Middle Panel - Card Collection Display */} -
- -
- - {/* Right Panel - Deck List */} -
- {isEditing && currentDeck ? ( - + ) : ( +
+ +
+ {/* Left Panel - Search and Filters */} +
+ +
+ {/* Middle Panel - Card Collection Display */} +
+ +
+ {/* Right Panel - Deck List */} + {isSaving ? ( + ) : ( - +
+ {isEditing && currentDeck ? ( + + ) : ( + + )} +
)}
-
-
+ + )} ) } - export default Collection diff --git a/packages/webapp/src/store/network.ts b/packages/webapp/src/store/network.ts index d386677b..9c82123f 100644 --- a/packages/webapp/src/store/network.ts +++ b/packages/webapp/src/store/network.ts @@ -45,7 +45,7 @@ export async function fetchDeck(player: Address, deckID: number): Promise