added cases

This commit is contained in:
MasterGordon 2024-10-15 21:06:36 +02:00
parent 5e30cee6ca
commit 157e4768f3
19 changed files with 419 additions and 83 deletions

View File

@ -11,11 +11,14 @@ import crypto from "crypto";
import { resetSessionUser, setSessionUser } from "../router";
import { userSettings } from "../../shared/user-settings";
import { UnauthorizedError } from "../errors/UnauthorizedError";
import { getGems } from "../repositories/gemsRepository";
import { getGems, removeGems } from "../repositories/gemsRepository";
import {
getCollection,
upsertCollection,
} from "../repositories/collectionRepository";
import { getWeight, lootboxes } from "../../shared/lootboxes";
import { weightedPickRandom } from "../../shared/utils";
import { emit } from "../events";
const secret = process.env.SECRET!;
@ -133,4 +136,35 @@ export const userController = createController({
await upsertCollection(db, user, collection);
},
),
openLootbox: createEndpoint(
z.object({
id: z.string(),
}),
async ({ id }, { db, user }) => {
if (!user) throw new UnauthorizedError("Unauthorized");
const collection = await getCollection(db, user);
const lootbox = lootboxes.find((l) => l.id === id);
if (!lootbox) {
throw new Error("Lootbox not found");
}
removeGems(db, user, lootbox.price);
const result = weightedPickRandom(lootbox.items, (i) =>
getWeight(i.rarity),
);
console.log(result);
collection.entries.push({
id: result.id,
aquired: Date.now(),
selected: false,
});
await upsertCollection(db, user, collection);
emit({
type: "lootboxPurchased",
user,
lootbox: lootbox.id,
reward: result.id,
rarity: result.rarity,
});
},
),
});

BIN
bun.lockb

Binary file not shown.

View File

@ -25,6 +25,10 @@
"@radix-ui/react-switch": "^1.1.1",
"@tanstack/react-query": "^5.59.11",
"@tanstack/react-query-devtools": "^5.59.11",
"@tsparticles/engine": "^3.5.0",
"@tsparticles/preset-sea-anemone": "^3.1.0",
"@tsparticles/react": "^3.0.0",
"@tsparticles/slim": "^3.5.0",
"@uidotdev/usehooks": "^2.4.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",

View File

@ -1,3 +1,4 @@
import type { Rarity } from "../shared/lootboxes";
export type EventType = "new" | "finished" | "updateGame" | "updateStage";
export type Events =
@ -25,4 +26,11 @@ export type Events =
type: "gemsRewarded";
stage: number;
gems: number;
}
| {
type: "lootboxPurchased";
lootbox: string;
reward: string;
user: string;
rarity: Rarity;
};

View File

@ -1,4 +1,5 @@
import type { themes } from "../src/themes";
import lootbox1 from "../src/assets/illustrations/lootbox1.png?w=360&inline";
export const rarities = [
{
@ -23,11 +24,18 @@ export const rarities = [
},
] as const;
type Rarity = (typeof rarities)[number]["id"];
export const getWeight = (rarity: Rarity) =>
rarities.find((r) => r.id === rarity)?.weight ?? 0;
export type Rarity = (typeof rarities)[number]["id"];
type ThemeId = (typeof themes)[number]["id"];
interface Lootbox {
name: string;
id: string;
price: number;
priceText: string;
image: string;
items: {
id: ThemeId;
rarity: Rarity;
@ -36,6 +44,10 @@ interface Lootbox {
export const series1: Lootbox = {
name: "Series 1",
id: "series1",
price: 5000,
priceText: "5.000",
image: lootbox1,
items: [
{
id: "basic",
@ -195,3 +207,5 @@ export const series1: Lootbox = {
},
],
};
export const lootboxes = [series1];

View File

@ -3,11 +3,6 @@ export const pickRandom = <T>(arr: T[]) => {
return arr[index];
};
interface WeightedEntry<T> {
weight: number;
value: T;
}
export const hashStr = (str: string) => {
return [...str].reduce(
(hash, c) => (Math.imul(31, hash) + c.charCodeAt(0)) | 0,
@ -16,18 +11,19 @@ export const hashStr = (str: string) => {
};
export const weightedPickRandom = <T>(
arr: WeightedEntry<T>[],
arr: T[],
getWeight: (item: T) => number = () => 1,
getRandom: (tw: number) => number = (totalWeight) =>
Math.random() * totalWeight,
): T => {
const totalWeight = arr.reduce((acc, cur) => acc + cur.weight, 0);
const totalWeight = arr.reduce((acc, cur) => acc + getWeight(cur), 0);
const random = getRandom(totalWeight);
let currentWeight = 0;
for (const entry of arr) {
currentWeight += entry.weight;
currentWeight += getWeight(entry);
if (random < currentWeight) {
return entry.value;
return entry;
}
}
return arr[arr.length - 1].value;
return arr[arr.length - 1];
};

View File

@ -90,7 +90,7 @@ const Shell: React.FC<PropsWithChildren> = ({ children }) => {
</NavLink>
<Hr />
<Feed />
<Hr />
{/* <Hr /> */}
<div className="grow" />
<NavLink href="https://github.com/MasterGordon/minesweeper" external>
<GitBranch />

View File

@ -10,3 +10,8 @@ export const loginTokenAtom = atomWithStorage<string | undefined>(
export const cursorXAtom = atom(0);
export const cursorYAtom = atom(0);
export const feedItemsAtom = atom<FeedItem[]>([]);
interface LootboxResult {
result: string;
lootbox: string;
}
export const lootboxResultAtom = atom<LootboxResult | undefined>();

View File

@ -136,6 +136,17 @@ const Board: React.FC<BoardProps> = (props) => {
});
}
}, [ref]);
useEffect(() => {
const listener = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setZenMode(false);
}
};
document.addEventListener("keydown", listener);
return () => {
document.removeEventListener("keydown", listener);
};
}, []);
return (
<div className="flex flex-col w-full">

View File

@ -28,24 +28,27 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
overlayClassName?: string;
}
>(({ className, children, overlayClassName, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg border-white/20 text-white/90 bg-black",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
<DialogOverlay className={overlayClassName}>
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg border-white/20 text-white/90 bg-black",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogOverlay>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;

View File

@ -1,6 +1,6 @@
import { AnimatePresence, motion } from "framer-motion";
import { useAtom } from "jotai";
import { feedItemsAtom } from "../../atoms";
import { feedItemsAtom, lootboxResultAtom } from "../../atoms";
import FeedItemElement from "./FeedItem";
import { useEffect } from "react";
import { addMessageListener, removeMessageListener } from "../../wsClient";
@ -9,6 +9,7 @@ import { useWSQuery } from "../../hooks";
const Feed: React.FC = () => {
const [items, setItems] = useAtom(feedItemsAtom);
const [, setLootboxResult] = useAtom(lootboxResultAtom);
const { data: user } = useWSQuery("user.getSelf", null);
useEffect(() => {
@ -19,7 +20,7 @@ const Feed: React.FC = () => {
}, [setItems]);
useEffect(() => {
const listener = (event: MessageEvent) => {
const listener = async (event: MessageEvent) => {
const data = JSON.parse(event.data) as Events;
const newItems = [...items];
if (data.type === "new" && data.user !== user) {
@ -49,11 +50,29 @@ const Feed: React.FC = () => {
gems: data.gems,
});
}
if (data.type === "lootboxPurchased" && data.user !== user) {
newItems.push({
type: "lootboxPurchased",
id: crypto.randomUUID(),
decay: Date.now() + 20_000,
lootbox: data.lootbox,
user: data.user,
reward: data.reward,
rarity: data.rarity,
});
await new Promise((res) => setTimeout(res, 2_000));
}
if (data.type === "lootboxPurchased" && data.user === user) {
setLootboxResult({
lootbox: data.lootbox,
result: data.reward,
});
}
setItems(newItems);
};
addMessageListener(listener);
return () => removeMessageListener(listener);
}, [items, setItems, user]);
}, [items, setItems, setLootboxResult, user]);
return (
<div className="flex flex-col gap-4 w-full items-start h-[30%] overflow-y-hidden">

View File

@ -2,6 +2,9 @@ import { motion } from "framer-motion";
import { PropsWithChildren } from "react";
import { formatTimeSpan } from "../../../shared/time";
import GemsIcon from "../GemIcon";
import { Rarity as RarityType } from "../../../shared/lootboxes";
import { Rarity } from "../Rarity";
import { themes } from "../../themes";
interface BaseFeedItem {
decay: number;
@ -26,7 +29,19 @@ interface GemsEarnedItem extends BaseFeedItem {
stage: number;
}
export type FeedItem = GameStartedItem | GameFinishedItem | GemsEarnedItem;
interface LootboxPurchasedItem extends BaseFeedItem {
type: "lootboxPurchased";
lootbox: string;
user: string;
reward: string;
rarity: RarityType;
}
export type FeedItem =
| GameStartedItem
| GameFinishedItem
| GemsEarnedItem
| LootboxPurchasedItem;
const FeedItemWrapper: React.FC<PropsWithChildren> = ({ children }) => {
return (
@ -59,6 +74,15 @@ const FeedItemElement: React.FC<{ item: FeedItem }> = ({ item }) => {
You got {item.gems} <GemsIcon /> for <span>stage {item.stage}</span>
</FeedItemWrapper>
);
case "lootboxPurchased":
return (
<FeedItemWrapper>
{item.user} got{" "}
<Rarity rarity={item.rarity}>
{themes.find((i) => i.id == item.reward)?.name}
</Rarity>
</FeedItemWrapper>
);
}
};

View File

@ -27,20 +27,20 @@ const LeaderboardButton = ({
<DialogContent>
<DialogHeader>
<DialogTitle>Leaderboard</DialogTitle>
<div className="grid grid-cols-[min-content_2fr_1fr] grid-border-b">
{leaderboard?.map((_, i) => (
<Fragment key={i}>
<div className="p-4 text-white/80 text-right">{i + 1}.</div>
<div className="p-4 text-white/90">
{leaderboard?.[i]?.user ?? "No User"}
</div>
<div className="p-4 text-white/90">
Stage {leaderboard?.[i]?.stage ?? 0}
</div>
</Fragment>
))}
</div>
</DialogHeader>
<div className="grid grid-cols-[min-content_2fr_1fr] grid-border-b">
{leaderboard?.map((_, i) => (
<Fragment key={i}>
<div className="p-4 text-white/80 text-right">{i + 1}.</div>
<div className="p-4 text-white/90">
{leaderboard?.[i]?.user ?? "No User"}
</div>
<div className="p-4 text-white/90">
Stage {leaderboard?.[i]?.stage ?? 0}
</div>
</Fragment>
))}
</div>
</DialogContent>
</Dialog>
);

22
src/components/Rarity.tsx Normal file
View File

@ -0,0 +1,22 @@
import { PropsWithChildren } from "react";
import { cn } from "../lib/utils";
import { rarities } from "../../shared/lootboxes";
export const Rarity: React.FC<PropsWithChildren<{ rarity: string }>> = ({
rarity,
children,
}) => {
return (
<span
className={cn(
"font-bold",
rarity == "common" && "text-common",
rarity == "uncommon" && "text-uncommon",
rarity == "rare" && "text-rare",
rarity == "legendary" && "text-legendary",
)}
>
{children ?? rarities.find((r) => r.id === rarity)?.name}
</span>
);
};

View File

@ -4,6 +4,10 @@
--color-primary: #D9AFD9;
--color-input: color-mix(in srgb, var(--color-white, #fff) 20%, transparent);
--color-background: black;
--color-common: var(--color-sky-500);
--color-uncommon: var(--color-green-400);
--color-rare: var(--color-red-500);
--color-legendary: var(--color-amber-500);
--bg-brand: -webkit-linear-gradient(225deg, rgb(251, 175, 21), rgb(251, 21, 242),
rgb(21, 198, 251)) 0% 0% / 100% 300%;
--bg-secondary: linear-gradient(90deg, #D9AFD9 0%, #97D9E1 100%) 0% 0% / 100% 300%;

View File

@ -28,9 +28,8 @@ const Collection = () => {
const selected = collection?.entries.some(
(e) => e.id === theme.id && e.selected,
);
const owned = collection?.entries.some(
(e) => e.id === theme.id && e.selected,
);
const owned = collection?.entries.some((e) => e.id === theme.id);
if (!owned) return null;
return (
<div key={theme.id}>
<div className="flex gap-4 justify-between">
@ -40,32 +39,36 @@ const Collection = () => {
<span className="text-white/70 text-sm"> (Owned)</span>
)}
</h3>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<Ellipsis className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={-12}>
<DropdownMenuItem
onClick={() => {
mutateSelected
.mutateAsync({ id: theme.id })
.then(() => refetch());
}}
>
Select
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
mutateShuffle.mutateAsync({ id: theme.id })
}
>
{" "}
Add to shuffle
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{owned && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<Ellipsis className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={-12}>
<DropdownMenuItem
onClick={() => {
mutateSelected
.mutateAsync({ id: theme.id })
.then(() => refetch());
}}
>
Select
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
mutateShuffle
.mutateAsync({ id: theme.id })
.then(() => refetch())
}
>
{" "}
Add to shuffle
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<Board
game={testBoard(theme.id)}

View File

@ -1,13 +1,197 @@
import { Fragment } from "react/jsx-runtime";
import { lootboxes } from "../../../shared/lootboxes";
import { Button } from "../../components/Button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../components/Dialog";
import GemsIcon from "../../components/GemIcon";
import { themes } from "../../themes";
import { useWSMutation } from "../../hooks";
import { Rarity } from "../../components/Rarity";
import { lootboxResultAtom } from "../../atoms";
import { useAtom } from "jotai";
import { useEffect } from "react";
import Particles, { initParticlesEngine } from "@tsparticles/react";
import { loadSlim } from "@tsparticles/slim";
import { loadSeaAnemonePreset } from "@tsparticles/preset-sea-anemone";
import { motion } from "framer-motion";
const Store = () => {
const openLootbox = useWSMutation("user.openLootbox");
const [lootboxResult, setLootboxResult] = useAtom(lootboxResultAtom);
const currentLootbox = lootboxes.find((l) => l.id === lootboxResult?.lootbox);
// this should be run only once per application lifetime
useEffect(() => {
initParticlesEngine(async (engine) => {
// you can initiate the tsParticles instance (engine) here, adding custom shapes or presets
// this loads the tsparticles package bundle, it's the easiest method for getting everything ready
// starting from v2 you can add only the features you need reducing the bundle size
//await loadAll(engine);
//await loadFull(engine);
await loadSlim(engine);
await loadSeaAnemonePreset(engine);
//await loadBasic(engine);
});
}, []);
return (
<div className="flex flex-col gap-4 w-full">
<h2 className="text-white/90 text-xl">Store</h2>
<div className="flex flex-row gap-y-6 gap-x-8 items-center w-full flex-wrap justify-center mb-10">
<div className="flex flex-col gap-4 justify-between">
<h3 className="text-white/90 text-lg">Lootboxes</h3>
<>
<Dialog
open={!!currentLootbox}
onOpenChange={() => setLootboxResult(undefined)}
>
<DialogContent className="relative h-96 w-[1000px]">
<DialogHeader>
<DialogTitle className="z-10">
Opening {currentLootbox?.name}
</DialogTitle>
</DialogHeader>
<div className="absolute flex h-full w-full flex-col items-center justify-center">
<Particles
className="rounded-md"
id="tsparticles"
options={{
id: "tsparticles",
preset: "seaAnemone",
}}
/>
<motion.div
className="text-white/90 text-6xl"
initial={{ scale: 0, rotate: 0, opacity: 0 }}
animate={{
scale: [0, 0.2, 0.5, 0.75, 1.5, 1.2, 1],
rotate: 360 * 2,
opacity: 1,
}}
transition={{ type: "spring", duration: 5 }}
>
<Rarity
// @ts-expect-error Don't care
rarity={
currentLootbox?.items.find(
(i) => i.id == lootboxResult?.result,
)?.rarity
}
>
{themes.find((t) => t.id === lootboxResult?.result)?.name}
</Rarity>
</motion.div>
</div>
</DialogContent>
</Dialog>
<div className="flex flex-col gap-4 w-full">
<h2 className="text-white/90 text-xl">Store</h2>
<div className="flex flex-row gap-y-6 gap-x-8 items-center w-full flex-wrap justify-center mb-10">
<div className="flex flex-row gap-y-6 gap-x-8 items-center w-full flex-wrap justify-center mb-10">
{lootboxes.map((lootbox) => (
<div
key={lootbox.id}
className="border-white/10 border-1 rounded-md p-4"
>
<div className="flex gap-4 justify-between">
<h3 className="text-white/90 text-lg">{lootbox.name}</h3>
<Dialog>
<DialogTrigger>
<Button variant="default" size="sm">
Preview
</Button>
</DialogTrigger>
<DialogContent
className="max-w-4xl m-4 relative top-4 translate-y-0"
overlayClassName="overflow-y-auto"
>
<DialogHeader>
<DialogTitle>{lootbox.name}</DialogTitle>
</DialogHeader>
<div className="flex gap-4 flex-wrap flex-row">
<div className="flex flex-col gap-4 basis-md">
<img
src={lootbox.image}
className="max-w-[360px] w-[min(360px,100%)]"
/>
<div className="text-white/70 text-base">
Introducing <i>{lootbox.name}</i>, the first-ever
lootbox for Minesweeper, packed with a variety of
stylish skins to customize your game like never
before! With <i>{lootbox.name}</i>, every click
becomes a statement, as you explore new looks for
your mines, tiles, and flags. Transform your
Minesweeper grid with these visually captivating
designs and make each puzzle feel fresh and
exciting.
</div>
<h3 className="text-white/90 text-lg">
What&apos;s Inside?
</h3>
<ul className="text-white/70 text-base flex flex-col gap-2">
<li>
<b>Mine Skins:</b> Swap out the classic mine icons
for creative alternatives like skulls, treasure
chests, or high-tech drones.
</li>
<li>
<b>Tile Skins:</b> Replace the plain tiles with
vibrant patterns such as tropical beaches,
medieval bricks, or sleek metallic plates.
</li>
<li>
<b>Flag Skins:</b> Mark suspected mines in style
with custom flag designs including pirate flags,
futuristic beacons, or glowing crystals.
</li>
<li>
<b>Backgrounds:</b> Enhance your gameplay
experience with unique backgrounds, from serene
mountain landscapes to bustling cityscapes or
outer space vistas.
</li>
<div className="text-white/70 text-base">
Step up your Minesweeper game and express your
personality with <i>{lootbox.name}</i>. Unlock new
looks and turn every game into a visual
masterpiece!
</div>
</ul>
</div>
<div className="flex justify-center grow">
<div className="grid grid-cols-2 gap-4">
<div>Skin</div>
<div>Rarity</div>
{lootbox.items.map((item) => (
<Fragment key={item.id}>
<div className="text-white/90">
{themes.find((t) => t.id === item.id)?.name}
</div>
<Rarity rarity={item.rarity} />
</Fragment>
))}
</div>
</div>
</div>
</DialogContent>
</Dialog>
</div>
<img src={lootbox.image} className="w-[360px]" />
<Button
variant="outline"
size="default"
className="mx-auto items-center"
onClick={() => openLootbox.mutateAsync({ id: lootbox.id })}
>
Buy for <b>{lootbox.priceText}</b> <GemsIcon />
</Button>
</div>
))}
</div>
</div>
</div>
</div>
</>
);
};

View File

@ -56,9 +56,11 @@ const createWSClient = () => {
): Promise<Awaited<ReturnType<Routes[TController][TAction]["handler"]>>> => {
if (ws.readyState !== WebSocket.OPEN) {
await new Promise<void>((res) => {
ws.onopen = () => {
const onOpen = () => {
ws.removeEventListener("open", onOpen);
res();
};
ws.addEventListener("open", onOpen);
});
}
const requestId = crypto.randomUUID();

View File

@ -5,5 +5,8 @@ import { imagetools } from "vite-imagetools";
// https://vitejs.dev/config/
export default defineConfig({
server: {
port: 3003,
},
plugins: [react(), tailwindcss(), imagetools()],
});