diff --git a/EVENTS.md b/EVENTS.md new file mode 100644 index 00000000..6ec2c787 --- /dev/null +++ b/EVENTS.md @@ -0,0 +1,41 @@ +## 2024-03-26 + +- Появился новый ресурс: камень +- Новый навык: Шахтер. Прокачивается в процессе добычи камня +- Теперь можно купить топор +- Топор ломается при каждом использовании +- Разная скорость добычи: руками и при использовании инструмента + +## 2024-03-25 + +- Фаза Альфа-1 окончена: собрали первую 1000 древесины в деревне +- Придумали с bezsovesty идею добавить аудио-комнату, чтобы можно было общаться с игроками + +## 2024-03-24 + +- Команда !продать +- Инвентарь игрока +- Первый навык: Лесоруб. Прокачивается в процессе добычи древесины + +## 2024-03-22 + +- Анимация животных. Зайцы и волк двигаются по экрану +- Рубашка игрока отображается отдельно, генерируется случайный цвет + +## 2024-03-21 + +- Отображение топора при рубке дерева +- Анимация у деревьев +- Топ игроков по репутации: топ поддержавших деревню + +## 2024-03-20 + +- Данные начали собираться в БД +- Появился первый ресурс - древесина +- Запустили фазу Альфа-1: начался сбор древесины, команда !дар +- Репутация в награду за пожертвования +- Первые звуки: звук леса и рубки деревьев + +## 2024-03-18 + +- Первый стрим с игрой \ No newline at end of file diff --git a/apps/api/src/bot.ts b/apps/api/src/bot.ts index 96d6d504..3796f2a3 100644 --- a/apps/api/src/bot.ts +++ b/apps/api/src/bot.ts @@ -2,11 +2,14 @@ import { promises as fs } from "node:fs"; import { RefreshingAuthProvider } from "@twurple/auth"; import { Bot, createBotCommand } from "@twurple/easy-bot"; import { + buyAxeFromDealer, createCommand, donateWoodFromPlayerInventory, findOrCreatePlayer, + findStoneToMine, findTreeToChop, getInventory, + reserveStone, reserveTree, sellWoodFromPlayerInventory, setPlayerMovingToTarget, @@ -69,7 +72,7 @@ export async function serveBot() { await reserveTree(tree.id); - // Send player to chop + // Send player to tree await setPlayerMovingToTarget({ id: player.id, targetId: tree.id, @@ -78,6 +81,38 @@ export async function serveBot() { }); }, ), + createBotCommand( + "добывать", + async (params, { userId, userName, reply }) => { + console.log("добывать", userId, userName, params); + + const player = await findOrCreatePlayer({ + twitchId: userId, + userName, + }); + if (!player || player.isBusy) { + void reply(`${userName}, ты пока занят(а).`); + return; + } + + const stone = await findStoneToMine(); + if (!stone || !stone.id) { + void reply( + `${userName}, нет доступного камня. Может скоро освободится?`, + ); + return; + } + + await reserveStone(stone.id); + + await setPlayerMovingToTarget({ + id: player.id, + targetId: stone.id, + x: stone.x, + y: stone.y, + }); + }, + ), createBotCommand( "подарить", async (params, { userId, userName, reply }) => { @@ -106,8 +141,9 @@ export async function serveBot() { }); void reply( - `${userName}, ты подарил деревне всю древесину! Твоя репутация возросла.`, + `${userName}, ты подарил(а) деревне всю древесину! Твоя репутация возросла.`, ); + return; } void reply( @@ -142,7 +178,8 @@ export async function serveBot() { command: "!продать", }); - void reply(`${userName}, ты продал всю древесину торговцу!`); + void reply(`${userName}, ты продал(а) всю древесину торговцу!`); + return; } void reply( @@ -150,6 +187,44 @@ export async function serveBot() { ); }, ), + createBotCommand( + "купить", + async (params, { userId, userName, reply }) => { + console.log("купить", userId, userName, params); + + const player = await findOrCreatePlayer({ + twitchId: userId, + userName, + }); + const items = await getInventory(player.id); + + if (params[0] === "топор") { + // Find if already have some + const axe = items.find((item) => item.type === "AXE"); + if (axe) { + // No way + void reply(`${userName}, у тебя уже есть топор.`); + return; + } + + const result = await buyAxeFromDealer(player.id); + if (!result) { + void reply(`${userName}, неа.`); + return; + } + + await createCommand({ + playerId: player.id, + command: "!купить", + }); + + void reply(`${userName}, ты купил(а) топор у торговца!`); + return; + } + + void reply(`${userName}, укажи конкретнее, например: !купить топор`); + }, + ), ], }); diff --git a/apps/api/src/db.repository.ts b/apps/api/src/db.repository.ts index d23aa4d6..c6ffbea3 100644 --- a/apps/api/src/db.repository.ts +++ b/apps/api/src/db.repository.ts @@ -1,7 +1,12 @@ import { createId } from "@paralleldrive/cuid2"; -import type { ItemType } from "../../../packages/api-sdk/src"; +import type { + ItemType, + PlayerBusinessType, + SkillType, + TargetType, +} from "../../../packages/api-sdk/src"; +import { getRandomInRange } from "../../../packages/api-sdk/src/lib/random.ts"; import { db } from "./db.client.ts"; -import { getRandomInRange } from "./lib/helpers.ts"; export function createPlayer(dto: { twitchId: string; userName: string }) { const colorIndex = getRandomInRange(0, 100); @@ -27,6 +32,12 @@ export function updatePlayer(dto: { twitchId: string; x: number; y: number }) { }); } +export function findPlayer(id: string) { + return db.player.findUnique({ + where: { id }, + }); +} + export async function findOrCreatePlayer({ twitchId, userName, @@ -60,6 +71,13 @@ export function findTopByReputationPlayers() { }); } +export async function getPlayerCoins(id: string) { + const player = await db.player.findUnique({ + where: { id }, + }); + return player ? player.coins : null; +} + export function createCommand(dto: { playerId: string; command: string; @@ -153,7 +171,7 @@ export async function growTrees() { } } -export async function setTreeInProgress(id: string) { +export async function setTreeInProgress(id: string, seconds: number) { const tree = await db.tree.findUnique({ where: { id }, }); @@ -161,9 +179,9 @@ export async function setTreeInProgress(id: string) { return null; } - // 30 seconds to chop? + // Time to chop const time = new Date(); - const milliseconds = 30 * 1000; + const milliseconds = seconds * 1000; const progressFinishAt = new Date(time.getTime() + milliseconds); return db.tree.update({ @@ -209,16 +227,15 @@ export async function setPlayerMovingToTarget(dto: { targetX: dto.x, targetY: dto.y, targetId: dto.targetId, - isBusy: true, // running? + isBusy: true, + businessType: "RUNNING", lastActionAt: new Date(), }, }); } -export async function setPlayerSkillUp(playerId: string, skill: "WOOD") { - const player = await db.player.findUnique({ - where: { id: playerId }, - }); +export async function setPlayerSkillUp(playerId: string, skill: SkillType) { + const player = await findPlayer(playerId); if (!player) { return null; } @@ -242,14 +259,44 @@ export async function setPlayerSkillUp(playerId: string, skill: "WOOD") { }); } - console.log("+1 skill", playerId); + const instrument = await getInventoryItem(playerId, "AXE"); + const increment = instrument ? 3 : 1; + + console.log(`+${increment} skill`, playerId); return db.player.update({ where: { id: playerId }, data: { - skillWood: { - increment: 1, + skillWood: { increment }, + }, + }); + } + + if (skill === "MINING") { + if (player.skillMining >= player.skillMiningNextLvl) { + console.log("skill lvl up!", playerId); + + const skillMiningNextLvl = Math.floor(player.skillMiningNextLvl * 1.5); + + return db.player.update({ + where: { id: playerId }, + data: { + skillMiningLvl: { increment: 1 }, + skillMiningNextLvl, + skillMining: 0, }, + }); + } + + const instrument = await getInventoryItem(playerId, "PICKAXE"); + const increment = instrument ? 3 : 1; + + console.log(`+${increment} skill`, playerId); + + return db.player.update({ + where: { id: playerId }, + data: { + skillMining: { increment }, }, }); } @@ -271,24 +318,52 @@ export async function setPlayerIsOnTarget(playerId: string) { return; } - // Find target - const tree = await findTree(player.targetId); - if (!tree) { + const targetType = await getTargetType(player.targetId); + if (!targetType) { return null; } - // After - will chop - await setPlayerCoordinates(player.id, tree.x, tree.y); - await setPlayerChopping(player.id); + if (targetType === "TREE") { + const tree = await findTree(player.targetId); + if (!tree) { + return null; + } - // Working time - await setTreeInProgress(tree.id); + await setPlayerCoordinates(player.id, tree.x, tree.y); + await setPlayerStartedWorking(player.id, "CHOPPING"); - await createCommand({ - playerId: player.id, - command: "!рубить", - target: tree.id, - }); + const axe = await getInventoryItem(playerId, "AXE"); + const workTimeSeconds = axe ? 10 : 30; + + await setTreeInProgress(tree.id, workTimeSeconds); + + return createCommand({ + playerId: player.id, + command: "!рубить", + target: tree.id, + }); + } + + if (targetType === "STONE") { + const stone = await findStone(player.targetId); + if (!stone) { + return null; + } + + await setPlayerCoordinates(player.id, stone.x, stone.y); + await setPlayerStartedWorking(player.id, "MINING"); + + const pickaxe = await getInventoryItem(playerId, "PICKAXE"); + const workTimeSeconds = pickaxe ? 10 : 30; + + await setStoneInProgress(stone.id, workTimeSeconds); + + return createCommand({ + playerId: player.id, + command: "!копать", + target: stone.id, + }); + } } export function setPlayerCoordinates(id: string, x: number, y: number) { @@ -301,19 +376,36 @@ export function setPlayerCoordinates(id: string, x: number, y: number) { }); } -export async function setPlayerChopping(id: string) { +export async function setPlayerStartedWorking( + id: string, + businessType: PlayerBusinessType, +) { await setPlayerMadeAction(id); - await clearPlayerTarget(id); // now player is on target + await clearPlayerTarget(id); return db.player.update({ where: { id }, data: { - isBusy: true, // chopping? - lastActionAt: new Date(), + isBusy: true, + businessType, }, }); } +export async function getTargetType(id: string): Promise { + const tree = await findTree(id); + if (tree) { + return "TREE"; + } + + const stone = await findStone(id); + if (stone) { + return "STONE"; + } + + return null; +} + async function addWoodToVillage(amount: number) { await db.village.updateMany({ data: { @@ -365,6 +457,31 @@ export async function donateWoodFromPlayerInventory(playerId: string) { }); } +export async function buyAxeFromDealer(playerId: string) { + const AXE_PRICE = 20; + + const axe = await getInventoryItem(playerId, "AXE"); + if (axe) { + return null; + } + + const coins = await getPlayerCoins(playerId); + if (!coins || coins < AXE_PRICE) { + return null; + } + + await db.player.update({ + where: { id: playerId }, + data: { + coins: { + decrement: AXE_PRICE, + }, + }, + }); + + return checkAndAddInventoryItem(playerId, "AXE", 1); +} + export async function sellWoodFromPlayerInventory(playerId: string) { const wood = await getInventoryItem(playerId, "WOOD"); if (!wood) { @@ -394,6 +511,7 @@ function setPlayerNotBusy(playerId: string) { where: { id: playerId }, data: { isBusy: false, + businessType: null, }, }); } @@ -440,6 +558,13 @@ export async function findCompletedTrees() { await checkAndAddInventoryItem(command.playerId, "WOOD", tree.resource); // Player is free now await setPlayerNotBusy(command.playerId); + + const minusDurability = getRandomInRange(8, 16); + await checkAndBreakInventoryItem( + command.playerId, + "AXE", + minusDurability, + ); } // Destroy tree @@ -514,3 +639,124 @@ export function getInventory(playerId: string) { where: { playerId }, }); } + +export async function checkAndBreakInventoryItem( + playerId: string, + type: ItemType, + durabilityAmount: number, +) { + const item = await getInventoryItem(playerId, type); + if (!item) { + return null; + } + + console.log(`Reduce item ${item.id} durability by ${durabilityAmount}!`); + + if (item.durability <= durabilityAmount) { + // Destroy item! + return db.inventoryItem.delete({ + where: { id: item.id }, + }); + } + + return db.inventoryItem.update({ + where: { id: item.id }, + data: { + durability: { + decrement: durabilityAmount, + }, + }, + }); +} + +export function findStones() { + return db.stone.findMany(); +} + +export function findStone(id: string) { + return db.stone.findUnique({ where: { id } }); +} + +export function findStoneToMine() { + return db.stone.findFirst({ + where: { + size: { gte: 50 }, + inProgress: false, + isReserved: false, + }, + orderBy: { + progressFinishAt: "asc", + }, + }); +} + +export function reserveStone(id: string) { + return db.stone.update({ + where: { id }, + data: { + isReserved: true, + }, + }); +} + +export async function findCompletedStones() { + const stones = await db.stone.findMany({ + where: { + inProgress: true, + progressFinishAt: { + lte: new Date(), + }, + }, + }); + for (const stone of stones) { + console.log(stone.id, `${stone.resource} resource`, "stone completed"); + + // Get command + const command = await db.command.findFirst({ + where: { target: stone.id }, + orderBy: { createdAt: "desc" }, + }); + if (command) { + await checkAndAddInventoryItem(command.playerId, "STONE", stone.resource); + // Player is free now + await setPlayerNotBusy(command.playerId); + + const minusDurability = getRandomInRange(8, 16); + await checkAndBreakInventoryItem( + command.playerId, + "PICKAXE", + minusDurability, + ); + } + + const resource = getRandomInRange(1, 4); + + await db.stone.update({ + where: { id: stone.id }, + data: { + isReserved: false, + inProgress: false, + resource, + }, + }); + } +} + +export async function setStoneInProgress(id: string, seconds: number) { + const stone = await findStone(id); + if (!stone) { + return null; + } + + const time = new Date(); + const milliseconds = seconds * 1000; + const progressFinishAt = new Date(time.getTime() + milliseconds); + + return db.stone.update({ + where: { id }, + data: { + progressFinishAt, + inProgress: true, + }, + }); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 95557c56..e2fbc154 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,11 +1,13 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { cors } from "hono/cors"; +import type { SkillType } from "../../../packages/api-sdk/src"; import { serveBot } from "./bot"; import { findActivePlayers, findCommands, findOrCreatePlayer, + findStones, findTopByReputationPlayers, findTreeToChop, findTrees, @@ -17,6 +19,7 @@ import { updateTree, } from "./db.repository.ts"; import { servePlayer } from "./player.ts"; +import { serveStone } from "./stone.ts"; import { serveTree } from "./tree.ts"; const app = new Hono(); @@ -69,11 +72,11 @@ app.get("/players/:id/inventory", async (c) => { return c.json(items); }); -app.post("players/:id/skill/wood", async (c) => { +app.post("players/:id/skill", async (c) => { const id = c.req.param("id"); + const body = await c.req.json<{ type: SkillType }>(); - // +1 - await setPlayerSkillUp(id, "WOOD"); + await setPlayerSkillUp(id, body.type); return c.json({ ok: true, @@ -106,6 +109,12 @@ app.patch("trees/:id", async (c) => { }); }); +app.get("/stones", async (c) => { + const stones = await findStones(); + + return c.json(stones); +}); + app.get("/village", async (c) => { const village = await findVillage(); @@ -118,6 +127,7 @@ console.log(`Server is running on port ${port}`); void serveBot(); void servePlayer(); void serveTree(); +void serveStone(); serve({ fetch: app.fetch, diff --git a/apps/api/src/stone.ts b/apps/api/src/stone.ts new file mode 100644 index 00000000..669baaee --- /dev/null +++ b/apps/api/src/stone.ts @@ -0,0 +1,8 @@ +import { findCompletedStones } from "./db.repository.ts"; + +export async function serveStone() { + // Check for completed + setInterval(() => { + void findCompletedStones(); + }, 5000); +} diff --git a/apps/api/src/tree.ts b/apps/api/src/tree.ts index a5a25caf..8654d2d5 100644 --- a/apps/api/src/tree.ts +++ b/apps/api/src/tree.ts @@ -9,5 +9,5 @@ export async function serveTree() { // Check for completed setInterval(() => { void findCompletedTrees(); - }, 10000); + }, 5000); } diff --git a/apps/client/public/stone/stone_res1_64.png b/apps/client/public/stone/stone_res1_64.png new file mode 100644 index 00000000..06d09546 Binary files /dev/null and b/apps/client/public/stone/stone_res1_64.png differ diff --git a/apps/client/src/components/dealer.tsx b/apps/client/src/components/dealer.tsx index 1e9bcdee..f57d6620 100644 --- a/apps/client/src/components/dealer.tsx +++ b/apps/client/src/components/dealer.tsx @@ -44,11 +44,10 @@ export const DealerBlock = ({ dealer }: { dealer: Dealer }) => { /> @@ -99,18 +98,10 @@ const DealBlock = ({ function getDealMessage(type: "BUY" | "SELL") { if (type === "BUY") { - return ( - <> - !продать древесину - - ); + return !продать древесину; } if (type === "SELL") { - return ( - <> - !купить кирку - - ); + return !купить топор; } } @@ -125,6 +116,13 @@ function getDealItem(type: ItemType, amount: number) { ); } + if (type === "AXE") { + return ( +
+ +
+ ); + } if (type === "PICKAXE") { return (
diff --git a/apps/client/src/components/interface.tsx b/apps/client/src/components/interface.tsx index 8fa02eb2..6dea7c0a 100644 --- a/apps/client/src/components/interface.tsx +++ b/apps/client/src/components/interface.tsx @@ -1,10 +1,11 @@ import { usePlayers } from "../hooks/usePlayers.ts"; +import { useStones } from "../hooks/useStones.ts"; import { useTrees } from "../hooks/useTrees.ts"; import { Background } from "./background.tsx"; import { DealerBlock } from "./dealer.tsx"; import { PlayerBlock } from "./player.tsx"; import { RabbitBlock } from "./rabbit.tsx"; -import { Stone } from "./stone.tsx"; +import { StoneBlock } from "./stone.tsx"; import { TopBlock } from "./top.tsx"; import { TreeBlock } from "./tree.tsx"; import { Village } from "./village.tsx"; @@ -13,6 +14,7 @@ import { WolfBlock } from "./wolf.tsx"; export const Interface = () => { const players = usePlayers(); const trees = useTrees(); + const stones = useStones(); const showPlayers = players.map((player) => ( @@ -22,6 +24,10 @@ export const Interface = () => { )); + const showStones = stones?.map((stone) => ( + + )); + return ( <> @@ -29,8 +35,8 @@ export const Interface = () => {
{showPlayers} {showTrees} + {showStones} - diff --git a/apps/client/src/components/player-hands.tsx b/apps/client/src/components/player-hands.tsx index 1abdf4b1..0bf80e99 100644 --- a/apps/client/src/components/player-hands.tsx +++ b/apps/client/src/components/player-hands.tsx @@ -1,9 +1,12 @@ -import type { InventoryItem, Player } from "packages/api-sdk/src"; +import type { InventoryItem, ItemType } from "packages/api-sdk/src"; export const PlayerHandsBlock = ({ item, isVisible, -}: { item: InventoryItem; isVisible: boolean }) => { +}: { + item: InventoryItem | null; + isVisible: boolean; +}) => { if (!item || !item.amount || item.amount === 0) { return null; } @@ -20,8 +23,11 @@ export const PlayerHandsBlock = ({ ); }; -const ResourceIcon = ({ type }: { type: Player["handsItemType"] }) => { +const ResourceIcon = ({ type }: { type: ItemType }) => { if (type === "WOOD") { return ; } + if (type === "STONE") { + return ; + } }; diff --git a/apps/client/src/components/player-skill.tsx b/apps/client/src/components/player-skill.tsx index 65ba7abe..524a76ad 100644 --- a/apps/client/src/components/player-skill.tsx +++ b/apps/client/src/components/player-skill.tsx @@ -1,38 +1,42 @@ import { useEffect, useState } from "react"; -import { setPlayerWoodSkillUp } from "../../../../packages/api-sdk/src"; +import { + type Player, + type PlayerBusinessType, + type SkillType, + setPlayerSkillUp, +} from "../../../../packages/api-sdk/src"; export const PlayerSkillBlock = ({ - playerId, + player, isGrowing, - skill, - skillLvl, - skillNextLvl, }: { - playerId: string; + player: Player; isGrowing: boolean; - skill: number; - skillLvl: number; - skillNextLvl: number; }) => { + const skillType = getSkillTypeByBusinessType(player.businessType); + const skill = getSkill(player, skillType) ?? 0; + const skillLvl = getSkillLvl(player, skillType) ?? 0; + const skillNextLvl = getSkillNextLvl(player, skillType) ?? 0; + const [visibleSeconds, setVisibleSeconds] = useState(0); - const isHidden = visibleSeconds <= 0; + const isHidden = visibleSeconds <= 0 || !skillType; const [width, setWidth] = useState(skill / (skillNextLvl / 100)); useEffect(() => { - if (!isGrowing) { + if (!isGrowing || !skillType) { return; } // Up every 5 seconds const reload = setInterval(() => { - void setPlayerWoodSkillUp(playerId); + void setPlayerSkillUp(player.id, skillType); setWidth(skill / (skillNextLvl / 100)); setVisibleSeconds((prevState) => prevState + 7); }, 5000); return () => clearInterval(reload); - }, [isGrowing, playerId, skill, skillNextLvl]); + }, [isGrowing, skill, skillNextLvl, skillType, player.id]); // -1 every sec useEffect(() => { @@ -43,6 +47,8 @@ export const PlayerSkillBlock = ({ return () => clearInterval(timer); }, []); + const description = getSkillTypeDescription(skillType); + return (
{skillLvl}
@@ -53,8 +59,56 @@ export const PlayerSkillBlock = ({ />
- лесоруб + {description}
); }; + +function getSkillTypeDescription(type: SkillType | null) { + if (!type) return ""; + + if (type === "WOOD") return "лесоруб"; + if (type === "MINING") return "шахтер"; +} + +function getSkillTypeByBusinessType( + type: PlayerBusinessType, +): SkillType | null { + if (type === "CHOPPING") return "WOOD"; + if (type === "MINING") return "MINING"; + return null; +} + +function getSkill(player: Player, type: SkillType | null) { + if (!type) return; + + if (type === "WOOD") { + return player.skillWood; + } + if (type === "MINING") { + return player.skillMining; + } +} + +function getSkillLvl(player: Player, type: SkillType | null) { + if (!type) return; + + if (type === "WOOD") { + return player.skillWoodLvl; + } + if (type === "MINING") { + return player.skillMiningLvl; + } +} + +function getSkillNextLvl(player: Player, type: SkillType | null) { + if (!type) return; + + if (type === "WOOD") { + return player.skillWoodNextLvl; + } + if (type === "MINING") { + return player.skillMiningNextLvl; + } +} diff --git a/apps/client/src/components/player.tsx b/apps/client/src/components/player.tsx index 603b6ee7..dd4898f9 100644 --- a/apps/client/src/components/player.tsx +++ b/apps/client/src/components/player.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { + type InventoryItem, type Player, setPlayerIsOnTarget, } from "../../../../packages/api-sdk/src"; @@ -11,7 +12,10 @@ import { ToolBlock } from "./tool.tsx"; export const PlayerBlock = ({ player }: { player: Player }) => { const items = useInventory(player.id); - const itemsWood = items[0] ?? null; + const wood = items.find((item) => item.type === "WOOD") ?? null; + const stone = items.find((item) => item.type === "STONE") ?? null; + const axe = items.find((item) => item.type === "AXE") ?? null; + const pickaxe = items.find((item) => item.type === "PICKAXE") ?? null; const size = 100; const height = (size * 64) / 100; @@ -67,19 +71,32 @@ export const PlayerBlock = ({ player }: { player: Player }) => { needToMoveY, ]); - const isChopping = player.isBusy && !needToMove; + const isChopping = player.businessType === "CHOPPING" && !needToMove; + const isMining = player.businessType === "MINING" && !needToMove; + const isWorking = isChopping || isMining; + + const [showInHand, setShowInHand] = useState(null); + + useEffect(() => { + if (isChopping) { + setShowInHand(wood); + } + if (isMining) { + setShowInHand(stone); + } + }, [isChopping, wood, isMining, stone]); const [handVisibleSeconds, setHandVisibleSeconds] = useState(0); useEffect(() => { - isChopping && setHandVisibleSeconds(50); + isWorking && setHandVisibleSeconds(50); const timer = setInterval(() => { setHandVisibleSeconds((prevState) => (prevState > 0 ? prevState - 1 : 0)); }, 1000); return () => clearInterval(timer); - }, [isChopping]); + }, [isWorking]); return (
@@ -89,24 +106,21 @@ export const PlayerBlock = ({ player }: { player: Player }) => { src={"hero/hero_empty_64.png"} alt="" className="w-fit" - style={{ height: height }} + style={{ height }} /> - {isChopping && } + + {isChopping && !!axe && } + {isMining && !!pickaxe && } + 0} />
- +
diff --git a/apps/client/src/components/sound.tsx b/apps/client/src/components/sound.tsx index c5a588e0..2fc062d7 100644 --- a/apps/client/src/components/sound.tsx +++ b/apps/client/src/components/sound.tsx @@ -4,6 +4,7 @@ export const Sound = () => { const sound = new Howl({ src: ["/sound/forest1.mp3"], loop: true, + volume: 0.7, }); sound.play(); diff --git a/apps/client/src/components/stone.tsx b/apps/client/src/components/stone.tsx index 64542764..d70d0a36 100644 --- a/apps/client/src/components/stone.tsx +++ b/apps/client/src/components/stone.tsx @@ -1,25 +1,44 @@ -export const Stone = () => { - const stone = { - x: 1250, - y: 200, - }; +import { Howl } from "howler"; +import { useEffect, useMemo } from "react"; +import type { Stone } from "../../../../packages/api-sdk/src"; - const size = 100; +export const StoneBlock = ({ stone }: { stone: Stone }) => { + const size = stone.size; const height = (size * 128) / 100; - const isShaking = false; + const isShaking = stone.inProgress; + + const sound = useMemo( + () => + new Howl({ + src: ["/sound/chopping1.wav"], + loop: true, + }), + [], + ); + + useEffect(() => { + if (isShaking) { + sound.play(); + return; + } + + sound.stop(); + }, [isShaking, sound]); return (
diff --git a/apps/client/src/components/tree.tsx b/apps/client/src/components/tree.tsx index c6e66a3f..c70599f7 100644 --- a/apps/client/src/components/tree.tsx +++ b/apps/client/src/components/tree.tsx @@ -3,7 +3,6 @@ import { useEffect, useMemo } from "react"; import type { Tree } from "../../../../packages/api-sdk/src"; export const TreeBlock = ({ tree }: { tree: Tree }) => { - const type = tree.type; const size = tree.size; const height = (size * 128) / 100; @@ -36,7 +35,7 @@ export const TreeBlock = ({ tree }: { tree: Tree }) => { >
{
-

Фаза Альфа-1: Рубим деревья

+

Фаза Альфа-2: ???

diff --git a/apps/client/src/hooks/useStones.ts b/apps/client/src/hooks/useStones.ts new file mode 100644 index 00000000..48c6eed8 --- /dev/null +++ b/apps/client/src/hooks/useStones.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react"; +import { type Stone, getStones } from "../../../../packages/api-sdk/src"; + +export const useStones = () => { + const [stones, setStones] = useState([]); + + useEffect(() => { + getStones().then((res) => { + if (!res) return; + + setStones(res); + }); + + const reload = setInterval(() => { + getStones().then((res) => { + if (!res) return; + + setStones(res); + }); + }, 1000); + + return () => clearInterval(reload); + }, []); + + return stones; +}; diff --git a/apps/client/src/index.css b/apps/client/src/index.css index 1de49a25..9276c355 100644 --- a/apps/client/src/index.css +++ b/apps/client/src/index.css @@ -66,6 +66,21 @@ animation: animation-tree-chopping 2.6s infinite; } +@keyframes animation-stone-little-shake { + 0% { transform: skewY(0deg); } + 10% { transform: skewY(-1deg); } + 15% { transform: skewY(0); } + 50% { transform: skewY(0); } + 57% { transform: skewY(1deg); } + 65% { transform: skewY(-1deg); } + 73% { transform: skewY(0); } + 100% { transform: skewY(0deg); } +} + +.animation-stone-little-shake { + animation: animation-stone-little-shake 10s infinite; +} + @keyframes animation-rabbit-hop { 0% { transform: translate(0, 0) rotate(0deg); diff --git a/package-lock.json b/package-lock.json index 03ca0010..a9036621 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,29 +9,29 @@ "version": "0.0.1", "license": "MIT", "dependencies": { - "@hono/node-server": "1.8.2", + "@hono/node-server": "1.9.0", "@paralleldrive/cuid2": "2.2.2", "@prisma/client": "5.11.0", "@twurple/auth": "7.1.0", "@twurple/chat": "7.1.0", "@twurple/easy-bot": "7.1.0", - "hono": "4.1.3", + "hono": "4.1.4", "howler": "2.2.4", "react": "18.2.0", "react-dom": "18.2.0" }, "devDependencies": { - "@biomejs/biome": "1.6.2", + "@biomejs/biome": "1.6.3", "@tailwindcss/vite": "4.0.0-alpha.10", "@types/howler": "2.2.11", - "@types/react": "18.2.67", + "@types/react": "18.2.71", "@types/react-dom": "18.2.22", "@vitejs/plugin-react": "4.2.1", "prisma": "5.11.0", "tailwindcss": "4.0.0-alpha.10", "tsx": "4.7.1", "typescript": "5.4.3", - "vite": "5.2.2" + "vite": "5.2.6" } }, "node_modules/@ampproject/remapping": { @@ -375,9 +375,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.6.2.tgz", - "integrity": "sha512-vw6JhYnpLRRDaawI+d7NaQj17F7LSSJrgT03IQUETwRUG3Q1/a4ByJRphTVXPuhiTnaKVmUlEF3I5NSitcdD+g==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.6.3.tgz", + "integrity": "sha512-Xnp/TIpIcTnRA4LwerJuoGYQJEqwXtn5AL0U0OPXll/QGbAKmcUAfizU880xTwZRD4f53iceqODLDaD3wxYlIw==", "dev": true, "hasInstallScript": true, "bin": { @@ -391,20 +391,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "1.6.2", - "@biomejs/cli-darwin-x64": "1.6.2", - "@biomejs/cli-linux-arm64": "1.6.2", - "@biomejs/cli-linux-arm64-musl": "1.6.2", - "@biomejs/cli-linux-x64": "1.6.2", - "@biomejs/cli-linux-x64-musl": "1.6.2", - "@biomejs/cli-win32-arm64": "1.6.2", - "@biomejs/cli-win32-x64": "1.6.2" + "@biomejs/cli-darwin-arm64": "1.6.3", + "@biomejs/cli-darwin-x64": "1.6.3", + "@biomejs/cli-linux-arm64": "1.6.3", + "@biomejs/cli-linux-arm64-musl": "1.6.3", + "@biomejs/cli-linux-x64": "1.6.3", + "@biomejs/cli-linux-x64-musl": "1.6.3", + "@biomejs/cli-win32-arm64": "1.6.3", + "@biomejs/cli-win32-x64": "1.6.3" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.6.2.tgz", - "integrity": "sha512-2sGcNO1wDuQ6r97/SDaPzP3ehrCL7qHXpVggcB/OonbVBEamqIkN1tHsID/snnX3R2ax2QTarjb4bQ+1BpEWzA==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.6.3.tgz", + "integrity": "sha512-0E8PGu3/8HSkBJdtjno+niJE1ANS/12D7sPK65vw5lTBYmmaYwJdfclDp6XO0IAX7uVd3/YtXlsEua0SVrNt3Q==", "cpu": [ "arm64" ], @@ -418,9 +418,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.6.2.tgz", - "integrity": "sha512-qtHDXIHd7eRIHv41XdG6pt1dbw+qiD0OgLlJn5rvW20kSSFfLxW8yc4upcC1PzlruP1BQpKFec3r5rx1duTtzw==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.6.3.tgz", + "integrity": "sha512-UWu0We/aIRtWXgJKe6ygWt2xR0yXs64BwWqtZbfxBojRn3jgW8UdFAkV5yiUOX3TQlsV6BZH1EQaUAVsccUeeA==", "cpu": [ "x64" ], @@ -434,9 +434,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.6.2.tgz", - "integrity": "sha512-e1FJ59lx84QoqQgu1/uzAPIcYGcTkZY/m6Aj8ZHwi7KoWAE5xSogximFHNQ82lS4qkUfG7KaPTbYT6cGJjN9jQ==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.6.3.tgz", + "integrity": "sha512-wFVkQw38kOssfnkbpSh6ums5TaElw3RAt5i/VZwHmgR2nQgE0fHXLO7HwIE9VBkOEdbiIFq+2PxvFIHuJF3z3Q==", "cpu": [ "arm64" ], @@ -450,9 +450,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.6.2.tgz", - "integrity": "sha512-ej3Jj6O9KUSCJUWqVs+9aOo6IcRIALHaGFB20wnQTWtRMFhu1PluM48MrQtMKputgdk5/CopQ662IdKf1PeuEg==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.6.3.tgz", + "integrity": "sha512-AntGCSfLN1nPcQj4VOk3X2JgnDw07DaPC8BuBmRcsRmn+7GPSWLllVN5awIKlRPZEbGJtSnLkTiDc5Bxw8OiuA==", "cpu": [ "arm64" ], @@ -466,9 +466,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.6.2.tgz", - "integrity": "sha512-S6Wc5YX6aLDLMzwlDmiw/kjK62Ex+xzE432M5ge9q8tSCluGeHIzrenrJlu8E0xPG2FEipDaK4iqwnjS9O6e2A==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.6.3.tgz", + "integrity": "sha512-vyn8TQaTZg617hjqFitwGmb1St5XXvq6I3vmxU/QFalM74BryMSvYCrYWb2Yw/TkykdEwZTMGYp+SWHRb04fTg==", "cpu": [ "x64" ], @@ -482,9 +482,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.6.2.tgz", - "integrity": "sha512-uOVt4UBkFTFtdXgPX3QuSHRPVIvj07FP0P7A0UOP++idd0r9Bxyt5iIBaAORM3eQyGQqzCGPln1GuM6GalYKzg==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.6.3.tgz", + "integrity": "sha512-GelAvGsUwbxfFpKLG+7+dvDmbrfkGqn08sL8CMQrGnhjE1krAqHWiXQsjfmi0UMFdMsk7hbc4oSAP+1+mrXcHQ==", "cpu": [ "x64" ], @@ -498,9 +498,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.6.2.tgz", - "integrity": "sha512-5zuxNyvnKy7oLN7KLkqcYpsMKGubfMaeQ+RqnpFsmrofQAxpOo6EL/TyJvr8g533Z0a2/cQ/ALqnwl0mN3KQoQ==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.6.3.tgz", + "integrity": "sha512-Gx8N2Tixke6pAI1BniteCVZgUUmaFEDYosdWxoaCus15BZI/7RcBxhsRM0ZL/lC66StSQ8vHl8JBrrld1k570Q==", "cpu": [ "arm64" ], @@ -514,9 +514,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.6.2.tgz", - "integrity": "sha512-O3nf09/m3cb3/U3M+uO4l236iTZr4F4SmLNG3okKXPfyZqKLNnF6OjdTHOYEiNXnGEtlRuUeemqb3vht9JkXaw==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.6.3.tgz", + "integrity": "sha512-meungPJw64SqoR7LXY1wG7GC4+4wgpyThdFUMGXa6PCe0BLFOIOcZ9VMj9PstuczMPdgmt/BUMPsj25dK1VO8A==", "cpu": [ "x64" ], @@ -1017,9 +1017,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.8.2.tgz", - "integrity": "sha512-h8l2TBLCPHZBUrrkosZ6L5CpBLj6zdESyF4B+zngiCDF7aZFQJ0alVbLx7jn8PCVi9EyoFf8a4hOZFi1tD95EA==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.9.0.tgz", + "integrity": "sha512-oJjk7WXBlENeHhWiMqSyxPIZ3Kmf5ZYxqdlcSIXyN8Rn50bNJsPl99G4POBS03Jxh56FdfRJ0SEnC8mAVIiavQ==", "engines": { "node": ">=18.14.1" } @@ -1690,9 +1690,9 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.2.67", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.67.tgz", - "integrity": "sha512-vkIE2vTIMHQ/xL0rgmuoECBCkZFZeHr49HeWSc24AptMbNRo7pwSBvj73rlJJs9fGKj0koS+V7kQB1jHS0uCgw==", + "version": "18.2.71", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.71.tgz", + "integrity": "sha512-PxEsB9OjmQeYGffoWnYAd/r5FiJuUw2niFQHPc2v2idwh8wGPkkYzOHuinNJJY6NZqfoTCiOIizDOz38gYNsyw==", "dev": true, "dependencies": { "@types/prop-types": "*", @@ -1992,9 +1992,9 @@ } }, "node_modules/hono": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.1.3.tgz", - "integrity": "sha512-V0I6qCw0gn2MA4LLtyXe6oD3/7ToeQf5Zv98o7uSuLuViQgWHJeYoYrZ4NbXhOtg4SaZjNJJm1+XuFB3LN+j6A==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.1.4.tgz", + "integrity": "sha512-JcdAKRBHjWO5OEkEW6Lv5NUr4QLl4InshCIUnHwGY7hymCxmV1Ji/eAAr1hclQixWc3I7ZljMHXwIedNWRAcqA==", "engines": { "node": ">=16.0.0" } @@ -2596,9 +2596,9 @@ } }, "node_modules/vite": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.2.tgz", - "integrity": "sha512-FWZbz0oSdLq5snUI0b6sULbz58iXFXdvkZfZWR/F0ZJuKTSPO7v72QPXt6KqYeMFb0yytNp6kZosxJ96Nr/wDQ==", + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.6.tgz", + "integrity": "sha512-FPtnxFlSIKYjZ2eosBQamz4CbyrTizbZ3hnGJlh/wMtCrlp1Hah6AzBLjGI5I2urTfNnpovpHdrL6YRuBOPnCA==", "dev": true, "dependencies": { "esbuild": "^0.20.1", diff --git a/package.json b/package.json index 104c7ee2..0e4a78ee 100644 --- a/package.json +++ b/package.json @@ -20,28 +20,28 @@ }, "homepage": "https://github.com/hmbanan666/royal-madness-twitch-game#readme", "dependencies": { - "@hono/node-server": "1.8.2", + "@hono/node-server": "1.9.0", "@paralleldrive/cuid2": "2.2.2", "@prisma/client": "5.11.0", "@twurple/auth": "7.1.0", "@twurple/chat": "7.1.0", "@twurple/easy-bot": "7.1.0", - "hono": "4.1.3", + "hono": "4.1.4", "howler": "2.2.4", "react": "18.2.0", "react-dom": "18.2.0" }, "devDependencies": { - "@biomejs/biome": "1.6.2", + "@biomejs/biome": "1.6.3", "@tailwindcss/vite": "4.0.0-alpha.10", "@types/howler": "2.2.11", - "@types/react": "18.2.67", + "@types/react": "18.2.71", "@types/react-dom": "18.2.22", "@vitejs/plugin-react": "4.2.1", "prisma": "5.11.0", "tailwindcss": "4.0.0-alpha.10", "tsx": "4.7.1", "typescript": "5.4.3", - "vite": "5.2.2" + "vite": "5.2.6" } } diff --git a/packages/api-sdk/src/lib/client.ts b/packages/api-sdk/src/lib/client.ts index 7351adb0..198dad47 100644 --- a/packages/api-sdk/src/lib/client.ts +++ b/packages/api-sdk/src/lib/client.ts @@ -1,4 +1,12 @@ -import type { Command, InventoryItem, Player, Tree, Village } from "./types"; +import type { + Command, + InventoryItem, + Player, + SkillType, + Stone, + Tree, + Village, +} from "./types"; export async function getVillage() { try { @@ -67,6 +75,15 @@ export const updateTree = async ({ return res.json(); }; +export async function getStones() { + try { + const res = await fetch("http://localhost:4001/stones"); + return (await res.json()) as Stone[]; + } catch (err) { + return null; + } +} + export const createPlayer = async ({ id, userName, @@ -89,10 +106,10 @@ export const setPlayerIsOnTarget = async (id: string) => { return res.json(); }; -export const setPlayerWoodSkillUp = async (id: string) => { - const res = await fetch(`http://localhost:4001/players/${id}/skill/wood`, { +export const setPlayerSkillUp = async (id: string, type: SkillType) => { + const res = await fetch(`http://localhost:4001/players/${id}/skill`, { method: "POST", - body: JSON.stringify({}), + body: JSON.stringify({ type }), }); return res.json(); }; diff --git a/apps/api/src/lib/helpers.ts b/packages/api-sdk/src/lib/random.ts similarity index 52% rename from apps/api/src/lib/helpers.ts rename to packages/api-sdk/src/lib/random.ts index ae5ce8e3..50f9bcc8 100644 --- a/apps/api/src/lib/helpers.ts +++ b/packages/api-sdk/src/lib/random.ts @@ -1,3 +1,3 @@ export function getRandomInRange(min: number, max: number) { - return Math.random() * (max - min) + min; + return Math.floor(Math.random() * (max - min) + min); } diff --git a/packages/api-sdk/src/lib/types.ts b/packages/api-sdk/src/lib/types.ts index 1ef41137..b7f5d858 100644 --- a/packages/api-sdk/src/lib/types.ts +++ b/packages/api-sdk/src/lib/types.ts @@ -16,6 +16,8 @@ export interface Command { player?: Player; } +export type TargetType = "TREE" | "STONE"; + export interface Player { id: string; createdAt: Date; @@ -29,6 +31,7 @@ export interface Player { userName: string; twitchId: string; isBusy: boolean; + businessType: PlayerBusinessType; colorIndex: number; handsItemType: null | ItemType; handsItemAmount: number; @@ -37,8 +40,15 @@ export interface Player { skillWoodLvl: number; skillWoodNextLvl: number; skillWood: number; + skillMiningLvl: number; + skillMiningNextLvl: number; + skillMining: number; } +export type PlayerBusinessType = null | "RUNNING" | "CHOPPING" | "MINING"; + +export type SkillType = "WOOD" | "MINING"; + export type ItemType = "WOOD" | "STONE" | "AXE" | "PICKAXE"; export interface InventoryItem { @@ -62,5 +72,19 @@ export interface Tree { isReserved: boolean; inProgress: boolean; progressFinishAt: Date; - type: string; + type: "1" | "2" | "3"; +} + +export interface Stone { + id: string; + createdAt: Date; + updatedAt: Date; + x: number; + y: number; + size: number; + resource: number; + isReserved: boolean; + inProgress: boolean; + progressFinishAt: Date; + type: "1"; } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9d7d3f01..fc228d6f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,31 +14,36 @@ model Village { globalTarget Int? globalTargetSuccess Int? wood Int @default(0) + stone Int @default(0) } model Player { - id String @id - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) - lastActionAt DateTime @default(now()) - twitchId String - userName String - x Int @default(0) - y Int @default(0) - targetX Int? @default(0) - targetY Int? @default(0) - targetId String? - isBusy Boolean @default(false) - colorIndex Int @default(0) - handsItemType String? - handsItemAmount Int @default(0) - coins Int @default(0) - reputation Int @default(0) - skillWoodLvl Int @default(0) - skillWoodNextLvl Int @default(20) - skillWood Int @default(0) - commands Command[] - items InventoryItem[] + id String @id + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) + lastActionAt DateTime @default(now()) + twitchId String + userName String + x Int @default(0) + y Int @default(0) + targetX Int? @default(0) + targetY Int? @default(0) + targetId String? + isBusy Boolean @default(false) + businessType String? + colorIndex Int @default(0) + handsItemType String? + handsItemAmount Int @default(0) + coins Int @default(0) + reputation Int @default(0) + skillWoodLvl Int @default(0) + skillWoodNextLvl Int @default(20) + skillWood Int @default(0) + skillMiningLvl Int @default(0) + skillMiningNextLvl Int @default(20) + skillMining Int @default(0) + commands Command[] + items InventoryItem[] } model InventoryItem { @@ -75,3 +80,17 @@ model Tree { progressFinishAt DateTime @default(now()) type String } + +model Stone { + id String @id + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) + x Int @default(0) + y Int @default(0) + size Int @default(0) + resource Int + isReserved Boolean @default(false) + inProgress Boolean @default(false) + progressFinishAt DateTime @default(now()) + type String +}