Merge feature/api

* Added backend capabilities with SQLite3
* Added routes for Next.js backend
This commit is contained in:
Joseph Ferano 2023-03-19 10:21:19 +07:00
parent 7098346b51
commit 99bc7a319f
40 changed files with 3838 additions and 598 deletions

2
.gitignore vendored
View File

@ -37,3 +37,5 @@ next-env.d.ts
# vscode
.vscode
database.db

13
Dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM node:16
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

View File

@ -12,12 +12,15 @@ yarn dev
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## API Endpoints
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
POST `/user/login` check if user exists
GET `/user/USER_ID/stakes` get stake event
POST `/user/USER_ID/stakes/claim` claim stake
POST `/user/USER_ID/stakes/start` start stake
GET `/user/USER_ID/bank-account` get balance
PUT `/user/USER_ID/bank-account` sell resource
GET `/user/USER_ID/inventory-items` get inventory items
POST `/user/USER_ID/inventory-items` buy item
GET `/user/USER_ID/staking-sources` get staking sources
POST `/user/USER_ID/staking-sources` create staking source

56
config/game-config.json Normal file
View File

@ -0,0 +1,56 @@
{
"resources": ["Sollux", "Shadowstone", "Azurium", "Novafor", "Nebulance"],
"moons": {
"price": 100,
"resourceChance": 1.0,
"resourceMinStartAmount": 50,
"resourceMaxStartAmount": 200
},
"store": [
{
"id": "item1",
"name": "Drill A",
"description": "This is drill A",
"price": 250,
"claimAmount": 50,
"completionTimeInMins": 5,
"upgrades": [
{ "tier": 1, "price": 200, "claimBoost": 10 },
{ "tier": 2, "price": 300, "claimBoost": 20 },
{ "tier": 3, "price": 400, "claimBoost": 30 },
{ "tier": 4, "price": 500, "claimBoost": 40 },
{ "tier": 5, "price": 600, "claimBoost": 50 }
]
},
{
"id": "item2",
"name": "Drill B",
"description": "This is drill B",
"price": 250,
"claimAmount": 50,
"completionTimeInMins": 5,
"upgrades": [
{ "tier": 1, "price": 200, "claimBoost": 10 },
{ "tier": 2, "price": 300, "claimBoost": 20 },
{ "tier": 3, "price": 400, "claimBoost": 30 },
{ "tier": 4, "price": 500, "claimBoost": 40 },
{ "tier": 5, "price": 600, "claimBoost": 50 }
]
},
{
"id": "item3",
"name": "Drill C",
"description": "This is drill C",
"price": 250,
"claimAmount": 50,
"completionTimeInMins": 5,
"upgrades": [
{ "tier": 1, "price": 200, "claimBoost": 10 },
{ "tier": 2, "price": 300, "claimBoost": 20 },
{ "tier": 3, "price": 400, "claimBoost": 30 },
{ "tier": 4, "price": 500, "claimBoost": 40 },
{ "tier": 5, "price": 600, "claimBoost": 50 }
]
}
]
}

7
db.ts Normal file
View File

@ -0,0 +1,7 @@
import sqlite3 from "sqlite3";
import { open } from "sqlite";
export const dbConnection = open({
filename: "database.db",
driver: sqlite3.Database,
});

View File

@ -1,8 +1,16 @@
/** @type {import('next').NextConfig} */
const fs = require("fs");
const gameConfigContent = fs.readFileSync("config/game-config.json", "utf-8");
const gameConfig = JSON.parse(gameConfigContent);
const nextConfig = {
experimental: {
appDir: true,
},
}
env: {
gameConfig: gameConfig,
},
};
module.exports = nextConfig
module.exports = nextConfig;

2351
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,7 @@
},
"dependencies": {
"@next/font": "13.1.6",
"@solana/web3.js": "^1.73.3",
"@types/node": "18.13.0",
"@types/react": "18.0.27",
"@types/react-dom": "18.0.10",
@ -18,6 +19,8 @@
"next": "13.1.6",
"react": "18.2.0",
"react-dom": "18.2.0",
"sqlite": "^4.1.2",
"sqlite3": "^5.1.4",
"typescript": "4.9.5"
},
"devDependencies": {

42
sql/data.sql Normal file
View File

@ -0,0 +1,42 @@
INSERT INTO users(name) VALUES
('Joe'),
('Emil'),
('Niko'),
('Plug'),
('Upgrade');
INSERT INTO bank_account(user_id, balance) VALUES (1, 500);
INSERT INTO resource_account(user_id, resname, balance) VALUES
(1, 'Sollux', 100),
(1, 'Shadowstone', 100),
(1, 'Azurium', 100),
(1, 'Novafor', 100),
(1, 'Nebulance', 100);
INSERT INTO staking_source(name, description, user_id, address) VALUES
('Selene''s Eye',
'Selene''s Eye is a large and mysterious moon, named for its distinctive appearance - a bright, glowing eye that seems to stare out from the void of space',
1,
'0x1234568');
INSERT INTO resource_well(source_id, resname, supply) VALUES
(1, 'Sollux', 200),
(1, 'Shadowstone', 175),
(1, 'Azurium', 0),
(1, 'Novafor', 25),
(1, 'Nebulance', 10);
INSERT INTO inventory_item(user_id, store_item_id) VALUES
(1, 'item1'),
(1, 'item2');
INSERT INTO staking_event(user_id, well_id, inventory_item_id, duration_in_mins, stake_amount) VALUES
(1, 1, 1, 5, 50),
(1, 3, 2, 5, 50);
INSERT INTO claim_event(staking_event_id, claim_amount) VALUES
(2, 50);

73
sql/queries.sql Normal file
View File

@ -0,0 +1,73 @@
-- Give extra moon bucks
UPDATE bank_account SET balance = 100 WHERE user_id = 1;
SELECT * FROM bank_account WHERE user_id = 1;
--
SELECT staking_event.id,well_id,staking_event.source_id,
inventory_item_id,staking_event.created_at,expiration_at
FROM staking_event
INNER JOIN resource_well ON resource_well.id = well_id
INNER JOIN staking_source on staking_event.source_id = staking_source.id
WHERE staking_event.source_id = ? AND staking_source.user_id = ?;
SELECT name,init_supply
FROM resource_well
INNER JOIN resource ON resource.id = resource_well.resource_id
WHERE source_id = 1;
SELECT inventory_item.id,store_item_id, COUNT(upgrade_event.id) as upgrades
FROM inventory_item
LEFT JOIN upgrade_event ON inventory_item.id = upgrade_event.inventory_item_id
WHERE inventory_item.user_id = 1
GROUP BY inventory_item.id;
SELECT inventory_item.id,store_item_id
FROM inventory_item;
SELECT staking_event.id,well_id,staking_event.source_id,
inventory_item_id,staking_event.created_at,expiration_at
FROM staking_event
INNER JOIN staking_source on staking_event.source_id = staking_source.id
WHERE staking_event.source_id = 4 AND staking_source.user_id = 1;
SELECT staking_event.id, staking_event.well_id, staking_event.source_id,
staking_event.inventory_item_id, staking_event.duration_in_mins,
staking_event.created_at
FROM staking_event
LEFT JOIN claim_event ON staking_event.id = claim_event.staking_event_id
WHERE staking_event.source_id = 4 AND claim_event.staking_event_id IS NULL;
UPDATE staking_event SET created_at = '2023-03-16 09:39:37' WHERE id = 3;
SELECT staking_event.id, staking_source.id as sourceId, resname as resourceType,
inventory_item_id, duration_in_mins, stake_amount, staking_event.created_at,
CASE WHEN claim_event.staking_event_id IS NULL THEN 1 ELSE 0 END AS unclaimed
FROM staking_event
INNER JOIN resource_well ON well_id = resource_well.id
INNER JOIN staking_source ON source_id = staking_source.id
LEFT JOIN claim_event ON staking_event.id = claim_event.staking_event_id
WHERE staking_event.user_id = 1;
SELECT resource_account.id, resource_id,resource.name,balance
FROM resource_account
INNER JOIN resource ON resource_id = resource.id
WHERE user_id = 1;
SELECT staking_source.id as sourceId,resource_well.id as wellId,resname,supply FROM resource_well
INNER JOIN staking_source ON staking_source.id = resource_well.source_id
WHERE staking_source.user_id = 1;
SELECT inventory_item.id, tier, store_item_id,
CASE WHEN claim_event.staking_event_id IS NULL THEN 0 ELSE 1 END AS staking
FROM inventory_item
LEFT JOIN staking_event ON inventory_item_id = inventory_item.id
LEFT JOIN claim_event ON staking_event.id = claim_event.staking_event_id
WHERE inventory_item.id = 3;
SELECT inventory_item.id, tier, store_item_id, staking_event.id as stakeId,
staking_event.created_at as stakeTime, duration_in_mins, stake_amount
FROM inventory_item
LEFT JOIN staking_event ON inventory_item_id = inventory_item.id
WHERE inventory_item.store_item_id = 'item3' AND user_id = 1
ORDER BY staking_event.created_at DESC;

88
sql/tables.sql Normal file
View File

@ -0,0 +1,88 @@
PRAGMA foreign_keys = ON;
CREATE TABLE users (
id integer primary key autoincrement,
wallet varchar,
name varchar(32) not null
);
CREATE TABLE staking_source(
id integer primary key autoincrement,
name varchar not null,
description varchar not null,
user_id int not null,
address varchar(128) not null,
created_at timestamp DEFAULT (current_timestamp || 'Z'),
CONSTRAINT fk_user FOREIGN KEY(user_id)
REFERENCES users(id)
);
CREATE TABLE resource_well(
id integer primary key autoincrement,
resname varchar not null,
source_id int not null,
supply int not null,
CONSTRAINT fk_sid FOREIGN KEY(source_id)
REFERENCES staking_source(id)
ON DELETE CASCADE
);
CREATE TABLE inventory_item(
id integer primary key autoincrement,
user_id int not null,
tier int not null default 0,
store_item_id varchar not null unique,
created_at timestamp DEFAULT (current_timestamp || 'Z'),
CONSTRAINT fk_user FOREIGN KEY(user_id)
REFERENCES users(id)
);
CREATE TABLE upgrade_event(
id integer primary key autoincrement,
inventory_item_id int not null,
created_at timestamp DEFAULT (current_timestamp || 'Z'),
CONSTRAINT fk_iid FOREIGN KEY(inventory_item_id)
REFERENCES inventory_item(id)
);
CREATE TABLE staking_event(
id integer primary key autoincrement,
user_id int not null,
well_id int not null,
inventory_item_id int not null,
duration_in_mins int not null,
stake_amount int not null,
created_at timestamp DEFAULT (current_timestamp || 'Z'),
CONSTRAINT fk_user FOREIGN KEY(user_id)
REFERENCES users(id)
CONSTRAINT fk_wid FOREIGN KEY(well_id)
REFERENCES resource_well(id)
CONSTRAINT fk_iiid FOREIGN KEY(inventory_item_id)
REFERENCES inventory_item(id)
);
CREATE TABLE claim_event(
id integer primary key autoincrement,
staking_event_id int not null,
claim_amount int not null,
created_at timestamp DEFAULT (current_timestamp || 'Z'),
CONSTRAINT fk_se_id FOREIGN KEY(staking_event_id)
REFERENCES staking_event(id)
);
CREATE TABLE bank_account(
id integer primary key autoincrement,
user_id int not null,
balance int not null default 0 CHECK (balance >= 0),
CONSTRAINT fk_user FOREIGN KEY(user_id)
REFERENCES users(id)
);
CREATE TABLE resource_account(
id integer primary key autoincrement,
resname varchar not null,
user_id int not null,
balance int not null default 0 CHECK (balance >= 0),
CONSTRAINT fk_user FOREIGN KEY(user_id)
REFERENCES users(id)
);

View File

@ -1,25 +1,16 @@
"use client";
import React, { useEffect, useState } from "react";
import React from "react";
import { IBankAccount } from "typings";
const BankAccount = (props: { bankAccount: IBankAccount }) => {
const BankAccount = (props: { account: IBankAccount }) => {
return (
<div
className={
props.bankAccount.resourceType.bgColorClass +
" bg-gradient-to-br hover:bg-gradient-to-tr rounded-lg p-3"
}
className={" bg-gradient-to-br hover:bg-gradient-to-tr rounded-lg p-3"}
>
<div className="text-white">
<span
className={
props.bankAccount.resourceType.fontColorClass + " font-bold"
}
>
{props.bankAccount.resourceType.name}
</span>
<span className={" font-bold"}>MoonBucks</span>
<h3 className="text-2xl font-bold">
{props.bankAccount.balance.toLocaleString("en-US", {
{props.account.primaryBalance.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{" "}

View File

@ -1,10 +1,10 @@
"use client";
import React from "react";
import { IBankAccount } from "typings";
import BankAccount from "./BankAccount";
import ResourceAccount from "./ResourceAccount";
const BankAccountsView = (props: {
bankAccounts: IBankAccount[];
bankAccount: IBankAccount | undefined;
setLightBoxIsActive: () => void;
}) => {
return (
@ -22,9 +22,10 @@ const BankAccountsView = (props: {
</button>
</div>
</div>
{props.bankAccounts.map((bankAccount, id) => {
return <BankAccount key={id} bankAccount={bankAccount} />;
})}
{props.bankAccount &&
props.bankAccount.resourceAccounts.map((account, id) => {
return <ResourceAccount key={id} account={account} />;
})}
</div>
</div>
);

View File

@ -0,0 +1,45 @@
import React from "react";
interface ErrorPopoverProps {
message: string;
onClose: () => void;
}
const ErrorPopover: React.FC<ErrorPopoverProps> = ({ message, onClose }) => {
return (
<div className="fixed bottom-2 right-2 flex items-center justify-center z-50">
<div className="bg-white border border-red-500 rounded shadow-lg p-6 max-w-md mx-auto">
<div className="flex items-center">
<div className="flex-shrink-0 text-red-500">
<i className="fas fa-exclamation-triangle"></i>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-600">Error</h3>
<div className="mt-2 text-sm text-gray-700">{message}</div>
</div>
<button
onClick={onClose}
className="ml-auto bg-transparent border-none text-red-600 p-1 focus:outline-none"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
</div>
);
};
export default ErrorPopover;

View File

@ -4,11 +4,10 @@ import CardLayout from "../Layouts/CardLayout";
const InventoryItem = (props: {
inventoryItem: IInventoryItem;
inUse: boolean | undefined;
handleIncrementTier: (inventoryItem: IInventoryItem) => void;
upgradeInventoryItem: (itemId: number) => void;
}) => {
const getCurrentTier = (index: number) => {
return props.inventoryItem.storeItem.tiers[index];
return props.inventoryItem.storeItem.upgrades[index].tier;
};
return (
@ -16,7 +15,7 @@ const InventoryItem = (props: {
<h3 className="text-xl font-bold mb-2">
{props.inventoryItem.storeItem.name}{" "}
<span className="bg-green-600 rounded-full px-2">
{props.inventoryItem.currentTierIndex + 1}
{props.inventoryItem.currentTierIndex}
</span>
</h3>
<p className="text-sm">{props.inventoryItem.storeItem.description}</p>
@ -24,18 +23,21 @@ const InventoryItem = (props: {
<div className="flex-1">
<p className="font-bold mt-4">Yield</p>
<ul className="list-none">
{getCurrentTier(props.inventoryItem.currentTierIndex).tier}
{getCurrentTier(props.inventoryItem.currentTierIndex)}
</ul>
</div>
<div className="flex-1">
<div className="flex">
{props.inUse ? (
{/* { Check if the item is in use } */}
{false ? (
<button className="bg-slate-400 text-slate-600 px-4 py-2 rounded-lg font-bold w-28 text-center cursor-not-allowed">
Upgrade
</button>
) : (
<button
onClick={() => props.handleIncrementTier(props.inventoryItem)}
onClick={() =>
props.upgradeInventoryItem(props.inventoryItem.id)
}
className="bg-slate-100 text-slate-900 px-4 py-2 rounded-lg font-bold w-28 text-center"
>
Upgrade

View File

@ -5,15 +5,8 @@ import InventoryItem from "./InventoryItem";
const InventoryItemView = (props: {
stakingSources: IStakingSource[] | null;
inventoryItems: IInventoryItem[] | null | undefined;
handleIncrementTier: (inventoryItem: IInventoryItem) => void;
upgradeInventoryItem: (itemId: number) => void;
}) => {
const inUse = (inventoryItemId: number) => {
return props.stakingSources?.some((source) => {
if (!source.inventoryItem) return false;
return source.inventoryItem.id == inventoryItemId;
});
};
return (
<div className="bg-slate-800 text-white p-4 rounded-lg">
<h2 className="text-2xl font-bold mb-4">Your Inventory</h2>
@ -22,8 +15,7 @@ const InventoryItemView = (props: {
<InventoryItem
key={id}
inventoryItem={inventoryItem}
handleIncrementTier={props.handleIncrementTier}
inUse={inUse(inventoryItem.id)}
upgradeInventoryItem={props.upgradeInventoryItem}
/>
))}
</div>

View File

@ -7,15 +7,13 @@ const ResourceAccount = (props: { account: IResourceAccount }) => {
return (
<div
className={
resourceToBg(props.account.resourceType) +
" bg-gradient-to-br hover:bg-gradient-to-tr rounded-lg p-3 flex-1"
resourceToBg(props.account.name) +
" bg-gradient-to-br hover:bg-gradient-to-tr rounded-lg p-3"
}
>
<div className="text-white">
<span
className={resourceToFc(props.account.resourceType) + " font-bold"}
>
{props.account.resourceType}
<span className={resourceToFc(props.account.name) + " font-bold"}>
{props.account.name}
</span>
<h3 className="text-2xl font-bold">
{props.account.balance.toLocaleString("en-US", {

View File

@ -0,0 +1,32 @@
import React, { useState, ChangeEvent } from "react";
import { ISelectDropdownProps } from "typings";
const SelectDropdown: React.FC<ISelectDropdownProps> = (props) => {
const [selectedValue, setSelectedValue] = useState("");
const handleChange = (event: ChangeEvent<HTMLSelectElement>) => {
setSelectedValue(event.target.value);
if (props.onChange) {
props.onChange(event.target.value);
}
};
return (
<div>
<select
value={selectedValue}
onChange={handleChange}
className="text-black block w-full p-2 border border-gray-300 rounded-lg"
>
<option value="">Select an option</option>
{props.options.map((option, index) => (
<option key={index} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
};
export default SelectDropdown;

View File

@ -1,114 +1,87 @@
import React, { useState, useEffect } from "react";
import { IInventoryItem, IStakingSource, IClaimableResource } from "typings";
import { IInventoryItem, IStakingSource, IStake, IOption } from "typings";
import CardLayout from "../Layouts/CardLayout";
import {
calculateRemainingTime,
prettifyTime,
getObjectFromArray,
} from "../../utils/helpers";
import SelectDropdown from "./SelectDropdown";
const StakingSource = (props: {
stakingSource: IStakingSource;
inventoryItems: IInventoryItem[] | null | undefined;
handleAddItem: (
inventoryItem: IInventoryItem,
stakingSource: IStakingSource
) => void;
claimResource: (
stakingSource: IStakingSource,
claimedResource: IClaimableResource
) => boolean;
claimStake: (stakingEventId: number) => void;
}) => {
const [isMining, setIsMining] = useState(false);
const [miningTime, setMiningTime] = useState(0);
const [claimableResource, setClaimableResource] =
useState<IClaimableResource | null>(null);
const [activeInventoryItem, setActiveInventoryItem] =
useState<IInventoryItem | null>(null);
const [activeTier, setActiveTier] = useState(0);
const [activeStakes, setActiveStakes] = useState<IStake[]>([]);
// Check if claimable every second
useEffect(() => {
if (isMining && activeInventoryItem) {
const intervalId = setInterval(() => {
setMiningTime((prev) => prev - 1);
}, 1000);
if (miningTime === 0) {
setIsMining(false);
// Get a random resource from available wells
let randResource =
props.stakingSource.resourceWells[
Math.floor(Math.random() * props.stakingSource.resourceWells.length)
];
// Get yield based on the tier
const totalYield = activeInventoryItem.storeItem.tiers[activeTier].tier;
// Construct the claimableResource
const claimableResource: IClaimableResource = {
resourceType: randResource.resourceType,
balance: totalYield,
};
setClaimableResource(claimableResource);
clearInterval(intervalId);
setMiningTime(activeInventoryItem.storeItem.timeToClaim);
}
return () => {
clearInterval(intervalId);
};
}
}, [isMining, miningTime]);
const isChecked = (item: IInventoryItem) => {
if (!props.stakingSource.inventoryItem) return false;
return item.id === props.stakingSource.inventoryItem.id;
};
const handleStartMining = () => {
if (props.stakingSource.inventoryItem) {
setActiveInventoryItem(props.stakingSource.inventoryItem);
setMiningTime(props.stakingSource.inventoryItem.storeItem.timeToClaim);
setActiveTier(props.stakingSource.inventoryItem.currentTierIndex);
setIsMining(true);
}
};
const handleClaim = () => {
if (!claimableResource) return;
if (props.claimResource(props.stakingSource, claimableResource)) {
setClaimableResource(null);
}
};
const renderButton = () => {
if (props.stakingSource.inventoryItem) {
if (!claimableResource && !isMining) {
return (
<button
onClick={() => handleStartMining()}
className="bg-slate-100 text-slate-900 px-4 py-2 rounded-lg font-bold text-center"
>
Start Mining
</button>
);
}
}
if (isMining)
return (
<button className="bg-slate-400 text-slate-600 px-4 py-2 rounded-lg font-bold w-28 text-center cursor-not-allowed">
{miningTime}
</button>
const handleIsClaimable = props.stakingSource.activeStakes.map((stake) => {
const remainingTime = calculateRemainingTime(
stake.startTime,
stake.durationInMins
);
if (claimableResource) {
const obj = {
...stake,
remainingTime: remainingTime,
isClaimable: remainingTime <= 0,
};
return obj;
});
const intervalId = setInterval(() => {
setActiveStakes(handleIsClaimable);
}, 1000);
return () => {
clearInterval(intervalId);
};
});
const handleStartMining = () => {
console.log("Start mining..");
};
const handleClaim = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
console.log("Start claiming..");
};
const handleSelectChange = (selectedValue: string) => {
console.log(selectedValue);
// Render a staking button
};
const RenderButtonOrCountdown = (props: { stake: IStake }) => {
if (!props.stake.remainingTime) return <p>Error</p>;
if (!props.stake.unclaimed)
return <p className="text-red-400 font-bold">Claimed</p>;
if (props.stake.remainingTime <= 0) {
return (
<button
onClick={() => handleClaim()}
onClick={(e) => handleClaim(e)}
className="bg-slate-100 text-slate-900 px-4 py-2 rounded-lg font-bold text-center"
>
Claim
</button>
);
}
if (true) {
return (
<div className="flex">
<p>{prettifyTime(props.stake.remainingTime).hours} hrs.</p>
<p>{prettifyTime(props.stake.remainingTime).minutes} mins.</p>
<p>{prettifyTime(props.stake.remainingTime).seconds} sec.</p>
</div>
);
}
};
const isChecked = (item: IInventoryItem): boolean => {
return props.stakingSource.activeStakes.some((obj) => obj.id === item.id);
};
return (
@ -117,69 +90,54 @@ const StakingSource = (props: {
<p className="text-sm">{props.stakingSource.description}</p>
<div className="flex gap-4">
<div className="flex-1">
<p className="font-bold mt-4">Resources</p>
<ul className="list-none">
{props.stakingSource.resourceWells.map((resourceWell, id) => (
<li key={id}>
<span>{resourceWell.resourceType.name}</span>{" "}
<span className="text-teal-500 font-bold">
{resourceWell.supply}
</span>
</li>
<p className="font-bold mt-4 mb-2">Stakes</p>
{activeStakes &&
activeStakes.map((stake, id) => (
<div key={id} className="mb-3 border border-white p-2">
<p>
<span className="font-bold">Drill: </span>
{props.inventoryItems &&
getObjectFromArray(props.inventoryItems, "id", stake.id)
?.storeItem.name}
</p>
<p>
<span className="font-bold">Resource: </span>
{stake.resourceType}
</p>
<p>
<span className="font-bold">Stake amount: </span>
{stake.stakeAmount}
</p>
<p>
<span className="font-bold">Start Time:</span>{" "}
{stake.startTime}
</p>
<RenderButtonOrCountdown stake={stake} />
</div>
))}
</ul>
</div>
<div className="flex-1">
<p className="font-bold mt-4">Status</p>
<ul className="list-none">
<li className="flex">
<span className="text-green-600 mr-1">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</span>
Operational
</li>
</ul>
<p className="font-bold mt-4">Equipment</p>
{!isMining
? props.inventoryItems &&
props.inventoryItems.map((item, id) => (
<div key={id}>
<input
type="radio"
name="inventoryItem"
value={item.id}
checked={isChecked(item)}
onChange={() =>
props.handleAddItem(item, props.stakingSource)
}
/>
<label htmlFor={item.id.toString()}>
{item.storeItem.name}{" "}
<span className="bg-white text-black rounded-full px-2">
{item.currentTierIndex + 1}
</span>
</label>
</div>
))
: props.stakingSource.inventoryItem && (
<p>{props.stakingSource.inventoryItem.storeItem.name}</p>
)}
<p className="font-bold mt-4 mb-2">Activate Drills</p>
{props.inventoryItems &&
props.inventoryItems.map(
(item, id) =>
!isChecked(item) && (
<div key={id} className="border border-white p-2">
<p>{item.storeItem.name}</p>
<SelectDropdown
options={props.stakingSource.resourceWells.map(
(well): IOption => ({
value: well.resourceType,
label: well.resourceType,
})
)}
onChange={handleSelectChange}
/>
</div>
)
)}
</div>
</div>
{renderButton()}
</CardLayout>
);
};

View File

@ -1,19 +1,12 @@
"use client";
import React from "react";
import { IInventoryItem, IStakingSource, IClaimableResource } from "typings";
import { IInventoryItem, IStakingSource, IStake, IGameConfig } from "typings";
import StakingSource from "./StakingSource";
const StakingSourcesView = (props: {
stakingSources: IStakingSource[] | null;
inventoryItems: IInventoryItem[] | null | undefined;
handleAddItem: (
inventoryItem: IInventoryItem,
stakingSource: IStakingSource
) => void;
claimResource: (
stakingSource: IStakingSource,
claimedResource: IClaimableResource
) => boolean;
claimStake: (stakingEventId: number) => void;
}) => {
return (
<div className="bg-slate-800 p-4 rounded-lg text-white">
@ -24,8 +17,7 @@ const StakingSourcesView = (props: {
key={id}
stakingSource={stakingSource}
inventoryItems={props.inventoryItems}
handleAddItem={props.handleAddItem}
claimResource={props.claimResource}
claimStake={props.claimStake}
/>
))}
</div>

View File

@ -1,11 +1,11 @@
"use client";
import React, { useState } from "react";
import { IStoreItem, IInventoryItem } from "typings";
import { IStoreItem } from "typings";
import CommonCardLayout from "../Layouts/CommonCardLayout";
const StoreItem = (props: {
storeItem: IStoreItem;
handleBuy: (storeItem: IStoreItem) => void;
buyStoreItem: (itemId: string) => void;
owned: boolean | undefined;
}) => {
return (
@ -22,20 +22,22 @@ const StoreItem = (props: {
<p className="text-lg font-bold">$ {props.storeItem.price}</p>
<p className="font-bold mt-3">Tier Upgrades</p>
<table className="table-auto text-center">
<tr>
<th className="border border-white py-1 px-3 w-18">Tier</th>
<th className="border border-white py-1 px-3 w-28">Price</th>
</tr>
{props.storeItem.tiers.map((tier) => (
<tbody>
<tr>
<td className="border border-white py-1 px-3 w-18">
{tier.tier}
</td>
<td className="border border-white py-1 px-3 w-28">
${tier.price}
</td>
<th className="border border-white py-1 px-3 w-18">Tier</th>
<th className="border border-white py-1 px-3 w-28">Price</th>
</tr>
))}
{props.storeItem.upgrades.map((tier, id) => (
<tr key={id}>
<td className="border border-white py-1 px-3 w-18">
{tier.tier}
</td>
<td className="border border-white py-1 px-3 w-28">
${tier.price}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center h-100">
@ -45,7 +47,7 @@ const StoreItem = (props: {
</button>
) : (
<button
onClick={() => props.handleBuy(props.storeItem)}
onClick={() => props.buyStoreItem(props.storeItem.id)}
className="bg-slate-100 text-slate-900 px-4 py-2 rounded-lg font-bold w-28 text-center"
>
Buy

View File

@ -5,9 +5,9 @@ import StoreItem from "./StoreItem";
const StoreItemView = (props: {
storeItems: IStoreItem[] | null;
inventoryItems: IInventoryItem[] | null | undefined;
handleBuy: (storeItem: IStoreItem) => void;
buyStoreItem: (itemId: string) => void;
}) => {
const isOwned = (storeItemId: number) => {
const isOwned = (storeItemId: string) => {
return props.inventoryItems?.some(
(item) => item.storeItem.id == storeItemId
);
@ -19,7 +19,6 @@ const StoreItemView = (props: {
owned: isOwned(storeItem.id),
};
});
return (
<div className="bg-slate-800 p-4 rounded-lg">
<h2 className="text-2xl font-bold mb-4 text-white">Store</h2>
@ -30,7 +29,7 @@ const StoreItemView = (props: {
<StoreItem
key={id}
storeItem={storeItem}
handleBuy={props.handleBuy}
buyStoreItem={props.buyStoreItem}
owned={storeItem.owned}
/>
))}

View File

@ -1,14 +1,80 @@
"use client";
import { useEffect, useState } from "react";
import type { PublicKey } from "@solana/web3.js";
import "./globals.css";
import { PhantomProvider } from "typings";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const [provider, setProvider] = useState<PhantomProvider>();
const [walletAddress, setWalletAddress] = useState("");
const [showErrorMessage, setShowErrorMessage] = useState(false);
useEffect(() => {
const provider = getProvider();
setProvider(provider);
if (!provider) return;
provider.on("connect", async (publicKey: PublicKey) => {
const body = { publicKey: publicKey };
const res = await fetch("/api/user/login", {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
if (res.status === 200) {
setWalletAddress(publicKey.toBase58());
}
if (res.status === 404) {
setShowErrorMessage(true);
setTimeout(() => {
setShowErrorMessage(false);
}, 3000);
}
});
}, []);
const getProvider = () => {
if ("phantom" in window) {
const anyWindow: any = window;
const provider = anyWindow.phantom.solana;
if (provider.isPhantom) return provider;
}
};
const connectWallet = () => {
if (!provider) return console.log("no provider");
provider.connect();
};
return (
<html lang="en">
<head />
<body className="bg-slate-900">{children}</body>
<body className="bg-slate-900">
<nav className="text-white flex justify-between mt-4">
<div className="p-2 m-4 bg-slate-800 rounded-lg">Moon Miners</div>
{showErrorMessage && (
<div className="p-2 m-4 bg-red-800 rounded-lg">
Wallet address not registered!
</div>
)}
{walletAddress.length === 0 ? (
<button
onClick={connectWallet}
className="p-2 m-4 bg-slate-800 rounded-lg">
Connect Wallet
</button>
) : (
<div className="p-2 m-4 bg-slate-800 rounded-lg">
{walletAddress}
</div>
)}
</nav>
{children}
</body>
</html>
);
}

View File

@ -6,12 +6,14 @@ import StakingSourcesView from "./Components/StakingSourcesView";
import BankAccountsView from "./Components/BankAccountsView";
import StoreItemView from "./Components/StoreItemView";
import ResourceStore from "./Components/ResourceStore";
import ErrorPopover from "./Components/ErrorPopover";
import { gameConfig } from "@/utils/gameLogic";
import {
IStoreItem,
IInventoryItem,
IStakingSource,
IBankAccount,
IClaimableResource,
IStake,
} from "typings";
export default function Home() {
@ -21,176 +23,220 @@ export default function Home() {
const [stakingSources, setStakingSources] = useState<IStakingSource[] | null>(
[]
);
const [bankAccounts, setBankAccounts] = useState<IBankAccount[]>([]);
const [bankAccount, setBankAccount] = useState<IBankAccount>();
const [storeItems, setStoreItems] = useState<IStoreItem[]>([]);
const [userStakes, setUserStakes] = useState<IStake[]>([]);
const [lightBoxIsActive, setLightBoxIsActive] = useState(false);
const [userId, setUserId] = useState<number | null>(null);
const [error, setError] = useState<Error | null>(null);
const [errorTime, setErrorTime] = useState(3);
// EMIL
// It should be possible to add infinite drills on each resource (however many the user has)
// JOE
// Transaction table
// Add dev buttons to prototype
// DONE POST /user/login check if user exists
// DONE GET /user/USER_ID/stakes get stake event
// DONE POST /user/USER_ID/stakes/claim claim stake
// DONE POST /user/USER_ID/stakes/start start stake
// DONE GET /user/USER_ID/bank-account get balance
// DONE PUT /user/USER_ID/bank-account sell resource
// DONE GET /user/USER_ID/inventory-items get inventory items
// DONE POST /user/USER_ID/inventory-items buy item
// DONE GET /user/USER_ID/staking-sources get staking sources
// DONE POST /user/USER_ID/staking-sources create staking source
// Connect to DB here
useEffect(() => {
// get the user who is currently logged in
const loggedInUser = 1;
const fetchUser = async (wallet: string) => {
const response = await fetch(`/api/user/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
wallet: wallet,
}),
});
if (response.status === 200) {
console.log("Logged in");
const { userId } = await response.json();
setUserId(userId);
}
};
const fetchInventoryItems = async () => {
const response = await fetch(`/api/user/${loggedInUser}/inventory-items`);
const DBInventoryItems = await response.json();
setInventoryItems(DBInventoryItems.message);
const fetchBankAccount = async () => {
const response = await fetch(`/api/user/${userId}/bank-account`);
const bankAccount = await response.json();
setBankAccount(bankAccount);
};
const fetchStakingSources = async () => {
const response = await fetch(`/api/user/${loggedInUser}/staking-sources`);
const DBStakingSources = await response.json();
setStakingSources(DBStakingSources.message);
const response = await fetch(`/api/user/${userId}/staking-sources`);
const sources = await response.json();
setStakingSources(sources);
};
const fetchBankAccounts = async () => {
const response = await fetch(`/api/user/${loggedInUser}/bank-accounts`);
const DBBankAccounts = await response.json();
setBankAccounts(DBBankAccounts.message);
};
const fetchInventoryItems = async () => {
const response = await fetch(`/api/user/${userId}/inventory-items`);
const DBInventoryItems = await response.json();
const fetchStoreItems = async () => {
const response = await fetch(`/api/store/items`);
const DBStoreItems = await response.json();
setStoreItems(DBStoreItems.message);
};
fetchBankAccounts();
fetchStakingSources();
fetchInventoryItems();
fetchStoreItems();
}, []);
// Use effect to update the items on staking sources when inventoryItems are updated
useEffect(() => {
const updateItemsOnStakingSources = () => {
const updatedStakingSources = stakingSources?.map((source) => {
const item = inventoryItems?.find(
(item) => source.inventoryItem?.id === item.id
for (const invItem of DBInventoryItems) {
invItem.storeItem = gameConfig.store.find(
(item) => item.id === invItem.storeItemId
);
if (item) {
return { ...source, inventoryItem: item };
} else {
return source;
}
}
setInventoryItems(DBInventoryItems);
};
const fetchStakes = async () => {
const response = await fetch(`/api/user/${userId}/stakes`);
const stakes = await response.json();
setUserStakes(
stakes.map((stake: IStake) => ({
...stake,
unclaimed: stake.unclaimed == 1,
}))
);
};
fetchUser("abcdefg"); // Public key goes here
// Nico is there a better way of checking if a user is logged in?
if (userId) {
setStoreItems(gameConfig.store);
fetchBankAccount();
fetchStakingSources();
fetchInventoryItems();
fetchStakes();
}
}, [userId]);
// Hide error automatically
useEffect(() => {
if (error) {
const intervalId = setInterval(() => {
setErrorTime((prev) => prev - 1);
}, 1000);
if (errorTime === 0) {
setError(null);
clearInterval(intervalId);
setErrorTime(3);
}
return () => {
clearInterval(intervalId);
};
}
}, [error, errorTime]);
const claimStake = async (stakingEventId: number) => {
const response = await fetch(`/api/user/${userId}/stakes/claim`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stakingEventId: stakingEventId }),
});
return await response.json();
};
const startStake = async (inventoryItemId: number, wellId: number) => {
const response = await fetch(`/api/user/${userId}/stakes/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
inventoryItemId: inventoryItemId,
wellId: wellId,
}),
});
return await response.json();
};
// Which object is it?
const sellResource = async (obj: any) => {
const response = await fetch(`/api/user/${userId}/bank-account`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
obj: obj,
}),
});
return await response.json();
};
const buyStoreItem = async (itemId: string) => {
try {
const response = await fetch(`/api/user/${userId}/inventory-items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
itemId: itemId,
}),
});
updatedStakingSources && setStakingSources(updatedStakingSources);
};
if (stakingSources && stakingSources?.length > 0) {
updateItemsOnStakingSources();
}
}, [inventoryItems]);
const handleAddItem = (
inventoryItem: IInventoryItem,
stakingSource: IStakingSource
) => {
const newStakingSources = stakingSources?.map((source) => {
if (source.id === stakingSource.id) {
return { ...source, inventoryItem: inventoryItem };
} else {
return source;
if (!response.ok) {
const error = await response.text();
setError(new Error(error));
return;
}
});
if (newStakingSources) {
setStakingSources(newStakingSources);
const data = await response.json();
if (response.status == 200) {
// Return success message
console.log(data.message);
}
} catch (error) {
if (error instanceof Error) {
setError(error);
} else {
setError(new Error("An unknown error occurred."));
}
}
};
const handleBuy = (storeItem: IStoreItem) => {
const hasItem = inventoryItems?.some((item) => {
item.storeItem.id === storeItem.id;
});
const upgradeInventoryItem = async (itemId: number) => {
try {
const response = await fetch(`/api/user/${userId}/inventory-items`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
itemId: itemId,
}),
});
if (hasItem) return;
if (!response.ok) {
const error = await response.text();
setError(new Error(error));
return;
}
const getNewIndex = () => {
if (!inventoryItems) return 0;
return inventoryItems.length;
};
const data = await response.json();
const newInventoryItem = {
id: getNewIndex(),
stakingSource: null,
storeItem: storeItem,
currentTierIndex: 0,
};
if (response.status == 200) {
// Return success message
console.log(data.message);
}
if (inventoryItems && inventoryItems !== undefined) {
setInventoryItems([...inventoryItems, newInventoryItem]);
if (response.status == 400) {
// return error message
setError(data.error);
}
} catch (error) {
if (error instanceof Error) {
setError(error);
} else {
setError(new Error("An unknown error occurred."));
}
}
};
const claimResource = (
stakingSource: IStakingSource,
claimedResource: IClaimableResource
): boolean => {
const bankAccount = bankAccounts.find(
(obj) => obj["resourceType"]["name"] === claimedResource.resourceType.name
);
if (!bankAccount) return false;
decrementResourceWell(stakingSource, claimedResource);
incrementBalance(bankAccount, claimedResource.balance);
return true;
};
const decrementResourceWell = (
stakingSource: IStakingSource,
claimedResource: IClaimableResource
) => {
const updatedResourceWells = stakingSource.resourceWells.map((well) => {
if (well.resourceType.name === claimedResource.resourceType.name) {
return { ...well, supply: well.supply - claimedResource.balance };
} else {
return well;
}
const createStakingSource = async () => {
const response = await fetch(`/api/user/${userId}/staking-sources`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const updatedStakingSources = stakingSources?.map((source) => {
if (source.id === stakingSource.id) {
return { ...source, resourceWells: updatedResourceWells };
} else {
return source;
}
});
updatedStakingSources && setStakingSources(updatedStakingSources);
};
const decrementBalance = () => {};
const incrementBalance = (bankAccount: IBankAccount, amount: number) => {
const updatedBankAccounts = bankAccounts.map((account) => {
if (account.id === bankAccount.id) {
return { ...account, balance: account.balance + amount };
} else {
return account;
}
});
setBankAccounts(updatedBankAccounts);
};
const getStoreItemConfiguration = () => {};
const handleIncrementTier = (inventoryItem: IInventoryItem) => {
console.log("Incrementing Tier");
// Check user has balance
// Decrement user balance
if (inventoryItem.currentTierIndex === 4) return;
const updatedInventoryItems = inventoryItems?.map((item) => {
if (item.id === inventoryItem.id) {
return { ...item, currentTierIndex: item.currentTierIndex + 1 };
} else {
return item;
}
});
if (updatedInventoryItems !== undefined)
setInventoryItems(updatedInventoryItems);
return await response.json();
};
const handleSetLightBox = () => {
@ -206,8 +252,11 @@ export default function Home() {
return (
<>
{error && (
<ErrorPopover message={error.message} onClose={handleCloseError} />
)}
<BankAccountsView
bankAccounts={bankAccounts}
bankAccount={bankAccount}
setLightBoxIsActive={handleSetLightBox}
/>
{lightBoxIsActive && (
@ -220,18 +269,17 @@ export default function Home() {
<StakingSourcesView
stakingSources={stakingSources}
inventoryItems={inventoryItems}
handleAddItem={handleAddItem}
claimResource={claimResource}
claimStake={claimStake}
/>
<InventoryItemView
stakingSources={stakingSources}
inventoryItems={inventoryItems}
handleIncrementTier={handleIncrementTier}
upgradeInventoryItem={upgradeInventoryItem}
/>
<StoreItemView
storeItems={storeItems}
inventoryItems={inventoryItems}
handleBuy={handleBuy}
buyStoreItem={buyStoreItem}
/>
</div>
</>

39
src/pages/api/drop.ts Normal file
View File

@ -0,0 +1,39 @@
import { dbConnection } from "db";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
if (req.method === "GET") {
const db = await dbConnection;
const users = "DROP TABLE users";
await db.all(users);
const resource = "DROP TABLE resource";
await db.all(resource);
const staking_source = "DROP TABLE staking_source";
await db.all(staking_source);
const resource_well = "DROP TABLE resource_well";
await db.all(resource_well);
const staking_event = "DROP TABLE staking_event";
await db.all(staking_event);
const claim_event = "DROP TABLE claim_event";
await db.all(claim_event);
const upgrade_event = "DROP TABLE upgrade_event";
await db.all(upgrade_event);
const store_item = "DROP TABLE store_item";
await db.all(store_item);
const inventory_item = "DROP TABLE inventory_item";
await db.all(inventory_item);
const bank_account = "DROP TABLE bank_account";
await db.all(bank_account);
const resource_account = "DROP TABLE resource_account";
await db.all(resource_account);
res.status(200).json("Tables dropped");
}
} catch (error) {
res.status(500).json(error);
}
}

27
src/pages/api/seed.ts Normal file
View File

@ -0,0 +1,27 @@
import { dbConnection } from "db";
import type { NextApiRequest, NextApiResponse } from "next";
import * as fs from 'fs';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
if (req.method === "GET") {
const db = await dbConnection;
// Read SQL queries from file
const sql1 = fs.readFileSync('sql/tables.sql', 'utf-8');
// Execute the SQL queries
await db.exec(sql1);
const sql2 = fs.readFileSync('sql/data.sql', 'utf-8');
await db.exec(sql2);
res.status(200).json("Database seeded");
}
} catch (error) {
res.status(500).json(error.message);
}
}

View File

@ -1,80 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { IStoreItem } from "typings";
export default function handler(req: NextApiRequest, res: NextApiResponse) {
try {
if (req.method === "GET") {
// query all store items
const storeItems: IStoreItem[] = [
{
id: 1,
name: "Eclipse Drill",
description:
"A compact and lightweight drill designed for use in tight and narrow mining tunnels.",
price: 225,
timeToClaim: 3,
tiers: [
{
tier: 500,
price: 50,
},
{
tier: 600,
price: 75,
},
{
tier: 700,
price: 100,
},
{
tier: 800,
price: 150,
},
{
tier: 900,
price: 200,
},
],
},
{
id: 2,
name: "Moon Saw 2000",
description:
"A compact and lightweight drill designed for use in tight and narrow mining tunnels.",
price: 100,
timeToClaim: 3,
tiers: [
{
tier: 500,
price: 50,
},
{
tier: 550,
price: 75,
},
{
tier: 600,
price: 100,
},
{
tier: 650,
price: 150,
},
{
tier: 700,
price: 200,
},
],
},
];
// if no store items send empty array
if (!storeItems) return res.status(200).json({ message: [] });
// if store items found send store items
return res.status(200).json({ message: storeItems });
}
} catch (error) {
res.status(500).json({ message: "Unexpexted server error" });
}
}

View File

@ -0,0 +1,27 @@
import { dbConnection } from "db";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
// http://127.0.0.1:3000/api/user/1/add-balance/100
// Remember to change to POST
try {
if (req.method === "GET") {
const { userId, amount } = req.query;
const db = await dbConnection;
const sql = `
UPDATE bank_account SET balance = balance + ?
WHERE user_id = ?`;
const result = await db.run(sql, [amount, userId])
if (result.changes == 0) {
return res.status(400).json({error: `User ${userId} was not found`});
} else {
return res.status(200).json({message: result});
}
}
} catch (error) {
return res.status(500).json(error);
}
}

View File

@ -0,0 +1,48 @@
import { dbConnection } from "db";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
if (req.method === "GET") {
const { userId } = req.query;
const db = await dbConnection;
const userSql = `SELECT * FROM users WHERE id = ?`;
const user = await db.all(userSql, [userId]);
if (user.length === 0) return res.status(404).json("User not found");
const resourceAccountsSql = `
SELECT id,resname as resourceType,balance
FROM resource_account
WHERE user_id = ?`;
// need resourceName or at least a way to map together with gameConfiq
const resourceAccounts = await db.all(resourceAccountsSql, [userId]);
const bankAccountSql = `SELECT * FROM bank_account WHERE user_id = ?`;
const bankAccount = await db.all(bankAccountSql, [userId]);
const bankAccountObj = {
id: bankAccount[0].id,
primaryBalance: bankAccount[0].balance,
resourceAccounts: resourceAccounts,
};
return res.status(200).json(bankAccountObj);
}
if (req.method === "PUT") {
// sell resource
// payload userId, key:resourceName/value:amount
const { userId } = req.query;
const { obj } = req.body;
// make sure they have each resource they say they have
// calculate conversion rates
// increment balance
// decrement resources
}
} catch (error) {
res.status(500).json(error);
}
}

View File

@ -1,73 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { IBankAccount, IResourceType } from "typings";
export default function handler(req: NextApiRequest, res: NextApiResponse) {
try {
if (req.method === "GET") {
const { userId } = req.query;
// query db for user ID
const user = true;
// if user not found send error
if (!user) return res.status(404).json({ message: "User not found" });
// if user found query all bank accounts attached to this user
const DBresourceTypes: IResourceType[] = [
{
id: 1,
name: "Moonstone",
fontColorClass: "text-teal-400",
bgColorClass: "from-teal-800 to-teal-900",
},
{
id: 2,
name: "Lunarite",
fontColorClass: "text-cyan-400",
bgColorClass: "from-cyan-800 to-cyan-900",
},
{
id: 3,
name: "Selenite",
fontColorClass: "text-purple-300",
bgColorClass: "from-purple-800 to-purple-900",
},
{
id: 4,
name: "Heliogem",
fontColorClass: "text-rose-300",
bgColorClass: "from-rose-800 to-rose-900",
},
];
const bankAccounts: IBankAccount[] = [
{
id: 1,
resourceType: DBresourceTypes[0],
balance: 0,
},
{
id: 2,
resourceType: DBresourceTypes[1],
balance: 0,
},
{
id: 3,
resourceType: DBresourceTypes[2],
balance: 0,
},
{
id: 4,
resourceType: DBresourceTypes[3],
balance: 0,
},
];
// if bank accounts not found send empty array
if (!bankAccounts) return res.status(200).json({ message: [] });
// if bank accounts found send bank accounts
return res.status(200).json({ message: bankAccounts });
}
} catch (error) {
res.status(500).json({ message: "Unexpexted server error" });
}
}

View File

@ -1,26 +1,91 @@
import { dbConnection } from "db";
import type { NextApiRequest, NextApiResponse } from "next";
import { IInventoryItem } from "typings";
import { gameConfig } from "@/utils/gameLogic";
export default function handler(req: NextApiRequest, res: NextApiResponse) {
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
if (req.method === "GET") {
// Get all owned items
const { userId } = req.query;
const db = await dbConnection;
const inventorySql = `
SELECT id, store_item_id as storeItemId, tier as currentTierIndex
FROM inventory_item WHERE user_id = ?`;
const inventoryItems = await db.all(inventorySql, [userId]);
return res.status(200).json(inventoryItems);
// query db for user ID
const user = true;
// if user not found send error
if (!user) return res.status(404).json({ message: "User not found" });
} else if (req.method === "POST") {
// Buy a new item
const { userId } = req.query;
const { itemId } = req.body;
const db = await dbConnection;
// if user found query all inventory items attached to this user
const inventoryItems: IInventoryItem[] = [];
const storeItem = gameConfig.store.find((item) => item.id == itemId);
// if inventory items not found send empty array
if (!inventoryItems) return res.status(200).json({ message: [] });
if (storeItem == undefined) {
return res.status(400).json({ error: "Item does not exist" });
}
const itemPrice = storeItem.price;
// TODO: Split the try catch to report already owned item error
try {
await db.run("BEGIN");
await db.run(`UPDATE bank_account SET balance = balance - ?
WHERE user_id = ?`, [itemPrice, userId]);
await db.run(`INSERT INTO inventory_item (user_id, store_item_id)
VALUES (?, ?)`, [userId, itemId]);
await db.run("COMMIT");
} catch (error) {
await db.exec("ROLLBACK");
return res.status(400).json({ error: "Either Insufficient funds or item is already owned" });
}
return res.status(200).json({ message: "Item purchased successfully" });
// if inventory items found send inventory items
return res.status(200).json({ message: inventoryItems });
} else if (req.method === "PUT") {
// Upgrade an existing item
const { userId } = req.query;
const { itemId } = req.body;
const db = await dbConnection;
const invSql = "SELECT id,tier,store_item_id FROM inventory_item WHERE id = ? AND user_id = ?";
const invItem = await db.get(invSql, [itemId, userId]);
const storeItem = gameConfig.store.find((item) => item.id == invItem.store_item_id);
if (storeItem == undefined) {
return res.status(400).json({ error: "Item does not exist" });
}
const tier = invItem.tier;
if (tier == undefined) {
return res.status(400).json({ error: "Item does not exist" });
}
if (tier >= storeItem.upgrades.length) {
return res.status(400).json({ error: "Max upgrade reached" });
}
const upgradePrice = storeItem.upgrades[tier].price;
try {
await db.run("BEGIN");
await db.run(`UPDATE bank_account SET balance = balance - ?
WHERE user_id = ?`, [upgradePrice, userId]);
await db.run(`UPDATE inventory_item SET tier = tier + 1
WHERE user_id = ? AND store_item_id = ?;`, [userId, itemId]);
await db.run("INSERT INTO upgrade_event(inventory_item_id) VALUES ( ? )", [invItem.store_item_id]);
await db.run("COMMIT");
} catch (error) {
await db.exec("ROLLBACK");
return res.status(400).json({ error: "Insufficient funds" });
}
return res.status(200).json({ message: "Successfully upgraded item" });
}
} catch (error) {
res.status(500).json({ message: "Unexpexted server error" });
if(error instanceof Error){
res.status(500).json(error.message);
}else {
res.status(500).json("Unknow Error");
}
}
}

View File

@ -0,0 +1,60 @@
import { dbConnection } from "db";
import type { NextApiRequest, NextApiResponse } from "next";
import { IInventoryItem } from "typings";
import {
calculateRemainingTime,
} from "../../../../../utils/helpers";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
if (req.method === "POST") {
const { userId } = req.query;
const { stakingEventId } = req.body;
const db = await dbConnection;
try {
const stake = await db.get("SELECT * FROM staking_event WHERE id = ? AND user_id = ?",
[stakingEventId, userId]);
if (stake == undefined) {
return res.status(400).json({error: "Could not find stake"});
}
const claim = await db.get("SELECT id FROM claim_event WHERE staking_event_id = ?",
[stakingEventId])
if (claim != undefined) {
return res.status(400).json({error: "Stake already claimed"});
}
const timeRemaining = calculateRemainingTime(stake.created_at, stake.duration_in_mins);
if (timeRemaining > 0) {
return res.status(400).json({error: "Staking period has not ended"});
}
const well = await db.get("SELECT id,resname,supply FROM resource_well WHERE id = ?",
[stake.well_id])
const claimAmount = stake.stake_amount > well.supply ? well.supply : stake.stake_amount;
await db.run("BEGIN");
await db.run("INSERT INTO claim_event(staking_event_id, claim_amount) VALUES (?, ?)",
[stake.id, claimAmount])
console.log(well.id);
const r1 = await db.run(`UPDATE resource_well SET supply = supply - ? WHERE id = ?`,
[claimAmount, well.id]);
console.log(stake.user_id, well.resname);
const r2 = await db.run(`UPDATE resource_account SET balance = balance + ?
WHERE user_id = ? AND resname = ?`, [claimAmount, stake.user_id, well.resname]);
await db.run("COMMIT");
} catch (error) {
await db.exec("ROLLBACK");
return res.status(400).json({ error: error.message});
}
return res.status(200).json({result: "Successfully claimed stake"});
}
} catch (error) {
res.status(500).json(error);
}
}

View File

@ -0,0 +1,28 @@
import { dbConnection } from "db";
import type { NextApiRequest, NextApiResponse } from "next";
import { IInventoryItem } from "typings";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
if (req.method === "GET") {
const { userId } = req.query;
const db = await dbConnection;
const inventorySql = `
SELECT staking_event.id, staking_source.id as sourceId, resname as resourceType,
inventory_item_id, duration_in_mins, stake_amount, staking_event.created_at,
CASE WHEN claim_event.staking_event_id IS NULL THEN 1 ELSE 0 END AS unclaimed
FROM staking_event
INNER JOIN resource_well ON well_id = resource_well.id
INNER JOIN staking_source ON source_id = staking_source.id
LEFT JOIN claim_event ON staking_event.id = claim_event.staking_event_id
WHERE staking_event.user_id = ?`;
const inventoryItems = await db.all(inventorySql, [userId]);
return res.status(200).json(inventoryItems);
}
} catch (error) {
res.status(500).json(error);
}
}

View File

@ -0,0 +1,79 @@
import { dbConnection } from "db";
import type { NextApiRequest, NextApiResponse } from "next";
import {
calculateRemainingTime,
} from "../../../../../utils/helpers";
import { gameConfig } from "@/utils/gameLogic";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
if (req.method === "POST") {
const { userId } = req.query;
const {
inventoryItemId,
wellId,
} = req.body;
const db = await dbConnection;
try {
const inventorySql = `
SELECT inventory_item.id, tier, store_item_id, staking_event.id as stakeId,
staking_event.created_at as stakeTime, duration_in_mins, stake_amount
FROM inventory_item
LEFT JOIN staking_event ON inventory_item_id = inventory_item.id
WHERE inventory_item.store_item_id = ? AND inventory_item.user_id = ?
ORDER BY staking_event.created_at;
`;
const invItem = await db.get(inventorySql, [inventoryItemId, userId]);
const well = await db.get(`
SELECT resource_well.id, resname, source_id, supply as wellId FROM resource_well
INNER JOIN staking_source ON source_id = staking_source.id
WHERE resource_well.id = ? AND user_id = ?;
`, [wellId, userId]);
if (well == undefined) {
return res.status(400).json({error: `Well ${wellId} not found`});
}
const item = gameConfig.store.find((i) => i.id == invItem.store_item_id);
if (invItem == undefined || well == undefined || item == undefined) {
return res.status(400).json({ error: "A resource was not found" });
}
if (invItem.stakeId != undefined) {
const timeRemaining = calculateRemainingTime(invItem.stakeTime, invItem.duration_in_mins);
console.log(timeRemaining);
if (timeRemaining > 0) {
return res.status(400).json({error: `Item is still in use ${timeRemaining}`});
}
}
const boost = invItem.tier > 0 ? item.upgrades[invItem.tier - 1].claimBoost : 0;
const totalClaim = item.claimAmount + boost;
console.log(userId + " " + well.id + " " + invItem.id)
await db.run(`INSERT INTO staking_event(user_id, well_id, inventory_item_id, duration_in_mins, stake_amount)
VALUES (?,?,?,?,?)`,
[userId, well.id, invItem.id, item.completionTimeInMins, totalClaim]);
} catch (error) {
if(error instanceof Error){
return res.status(400).json({ error: error.message});
}else {
return res.status(400).json({ error: "Unknown Error"});
}
}
return res.status(200).json({result: "Successfully started a stake"});
}
} catch (error) {
if(error instanceof Error){
res.status(500).json(error.message);
}else {
res.status(500).json({ error: "Unknown Error"});
}
}
}

View File

@ -1,46 +1,93 @@
import { dbConnection } from "db";
import type { NextApiRequest, NextApiResponse } from "next";
import { IStakingSource } from "typings";
import { gameConfig } from "@/utils/gameLogic";
import {
generateRandomBase64String,
} from "../../../../utils/helpers";
export default function handler(req: NextApiRequest, res: NextApiResponse) {
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
if (req.method === "GET") {
const { userId } = req.query;
const db = await dbConnection;
const sourcesSql = `
SELECT id, name, description FROM staking_source
WHERE staking_source.user_id = ?`;
const stakingSources = await db.all(sourcesSql, [userId]);
for (const source of stakingSources) {
const wellsSql = `
SELECT id, resname as resourceType, supply FROM resource_well
WHERE source_id = ?`;
const resourceWells = await db.all(wellsSql, [source.id]);
source.resourceWells = resourceWells;
const stakesSql = `
SELECT staking_event.id, resname as resourceType, source_id as stakingSourceId,
inventory_item_id, duration_in_mins, stake_amount,
staking_event.created_at as startTime
FROM staking_event
INNER JOIN resource_well ON well_id = resource_well.id
INNER JOIN staking_source ON source_id = staking_source.id
LEFT JOIN claim_event ON staking_event.id = claim_event.staking_event_id
WHERE staking_source.id = ? AND claim_event.staking_event_id IS NULL;`;
const activeStakes = await db.all(stakesSql, [source.id]);
source.activeStakes = activeStakes;
}
const typedStakingSources: IStakingSource[] = stakingSources.map(source => ({
...source,
// Emil: The variable names are messed up so i'm renaming them
activeStakes: source.activeStakes.map((stake) => ({
...stake,
inventoryItemId: stake.inventory_item_id,
durationInMins: stake.duration_in_mins,
stakeAmount: stake.stake_amount
}))
}))
// query db for user ID
const user = true;
// if user not found send error
if (!user) return res.status(404).json({ message: "User not found" });
return res.status(200).json(typedStakingSources);
}
if (req.method === "POST") {
const { userId } = req.query;
const db = await dbConnection;
// if user found query all staking sources attached to this user
const stakingSources: IStakingSource[] = [
{
id: 1,
name: "Selene's Eye",
description:
"Selene's Eye is a large and mysterious moon, named for its distinctive appearance - a bright, glowing eye that seems to stare out from the void of space",
resourceWells:[
{
id: 1,
resourceType: {
id: 1,
name: "Moonstone",
fontColorClass: "text-teal-400",
bgColorClass: "from-teal-800 to-teal-900",
},
supply: 10000,
}
],
inventoryItem: null,
},
];
try {
const randomName = "Moon 1";
const randomDescription = "This is a moon orbiting Selene's planet";
const randomKey = "0x" + generateRandomBase64String(16);
const resMax = gameConfig.moons.resourceMaxStartAmount;
const resMin = gameConfig.moons.resourceMinStartAmount;
const moonPrice = gameConfig.moons.price;
// if staking sources not found send empty array
if (!stakingSources) return res.status(200).json({ message: [] });
// if staking sources found send staking sources
return res.status(200).json({ message: stakingSources });
await db.run("BEGIN");
await db.run(`UPDATE bank_account SET balance = balance - ?
WHERE user_id = ?`, [moonPrice, userId]);
const result = await db.run(`INSERT INTO staking_source(user_id, name, description, address)
VALUES (?, ?, ?, ?)`, [userId, randomName, randomDescription, randomKey]);
const sourceId = result.lastID;
for (const resname of gameConfig.resources) {
if (Math.random() < gameConfig.moons.resourceChance) {
const randomNumber = Math.random();
const range = resMax - resMin + 1;
const initSupply = Math.floor(Math.random() * range + resMin);
await db.run(`INSERT INTO resource_well (source_id, resname, supply)
VALUES (?, ?, ?)`, [sourceId, resname, initSupply]);
}
}
await db.run("COMMIT");
return res.status(200).json({"stakingSourceId": sourceId});
} catch (error) {
await db.exec("ROLLBACK");
if (error.message.includes("CHECK constraint failed")) {
return res.status(400).json({ "error": "Insuficcient funds" });
} else {
return res.status(400).json({ "error": error.message });
}
}
}
} catch (error) {
res.status(500).json({ message: "Unexpexted server error" });
res.status(500).json(error.message);
}
}

View File

@ -0,0 +1,26 @@
import { dbConnection } from "db";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
if (req.method === "POST") {
const { wallet } = req.body;
// const db = await dbConnection;
// const user = await db.get("SELECT id FROM users WHERE wallet = ?", [wallet]);
// Comment me out
return res.status(200).json({ userId: 1});
/* if (user != undefined) {
return res.status(200).json({ userId: user.id});
} else {
return res.status(404).json({ message: "User not found" });
} */
}
} catch (error) {
res.status(500).json(error);
}
}

3
src/utils/gameLogic.ts Normal file
View File

@ -0,0 +1,3 @@
import { IGameConfig } from "typings";
export const gameConfig = process.env.gameConfig as unknown as IGameConfig;

View File

@ -1,3 +1,6 @@
import { TimeDuration } from "typings";
import crypto from 'crypto';
export function getObjectFromArray<T>(array: T[], key: keyof T, value: any): T | undefined {
return array.find(obj => obj[key] === value);
}
@ -9,5 +12,59 @@ export function pushElementToArray<T>(array: T[], element: T): T[] {
}
export function sumValues<T extends Record<K, number>, K extends keyof T>(array: T[], key: K): number {
return array.reduce((acc, obj) => acc + obj[key], 0);
return array.reduce((acc, obj) => acc + obj[key], 0);
}
interface IMapping {
[key: string]: any
}
export function resourceToBg(resourceName: string): string {
const mapping: IMapping = {
"Sollux": "from-teal-800 to-teal-900",
"Shadowstone": "from-cyan-800 to-cyan-900",
"Azurium": "from-purple-800 to-purple-900",
"Novafor": "from-rose-800 to-rose-900",
"Nebulance": "from-rose-800 to-rose-900"
}
return mapping[resourceName]
}
export function resourceToFc(resourceName: string): string {
const mapping: IMapping = {
"Sollux": "text-teal-400",
"Shadowstone": "text-cyan-400",
"Azurium": "text-purple-300",
"Novafor": "text-rose-300",
"Nebulance": "text-rose-300"
}
return mapping[resourceName]
}
export const calculateRemainingTime = (startTime: string, durationInMins: number) => {
const start = new Date(startTime);
const end = new Date(start.getTime() + durationInMins * 60000);
const remaining = end.getTime() - Date.now();
return remaining;
};
export const prettifyTime = (remainingTime: number) => {
const hours = Math.floor((remainingTime / (1000 * 60 * 60)) % 24);
const minutes = Math.floor((remainingTime / (1000 * 60)) % 60);
const seconds = Math.floor((remainingTime / 1000) % 60);
return { hours, minutes, seconds };
}
export const generateRandomBase64String = (length: number) => {
// Create a random byte array of the desired length.
const byteArray = new Uint8Array(length);
crypto.getRandomValues(byteArray);
// Convert the byte array to a base64 string.
const base64String = btoa(String.fromCharCode(...byteArray));
return base64String;
}

52
test-endpoints.rest Normal file
View File

@ -0,0 +1,52 @@
:headers = <<
Content-Type: application/json
#
# Get Inventory Items
GET http://localhost:3000/api/seed
:headers
# Get Inventory Items
POST http://localhost:3000/api/user/login
:headers
{ "wallet" : "Wallet12345678" }
# Get Inventory Items
GET http://localhost:3000/api/user/1/bank-account
:headers
# Get Inventory Items
GET http://localhost:3000/api/user/1/staking-sources
:headers
# Get Inventory Items
POST http://localhost:3000/api/user/1/staking-sources
:headers
# Get Inventory Items
GET http://localhost:3000/api/user/1/inventory-items
:headers
# Buy a new Item
POST http://localhost:3000/api/user/1/inventory-items/
:headers
{ "itemId" : "item3" }
# Upgrade an owned item
PUT http://localhost:3000/api/user/1/inventory-items/
:headers
{ "itemId" : "item1" }
# Get stakes
GET http://localhost:3000/api/user/1/stakes/
:headers
# Start a stake
POST http://localhost:3000/api/user/1/stakes/start
:headers
{ "inventoryItemId": "item1", "wellId": 7 }
# Claim a stake
POST http://localhost:3000/api/user/1/stakes/claim
:headers
{ "stakingEventId" : 4 }

91
typings.d.ts vendored
View File

@ -11,16 +11,29 @@ export interface TimeDuration {
export interface IResourceWell {
id: number;
resourceType: IResourceType;
resourceType: string;
supply: number;
}
export interface IStake {
id: number;
resourceType: string;
stakingSourceId: number;
startTime: string;
inventoryItemId: number;
stakeAmount: number;
durationInMins: number;
unclaimed: boolean;
claimable: boolean | undefined
remainingTime: number | undefined
}
export interface IStakingSource {
id: number;
name: string;
description: string;
resourceWells: IResourceWell[];
inventoryItem: IInventoryItem | null;
activeStakes: IStake[];
}
export interface IInventoryItem {
@ -30,26 +43,35 @@ export interface IInventoryItem {
}
export interface IStoreItem {
id: number;
id: string;
name: string;
description: string;
price: number;
timeToClaim: number;
tiers: {
completionTimeInMins: number;
claimAmount: number;
upgrades: {
tier: number;
price: number;
claimBoost: number;
}[];
}
export interface IResourceAccount {
id: number;
resourceType: string;
balance: number;
}
export interface IBankAccount {
id: number;
resourceType: IResourceType;
balance: number;
primaryBalance: number;
resourceAccounts: IResourceAccount[];
}
export interface IClaimableResource {
resourceType: IResourceType;
balance: number;
export interface IGameConfig {
resources: string[];
[key: string]: any;
store: IStoreItem[];
}
export interface IConversionPair {
@ -57,3 +79,52 @@ export interface IConversionPair {
resourceAmount: number;
moneyAmount: number
}
// Phantom
export interface PhantomProvider {
publicKey: PublicKey | null;
isConnected: boolean | null;
signAndSendTransaction: (
transaction: Transaction,
opts?: SendOptions
) => Promise<{ signature: string; publicKey: PublicKey }>;
signTransaction: (transaction: Transaction) => Promise<Transaction>;
signAllTransactions: (transactions: Transaction[]) => Promise<Transaction[]>;
signMessage: (
message: Uint8Array | string,
display?: DisplayEncoding
) => Promise<any>;
connect: (opts?: Partial<ConnectOpts>) => Promise<{ publicKey: PublicKey }>;
disconnect: () => Promise<void>;
on: (event: PhantomEvent, handler: (args: any) => void) => void;
request: (method: PhantomRequestMethod, params: any) => Promise<unknown>;
}
type DisplayEncoding = "utf8" | "hex";
interface ConnectOpts {
onlyIfTrusted: boolean;
}
type PhantomEvent = "connect" | "disconnect" | "accountChanged";
type PhantomRequestMethod =
| "connect"
| "disconnect"
| "signAndSendTransaction"
| "signTransaction"
| "signAllTransactions"
| "signMessage";
// Generic stuff
export interface IOption {
value: string,
label: string
}
export interface ISelectDropdownProps {
options: IOption[];
onChange?: (value: string) => void;
}