diff --git a/app/drizzle/constants.ts b/app/drizzle/constants.ts index ba75775e2..d200da130 100644 --- a/app/drizzle/constants.ts +++ b/app/drizzle/constants.ts @@ -155,6 +155,19 @@ export const UserRanks = [ ] as const; export type UserRank = (typeof UserRanks)[number]; +export const RankedDivisions = [ + { key: "UNRANKED", name: "Unranked", rankedLp: 0 }, + { key: "WOOD", name: "Wood", rankedLp: 150 }, + { key: "ADEPT", name: "Adept", rankedLp: 300 }, + { key: "MASTER", name: "Master", rankedLp: 600 }, + { key: "LEGEND", name: "Legend", rankedLp: 900 }, + { key: "SANNIN", name: "Sannin", rankedLp: Infinity }, +] as const; + +// Type Definitions +export type RankedDivision = (typeof RankedDivisions)[number]["name"]; +export type RankedDivisionEntry = (typeof RankedDivisions)[number]; + export const ItemTypes = [ "WEAPON", "CONSUMABLE", @@ -260,6 +273,7 @@ export const BattleTypes = [ "QUEST", "VILLAGE_PROTECTOR", "TRAINING", + "RANKED", ] as const; export type BattleType = (typeof BattleTypes)[number]; @@ -268,6 +282,7 @@ export const PvpBattleTypes: BattleType[] = [ "SPARRING", "CLAN_BATTLE", "TOURNAMENT", + "RANKED", ]; export const TournamentTypes = ["CLAN"] as const; @@ -311,6 +326,9 @@ export const TrainingSpeeds = ["15min", "1hr", "4hrs", "8hrs"] as const; export type TrainingSpeed = (typeof TrainingSpeeds)[number]; export const JUTSU_MAX_RESIDUAL_EQUIPPED = 4; +export const JUTSU_MAX_SHIELD_EQUIPPED = 2; +export const JUTSU_MAX_GROUND_EQUIPPED = 1; +export const JUTSU_MAX_MOVEPREVENT_EQUIPPED = 1; export const UserAssociations = ["MARRIAGE", "DIVORCED"] as const; diff --git a/app/drizzle/migrations/0201_dancing_walrus.sql b/app/drizzle/migrations/0201_dancing_walrus.sql new file mode 100644 index 000000000..9517479eb --- /dev/null +++ b/app/drizzle/migrations/0201_dancing_walrus.sql @@ -0,0 +1,64 @@ +ALTER TABLE `UserData` ADD `rankedLp` INT DEFAULT 150 NOT NULL; +ALTER TABLE `BattleHistory` MODIFY COLUMN `battleType` ENUM('ARENA', 'COMBAT', 'SPARRING', 'KAGE_AI', 'KAGE_PVP', 'CLAN_CHALLENGE', 'CLAN_BATTLE', 'TOURNAMENT', 'QUEST', 'VILLAGE_PROTECTOR', 'TRAINING', 'RANKED') NOT NULL; +CREATE TABLE RankedPvpQueue ( + id varchar(191) NOT NULL, + userId varchar(191) NOT NULL, + rankedLp int NOT NULL, + createdAt datetime(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP(3)), + CONSTRAINT RankedPvpQueue_id PRIMARY KEY(id) +); +CREATE INDEX RankedPvpQueue_userId_idx ON RankedPvpQueue (userId); +CREATE INDEX RankedPvpQueue_rankedLp_idx ON RankedPvpQueue (rankedLp); +ALTER TABLE RankedPvpQueue ADD COLUMN queueStartTime timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP; +ALTER TABLE Battle MODIFY COLUMN battleType ENUM('ARENA', 'COMBAT', 'SPARRING', 'KAGE_AI', 'KAGE_PVP', 'CLAN_CHALLENGE', 'CLAN_BATTLE', 'TOURNAMENT', 'QUEST', 'VILLAGE_PROTECTOR', 'TRAINING', 'RANKED') NOT NULL; +ALTER TABLE DataBattleAction MODIFY COLUMN battleType ENUM('ARENA', 'COMBAT', 'SPARRING', 'KAGE_AI', 'KAGE_PVP', 'CLAN_CHALLENGE', 'CLAN_BATTLE', 'TOURNAMENT', 'QUEST', 'VILLAGE_PROTECTOR', 'TRAINING', 'RANKED') NOT NULL; +ALTER TABLE UserData ADD COLUMN rankedBattles int NOT NULL DEFAULT 0, ADD COLUMN rankedWins int NOT NULL DEFAULT 0, ADD COLUMN rankedStreak int NOT NULL DEFAULT 0; +-- Create the ranked jutsu loadout table +CREATE TABLE `RankedJutsuLoadout` ( + `id` varchar(191) NOT NULL, + `userId` varchar(191) NOT NULL, + `jutsuIds` json NOT NULL, + `createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + INDEX `RankedJutsuLoadout_userId_idx` (`userId`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- Add the ranked jutsu loadout reference to UserData +ALTER TABLE `UserData` +ADD COLUMN `rankedJutsuLoadout` varchar(191), +ADD INDEX `UserData_rankedJutsuLoadout_idx` (`rankedJutsuLoadout`); +ALTER TABLE `UserJutsu` ADD COLUMN `rankedEquipped` tinyint NOT NULL DEFAULT 0; +CREATE INDEX `Jutsu_rankedEquipped_idx` ON `UserJutsu` (`rankedEquipped`); + +CREATE TABLE "rankedUserJutsu" ( + "id" text PRIMARY KEY, + "userId" text NOT NULL REFERENCES "userData"("userId"), + "jutsuId" text NOT NULL REFERENCES "jutsu"("id"), + "level" integer NOT NULL DEFAULT 1, + "experience" integer NOT NULL DEFAULT 0, + "equipped" integer NOT NULL DEFAULT 0, + "createdAt" timestamp NOT NULL DEFAULT now(), + "updatedAt" timestamp NOT NULL DEFAULT now() +); + +-- Create an index on userId for faster lookups +CREATE INDEX "rankedUserJutsu_userId_idx" ON "rankedUserJutsu"("userId"); + +ALTER TABLE UserJutsu ADD COLUMN rankedEquipped tinyint NOT NULL DEFAULT 0; +CREATE INDEX Jutsu_rankedEquipped_idx ON UserJutsu (rankedEquipped); + +-- Create a unique constraint to prevent duplicate jutsu entries for the same user +CREATE UNIQUE INDEX "rankedUserJutsu_userId_jutsuId_unique" ON "rankedUserJutsu"("userId", "jutsuId"); +CREATE TABLE RankedUserJutsu ( + id varchar(191) PRIMARY KEY NOT NULL, + userId varchar(191) NOT NULL, + jutsuId varchar(191) NOT NULL, + createdAt datetime(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP(3)), + updatedAt datetime(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP(3)), + level int NOT NULL DEFAULT 1, + equipped tinyint NOT NULL DEFAULT 0, + finishTraining datetime(3), + UNIQUE KEY RankedUserJutsu_userId_jutsuId_key (userId, jutsuId), + INDEX RankedUserJutsu_jutsuId_idx (jutsuId), + INDEX RankedUserJutsu_equipped_idx (equipped) +) diff --git a/app/drizzle/schema.ts b/app/drizzle/schema.ts index 5e4849754..ffa7244e8 100644 --- a/app/drizzle/schema.ts +++ b/app/drizzle/schema.ts @@ -19,6 +19,7 @@ import { double, primaryKey, unique, + timestamp, } from "drizzle-orm/mysql-core"; import * as consts from "@/drizzle/constants"; import { createInsertSchema } from "drizzle-zod"; @@ -1032,6 +1033,23 @@ export const jutsuLoadout = mysqlTable( }, ); +export const rankedJutsuLoadout = mysqlTable( + "RankedJutsuLoadout", + { + id: varchar("id", { length: 191 }).primaryKey().notNull(), + userId: varchar("userId", { length: 191 }).notNull(), + jutsuIds: json("content").$type().notNull(), + createdAt: datetime("createdAt", { mode: "date", fsp: 3 }) + .default(sql`(CURRENT_TIMESTAMP(3))`) + .notNull(), + }, + (table) => { + return { + userIdIdx: index("RankedJutsuLoadout_userId_idx").on(table.userId), + }; + }, +); + export const notification = mysqlTable( "Notification", { @@ -1328,6 +1346,7 @@ export const userData = mysqlTable( anbuId: varchar("anbuId", { length: 191 }), clanId: varchar("clanId", { length: 191 }), jutsuLoadout: varchar("jutsuLoadout", { length: 191 }), + rankedJutsuLoadout: varchar("rankedJutsuLoadout", { length: 191 }), nRecruited: int("nRecruited").default(0).notNull(), lastIp: varchar("lastIp", { length: 191 }), username: varchar("username", { length: 191 }).notNull(), @@ -1461,6 +1480,10 @@ export const userData = mysqlTable( tavernMessages: int("tavernMessages").default(0).notNull(), audioOn: boolean("audioOn").default(true).notNull(), tutorialStep: tinyint("tutorialStep", { unsigned: true }).default(0).notNull(), + rankedLp: int("rankedLp").notNull().default(150), + rankedBattles: int("rankedBattles").notNull().default(0), + rankedWins: int("rankedWins").notNull().default(0), + rankedStreak: int("rankedStreak").notNull().default(0), }, (table) => { return { @@ -1471,6 +1494,7 @@ export const userData = mysqlTable( clanIdIdx: index("UserData_clanId_idx").on(table.clanId), anbuIdIdx: index("UserData_anbuId_idx").on(table.anbuId), jutsuLoadoutIdx: index("UserData_jutsuLoadout_idx").on(table.jutsuLoadout), + rankedJutsuLoadoutIdx: index("UserData_rankedJutsuLoadout_idx").on(table.rankedJutsuLoadout), levelIdx: index("UserData_level_idx").on(table.level), usernameKey: uniqueIndex("UserData_username_key").on(table.username), bloodlineIdIdx: index("UserData_bloodlineId_idx").on(table.bloodlineId), @@ -1541,6 +1565,7 @@ export const userDataRelations = relations(userData, ({ one, many }) => ({ conversations: many(user2conversation), items: many(userItem), jutsus: many(userJutsu), + rankedUserJutsus: many(rankedUserJutsu), badges: many(userBadge), recruitedUsers: many(userData, { relationName: "recruiter" }), recruiter: one(userData, { @@ -1566,6 +1591,10 @@ export const userDataRelations = relations(userData, ({ one, many }) => ({ fields: [userData.jutsuLoadout], references: [jutsuLoadout.id], }), + rankedLoadout: one(rankedJutsuLoadout, { + fields: [userData.rankedJutsuLoadout], + references: [rankedJutsuLoadout.id], + }), creatorBlacklist: many(userBlackList, { relationName: "creatorBlacklist" }), mpvpBattles: many(mpvpBattleUser), associations: many(userAssociation), @@ -1673,6 +1702,7 @@ export const userJutsu = mysqlTable( level: int("level").default(1).notNull(), experience: int("experience").default(0).notNull(), equipped: tinyint("equipped").default(0).notNull(), + rankedEquipped: tinyint("rankedEquipped").default(0).notNull(), finishTraining: datetime("finishTraining", { mode: "date", fsp: 3 }), }, (table) => { @@ -1683,11 +1713,41 @@ export const userJutsu = mysqlTable( ), jutsuIdIdx: index("UserJutsu_jutsuId_idx").on(table.jutsuId), equippedIdx: index("Jutsu_equipped_idx").on(table.equipped), + rankedEquippedIdx: index("Jutsu_rankedEquipped_idx").on(table.rankedEquipped), }; }, ); export type UserJutsu = InferSelectModel; +export const rankedUserJutsu = mysqlTable( + "RankedUserJutsu", + { + id: varchar("id", { length: 191 }).primaryKey().notNull(), + userId: varchar("userId", { length: 191 }).notNull(), + jutsuId: varchar("jutsuId", { length: 191 }).notNull(), + createdAt: datetime("createdAt", { mode: "date", fsp: 3 }) + .default(sql`(CURRENT_TIMESTAMP(3))`) + .notNull(), + updatedAt: datetime("updatedAt", { mode: "date", fsp: 3 }) + .default(sql`(CURRENT_TIMESTAMP(3))`) + .notNull(), + level: int("level").default(1).notNull(), + equipped: tinyint("equipped").default(0).notNull(), + finishTraining: datetime("finishTraining", { mode: "date", fsp: 3 }), + }, + (table) => { + return { + userIdJutsuIdKey: uniqueIndex("RankedUserJutsu_userId_jutsuId_key").on( + table.userId, + table.jutsuId, + ), + jutsuIdIdx: index("RankedUserJutsu_jutsuId_idx").on(table.jutsuId), + equippedIdx: index("RankedUserJutsu_equipped_idx").on(table.equipped), + }; + }, +); +export type RankedUserJutsu = InferSelectModel; + export const userJutsuRelations = relations(userJutsu, ({ one }) => ({ jutsu: one(jutsu, { fields: [userJutsu.jutsuId], @@ -1699,6 +1759,17 @@ export const userJutsuRelations = relations(userJutsu, ({ one }) => ({ }), })); +export const rankedUserJutsuRelations = relations(rankedUserJutsu, ({ one }) => ({ + jutsu: one(jutsu, { + fields: [rankedUserJutsu.jutsuId], + references: [jutsu.id], + }), + user: one(userData, { + fields: [rankedUserJutsu.userId], + references: [userData.userId], + }), +})); + export const userReport = mysqlTable( "UserReport", { @@ -2575,8 +2646,6 @@ export const userPollVote = mysqlTable( }, ); -export type UserPollVote = InferSelectModel; - export const userPollVoteRelations = relations(userPollVote, ({ one }) => ({ user: one(userData, { fields: [userPollVote.userId], @@ -2592,6 +2661,36 @@ export const userPollVoteRelations = relations(userPollVote, ({ one }) => ({ }), })); +export type UserPollVote = InferSelectModel; + +export const rankedPvpQueue = mysqlTable( + "RankedPvpQueue", + { + id: varchar("id", { length: 191 }).primaryKey().notNull(), + userId: varchar("userId", { length: 191 }) + .notNull() + .references(() => userData.userId), + rankedLp: int("rankedLp").notNull(), + queueStartTime: timestamp("queueStartTime").notNull().defaultNow(), + createdAt: timestamp("createdAt").notNull().defaultNow(), + }, + (table) => { + return { + userIdIdx: index("RankedPvpQueue_userId_idx").on(table.userId), + rankedLpIdx: index("RankedPvpQueue_rankedLp_idx").on(table.rankedLp), + }; + }, +); + +export type RankedPvpQueue = InferSelectModel; + +export const rankedPvpQueueRelations = relations(rankedPvpQueue, ({ one }) => ({ + user: one(userData, { + fields: [rankedPvpQueue.userId], + references: [userData.userId], + }), +})); + // User upload schema export const userUpload = mysqlTable( "UserUpload", diff --git a/app/src/app/profile/edit/page.tsx b/app/src/app/profile/edit/page.tsx index 9417fe218..ec1f6d75d 100644 --- a/app/src/app/profile/edit/page.tsx +++ b/app/src/app/profile/edit/page.tsx @@ -70,6 +70,7 @@ import { canSwapVillage } from "@/utils/permissions"; import { canSwapBloodline } from "@/utils/permissions"; import { useInfinitePagination } from "@/libs/pagination"; import { capitalizeFirstLetter } from "@/utils/sanitize"; +import { canEditPublicUser } from "@/utils/permissions"; import UserSearchSelect from "@/layout/UserSearchSelect"; import UserRequestSystem from "@/layout/UserRequestSystem"; import type { Gender } from "@/validators/register"; @@ -275,6 +276,16 @@ export default function EditProfile() { )} + {canEditPublicUser({ ...userData, role: userData.role }) && ( + + + + )} ); @@ -1648,3 +1659,48 @@ const ChangeGender: React.FC = () => { ); }; + +const AdminControls: React.FC = () => { + const { mutate: massUnequipAll, isPending: isUnequippingRegular } = api.jutsu.massUnequipAll.useMutation({ + onSuccess: (result) => { + showMutationToast(result); + }, + }); + + const { mutate: massUnequipAllRanked, isPending: isUnequippingRanked } = api.jutsu.massUnequipAllRanked.useMutation({ + onSuccess: (result) => { + showMutationToast(result); + }, + }); + + const isPending = isUnequippingRegular || isUnequippingRanked; + + return ( +
+
+
+

Mass Unequip All Jutsu

+

+ This will unequip all jutsu from all users. Use with caution. +

+
+
+ + +
+
+
+ ); +}; diff --git a/app/src/app/profile/page.tsx b/app/src/app/profile/page.tsx index 1736f994d..9b6c5398e 100644 --- a/app/src/app/profile/page.tsx +++ b/app/src/app/profile/page.tsx @@ -19,6 +19,18 @@ export default function Profile() { // State const { data: userData } = useRequiredUserData(); + // Fetch PvP rank + const shouldFetch = !!userData?.userId && userData?.rankedLp !== undefined; + const { data: pvpRank, error: pvpRankError, isLoading: pvpRankLoading } = + api.rankedpvp.getPvpRank.useQuery( + shouldFetch + ? { userId: userData.userId, rankedLp: userData.rankedLp } + : { userId: "", rankedLp: 0 }, // Provide default values instead of undefined + { + enabled: shouldFetch, // Prevents execution when data is missing + } + ); + // Query const { data: marriages } = api.marriage.getMarriedUsers.useQuery( {}, @@ -59,6 +71,14 @@ export default function Profile() {

Lvl. {userData.level} {showUserRank(userData)}

+

+ PvP Rank:{" "} + {pvpRankLoading + ? "Loading..." + : pvpRankError + ? "Error fetching PvP Rank" + : pvpRank || "Unknown"} +

Money: {userData.money.toFixed(2)}

Bank: {userData.bank.toFixed(2)}

Status: {userData.status}

@@ -74,6 +94,12 @@ export default function Profile() {

PvP Streak: {userData.pvpStreak}

PvP Activity: {userData.pvpActivity}

Medical Exp: {userData.medicalExperience}

+
+ Ranked Battles +

Battles: {userData.rankedBattles}

+

Wins: {userData.rankedWins}

+

Win Rate: {userData.rankedBattles > 0 ? ((userData.rankedWins / userData.rankedBattles) * 100).toFixed(1) : "0"}%

+

Current Streak: {userData.rankedStreak}

Reputation diff --git a/app/src/app/ranked/page.tsx b/app/src/app/ranked/page.tsx new file mode 100644 index 000000000..648d9a2a0 --- /dev/null +++ b/app/src/app/ranked/page.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { api } from "@/app/_trpc/client"; +import { Button } from "@/components/ui/button"; +import ContentBox from "@/layout/ContentBox"; +import Loader from "@/layout/Loader"; +import BanInfo from "@/layout/BanInfo"; +import { useRequireInVillage } from "@/utils/UserContext"; +import { showMutationToast } from "@/libs/toast"; +import ItemWithEffects from "@/layout/ItemWithEffects"; +import Modal from "@/layout/Modal"; +import { ActionSelector } from "@/layout/CombatActions"; +import JutsuFiltering, { useFiltering, getFilter } from "@/layout/JutsuFiltering"; +import type { Jutsu } from "@/drizzle/schema"; +import { OctagonX } from "lucide-react"; +import LoadoutSelector from "@/layout/LoadoutSelector"; + +const QueueTimer = ({ createdAt }: { createdAt: Date }) => { + const [queueTime, setQueueTime] = useState("0:00"); + + useEffect(() => { + const updateTimer = () => { + const now = new Date(); + const diff = now.getTime() - new Date(createdAt).getTime(); + const minutes = Math.floor(diff / 60000); + const seconds = Math.floor((diff % 60000) / 1000); + setQueueTime(`${minutes}:${seconds.toString().padStart(2, '0')}`); + }; + + updateTimer(); // Initial update + const interval = setInterval(updateTimer, 1000); + + return () => clearInterval(interval); + }, [createdAt]); + + return ( + {queueTime} + ); +}; + +export default function Ranked() { + // Router for forwarding + const router = useRouter(); + const utils = api.useUtils(); + + // Ensure user is in village + const { userData, access } = useRequireInVillage("/battlearena"); + + // Two-level filtering for jutsu + const state = useFiltering(); + const [isOpen, setIsOpen] = useState(false); + const [selectedJutsu, setSelectedJutsu] = useState(undefined); + + // Ranked PvP queue state and mutations + const { data: queueData } = api.combat.getRankedPvpQueue.useQuery(undefined, { + enabled: !!userData, + refetchInterval: 5000, // Refetch queue status every 5 seconds + }); + + // Get all jutsu and user jutsu data + const { + data: allJutsu, + isFetching: isLoadingJutsu, + fetchNextPage, + hasNextPage + } = api.jutsu.getAll.useInfiniteQuery( + { limit: 100, hideAi: true, ...getFilter(state) }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + enabled: !!userData, + } + ); + + useEffect(() => { + if (hasNextPage) { + void fetchNextPage(); + } + }, [hasNextPage, fetchNextPage]); + + const { data: userJutsus, isFetching: isLoadingUserJutsu } = api.jutsu.getRankedUserJutsus.useQuery( + getFilter(state), + { enabled: !!userData } + ); + + // Get total count of equipped jutsu without any filters + const { data: totalEquipped } = api.jutsu.getRankedUserJutsus.useQuery( + {}, // No filters to get total count + { enabled: !!userData } + ); + + // Check if user has reached the jutsu limit + const equippedCount = totalEquipped?.filter(uj => uj.equipped).length ?? 0; + const hasReachedLimit = equippedCount >= 15; + + // Mutations for queue management + const { mutate: queue, isPending: isQueuing } = api.combat.queueForRankedPvp.useMutation({ + onSuccess: (result) => { + if (result.success) { + showMutationToast({ ...result, message: "Queued for ranked PvP" }); + if (result.battleId) { + router.push("/combat"); + } + } else { + showMutationToast(result); + } + }, + }); + + const { mutate: leaveQueue, isPending: isLeaving } = api.combat.leaveRankedPvpQueue.useMutation({ + onSuccess: (result) => { + if (result.success) { + showMutationToast({ ...result, message: "Left ranked PvP queue" }); + } else { + showMutationToast(result); + } + }, + }); + + // Mutations for jutsu management + const { mutate: equip, isPending: isToggling } = api.jutsu.toggleRankedEquip.useMutation({ + onSuccess: async (result) => { + showMutationToast(result); + await utils.jutsu.getRankedUserJutsus.invalidate(); + }, + onSettled: () => { + setIsOpen(false); + setSelectedJutsu(undefined); + }, + }); + + const handleEquip = (jutsu: Jutsu) => { + if (hasReachedLimit && !userJutsuMap.get(jutsu.id)?.equipped) { + showMutationToast({ + success: false, + message: `You can only select up to 15 jutsu for ranked battles`, + }); + return; + } + equip({ jutsuId: jutsu.id }); + }; + + const { mutate: unequipAllRanked, isPending: isUnequipping } = api.jutsu.unequipAllRanked.useMutation({ + onSuccess: async (result) => { + showMutationToast(result); + await utils.jutsu.getRankedUserJutsus.invalidate(); + }, + }); + + const { mutate: checkMatches } = api.combat.checkRankedPvpMatches.useMutation({ + onSuccess: (result) => { + if (result.success && result.battleId) { + router.push("/combat"); + } + }, + }); + + // Check for matches periodically when in queue + useEffect(() => { + if (queueData?.inQueue) { + const interval = setInterval(() => { + checkMatches(); + }, 5000); // Check for matches every 5 seconds + return () => clearInterval(interval); + } + }, [queueData?.inQueue, checkMatches]); + + // Process jutsu data + const flatJutsu = allJutsu?.pages.flatMap((page) => page.data) ?? []; + const userJutsuMap = new Map( + userJutsus?.map((uj) => [uj.jutsuId, uj]) ?? [] + ); + const totalEquippedMap = new Map( + totalEquipped?.map((uj) => [uj.jutsuId, uj]) ?? [] + ); + + const processedJutsu = flatJutsu + .map((jutsu) => { + const equipped = !!totalEquippedMap.get(jutsu.id)?.equipped; + return { + ...jutsu, + highlight: equipped, + }; + }) + .filter(jutsu => { + // Filter out jutsu types not allowed in ranked battles + const restrictedTypes = ["SPECIAL", "BLOODLINE", "LOYALTY", "CLAN", "EVENT", "AI"]; + return !restrictedTypes.includes(jutsu.jutsuType); + }); + + // Sort if we have a loadout + if (processedJutsu) { + processedJutsu.sort((a, b) => { + const aEquipped = !!totalEquippedMap.get(a.id)?.equipped; + const bEquipped = !!totalEquippedMap.get(b.id)?.equipped; + + // 1. Always sort equipped jutsu to the top + if (aEquipped !== bEquipped) { + return aEquipped ? -1 : 1; + } + + // 2. If both are equipped AND we have a loadout, sort by loadout order + if (aEquipped && bEquipped && userData?.loadout?.jutsuIds) { + const aIndex = userData.loadout.jutsuIds.indexOf(a.id); + const bIndex = userData.loadout.jutsuIds.indexOf(b.id); + return aIndex - bIndex; + } + + // 3. Optional: fallback to alphabetical + return a.name.localeCompare(b.name); + }); + } + + + // Guards + if (!access) return ; + if (!userData) return ; + if (userData?.isBanned) return ; + + return ( + <> + +
+

+ Queue for ranked PvP battles! You will be matched with players of similar LP. + All battles are fought with level 100 characters with max stats. +

+

+ Players in queue: {queueData?.queueCount ?? 0} +

+

+ Selected Jutsu: {equippedCount}/15 +

+ {queueData?.inQueue && ( +

+ You are currently in queue. Waiting for opponent... + {queueData.createdAt && ( + + Time in queue: + + )} +

+ )} + {!queueData?.inQueue ? ( + + ) : ( + + )} +
+
+ + unequipAllRanked()}> + + Unequip All + + } + topRightContent={ + !isOpen && ( +
+ + +
+ ) + } + > + {isLoadingJutsu && } + { + setSelectedJutsu(processedJutsu?.find((jutsu) => jutsu.id === id)); + setIsOpen(true); + }} + emptyText="No jutsu available. Go to the training grounds in your village to learn some." + /> +
+ + {isOpen && selectedJutsu && ( + handleEquip(selectedJutsu)} + > + + + )} + + ); +} diff --git a/app/src/app/users/page.tsx b/app/src/app/users/page.tsx index 9a344aabc..df13890ab 100644 --- a/app/src/app/users/page.tsx +++ b/app/src/app/users/page.tsx @@ -32,6 +32,7 @@ export default function Users() { "Outlaws", "Community", "Staff", + "Ranked", ] as const; type TabName = (typeof tabNames)[number]; const [activeTab, setActiveTab] = useState("Online"); @@ -59,6 +60,14 @@ export default function Users() { const userCountNow = onlineStats?.onlineNow || 0; const userCountDay = onlineStats?.onlineDay || 0; const maxOnline = onlineStats?.maxOnline || 0; + + // Get PvP ranks for all users if we're in the Ranked tab + const allUserIds = users?.pages.flatMap(page => page.data.map(user => user.userId)) ?? []; + const { data: pvpRanks } = api.rankedpvp.getPvpRanks.useQuery( + { userIds: allUserIds }, + { enabled: activeTab === "Ranked" && allUserIds.length > 0 } + ); + const allUsers = users?.pages .map((page) => page.data) .flat() @@ -68,7 +77,9 @@ export default function Users() {

{user.username}

- Lvl. {user.level} {showUserRank(user)} + Lvl. {user.level} {activeTab === "Ranked" ? ( + pvpRanks?.[user.userId] || "Loading..." + ) : showUserRank(user)}

{user.village?.name || "Syndicate"}

@@ -99,6 +110,8 @@ export default function Users() { } else if (activeTab === "Staff") { columns.push({ key: "tavernMessages", header: "Yapper Rank", type: "string" }); columns.push({ key: "role", header: "Role", type: "capitalized" }); + } else if (activeTab === "Ranked") { + columns.push({ key: "rankedLp", header: "Ranked LP", type: "string" }); } if (userData && canSeeIps(userData.role)) { columns.push({ key: "lastIp", header: "LastIP", type: "string" }); diff --git a/app/src/layout/LoadoutSelector.tsx b/app/src/layout/LoadoutSelector.tsx index 625215e8d..d5179e0ff 100644 --- a/app/src/layout/LoadoutSelector.tsx +++ b/app/src/layout/LoadoutSelector.tsx @@ -5,6 +5,7 @@ import { fedJutsuLoadouts } from "@/utils/paypal"; import { Folder } from "lucide-react"; import { showMutationToast } from "@/libs/toast"; import { useRequiredUserData } from "@/utils/UserContext"; +import { usePathname } from "next/navigation"; interface LoadoutSelectorProps { size?: "small" | "large"; @@ -13,6 +14,8 @@ interface LoadoutSelectorProps { const LoadoutSelector: React.FC = (props) => { // State const { data: userData } = useRequiredUserData(); + const pathname = usePathname(); + const isRankedPage = pathname === "/ranked"; // tRPC utility const utils = api.useUtils(); @@ -76,6 +79,11 @@ const LoadoutSelector: React.FC = (props) => { return (
{data?.map((loadout, i) => { + // On ranked page, only show loadout 4 + if (isRankedPage && i !== 3) return null; + // On regular pages, only show loadouts 1-3 + if (!isRankedPage && i === 3) return null; + return (
= (props) => { {}, ); + const { data: pvpRank, isPending: isPendingPvpRank } = api.rankedpvp.getPvpRank.useQuery( + { + userId: userId, + rankedLp: profile?.rankedLp ?? 0 + }, + { + enabled: !!profile?.rankedLp + } + ); + // Forms const form = useForm>({ resolver: zodResolver(awardSchema), @@ -464,8 +474,11 @@ const PublicUserComponent: React.FC = (props) => {

Lvl. {profile.level} {capitalizeFirstLetter(profile.rank)}

+

+ PvP Rank: {isPendingPvpRank ? "Loading..." : (pvpRank ?? "Not ranked")} +

Village: {profile.village?.name}

-

Status: {profile.status}

+

Status: {profile.status === "QUEUED" ? "ASLEEP" : profile.status}

Account Status: {accountStatus}

Gender: {profile.gender}


diff --git a/app/src/libs/combat/database.ts b/app/src/libs/combat/database.ts index 2ded8b25e..cdb945767 100644 --- a/app/src/libs/combat/database.ts +++ b/app/src/libs/combat/database.ts @@ -20,6 +20,7 @@ import { JUTSU_TRAIN_LEVEL_CAP } from "@/drizzle/constants"; import { VILLAGE_SYNDICATE_ID } from "@/drizzle/constants"; import { KAGE_DEFAULT_PRESTIGE } from "@/drizzle/constants"; import { KAGE_PRESTIGE_REQUIREMENT } from "@/drizzle/constants"; +import { calculateLPChange } from "./ranked"; import type { PusherClient } from "@/libs/pusher"; import type { BattleTypes, BattleDataEntryType } from "@/drizzle/constants"; import type { DrizzleClient } from "@/server/db"; @@ -386,6 +387,34 @@ export const updateUser = async ( if (user.villagePrestige + result.villagePrestige < 0) { user.allyVillage = false; } + + // Calculate LP change for ranked battles + let lpChange = 0; + if (curBattle.battleType === "RANKED") { + const opponent = curBattle.usersState.find(u => u.userId !== userId); + if (opponent) { + lpChange = calculateLPChange(user, opponent, result.didWin > 0); + } + } + + // Update ranked battle statistics if it's a ranked battle + if (curBattle.battleType === "RANKED") { + await client + .update(userData) + .set({ + rankedBattles: sql`${userData.rankedBattles} + 1`, + ...(result.didWin > 0 + ? { + rankedWins: sql`${userData.rankedWins} + 1`, + rankedStreak: sql`${userData.rankedStreak} + 1`, + } + : { + rankedStreak: 0, + }), + }) + .where(eq(userData.userId, userId)); + } + // Update user & user items await Promise.all([ // Delete items @@ -474,6 +503,11 @@ export const updateUser = async ( : sql`immunityUntil`, } : { status: "AWAKE" }), + ...(curBattle.battleType === "RANKED" && lpChange !== 0 + ? { + rankedLp: sql`GREATEST(rankedLp + ${lpChange}, 0)`, + } + : {}), }) .where(eq(userData.userId, userId)), ]); diff --git a/app/src/libs/combat/ranked.ts b/app/src/libs/combat/ranked.ts new file mode 100644 index 000000000..9f83222b6 --- /dev/null +++ b/app/src/libs/combat/ranked.ts @@ -0,0 +1,96 @@ +import { RankedDivisions } from "@/drizzle/constants"; +import type { BattleUserState } from "@/libs/combat/types"; + +export interface RankedPvpQueue { + id: string; + userId: string; + rankedLp: number; + queueStartTime: Date; + createdAt: Date; +} + +// K-factor adjustments based on LP +const K_FACTOR_BASE = 24; +const K_FACTOR_LOW = 32; // For players < 300 LP +const K_FACTOR_MID = 28; // For players 300-600 LP +const K_FACTOR_HIGH = 24; // For players > 900 LP + +// Win streak bonus +const STREAK_BONUS = 5; + +function getKFactor(lp: number): number { + if (lp < 300) return K_FACTOR_LOW; + if (lp < 600) return K_FACTOR_MID; + if (lp > 900) return K_FACTOR_HIGH; + return K_FACTOR_BASE; +} + +function getRank(lp: number): string { + // Start from highest rank (lowest LP requirement) + for (let i = RankedDivisions.length - 1; i >= 0; i--) { + const division = RankedDivisions[i]; + if (!division) continue; + if (lp >= division.rankedLp) { + return division.name; + } + } + // If no rank found (shouldn't happen), return lowest rank + return RankedDivisions[0]?.name ?? "UNRANKED"; +} + +function getRankIndex(lp: number): number { + for (let i = RankedDivisions.length - 1; i >= 0; i--) { + const division = RankedDivisions[i]; + if (!division) continue; + if (lp >= division.rankedLp) { + return i; + } + } + return 0; +} + +export function calculateLPChange( + player: BattleUserState, + opponent: BattleUserState, + didWin: boolean, +): number { + // If it's a draw (both players have 0 health), return 0 + if (player.curHealth <= 0 && opponent.curHealth <= 0) { + return 0; + } + + const playerLP = player.rankedLp ?? 0; + const opponentLP = opponent.rankedLp ?? 0; + const playerRank = getRankIndex(playerLP); + const opponentRank = getRankIndex(opponentLP); + + // Calculate expected probability (Elo formula) + const expectedScore = 1 / (1 + Math.pow(10, (opponentLP - playerLP) / 400)); + const actualScore = didWin ? 1 : 0; + + // Get K-factor based on player's LP + const kFactor = getKFactor(playerLP); + + // Calculate base LP change + let lpChange = Math.round(kFactor * (actualScore - expectedScore)); + + // Apply rank-based adjustments + if (didWin) { + // Bonus for beating higher-ranked opponents + if (opponentRank < playerRank) { + lpChange += Math.min(10, (playerRank - opponentRank) * 2); + } + } else { + // Protection against losing to much higher-ranked opponents + if (opponentRank < playerRank - 1) { + lpChange = Math.max(-10, lpChange); + } + } + + // Add win streak bonus + if (didWin && player.rankedStreak > 0) { + lpChange += STREAK_BONUS * Math.min(5, player.pvpStreak); + } + + return lpChange; +} diff --git a/app/src/libs/combat/types.ts b/app/src/libs/combat/types.ts index fbd3175cb..f18dd1f3b 100644 --- a/app/src/libs/combat/types.ts +++ b/app/src/libs/combat/types.ts @@ -734,6 +734,12 @@ export const UnknownTag = z.object({ description: msg("An unknown tag - please report & change!"), }); +export const GroundTag = z.object({ + ...BaseAttributes, + type: z.literal("ground").default("ground"), + description: msg("ground dot"), +}); + export const IncreaseMarriageSlots = z.object({ ...BaseAttributes, rank: z.enum(LetterRanks).default("D"), @@ -766,6 +772,7 @@ export const AllTags = z.union([ ElementalSealTag.default({}), FleePreventTag.default({}), FleeTag.default({}), + GroundTag.default({}), HealPreventTag.default({}), HealTag.default({}), IncreaseDamageGivenTag.default({}), diff --git a/app/src/libs/combat/util.ts b/app/src/libs/combat/util.ts index ed1515350..900fee9ed 100644 --- a/app/src/libs/combat/util.ts +++ b/app/src/libs/combat/util.ts @@ -981,6 +981,31 @@ export const processUsersForBattle = (info: { user.curStamina = user.maxStamina; } + if (battleType === "RANKED") { + user.level = 100; + user.experience = 4399880; + user.bloodlineId = ""; + user.maxHealth = 5050; + user.curHealth = 5050; + user.maxChakra = 5050; + user.curChakra = 5050; + user.maxStamina = 5050; + user.curStamina = 5050; + user.strength = 200000; + user.intelligence = 200000; + user.willpower = 200000; + user.speed = 200000; + user.ninjutsuOffence = 450000; + user.ninjutsuDefence = 450000; + user.genjutsuOffence = 450000; + user.genjutsuDefence = 450000; + user.taijutsuOffence = 450000; + user.taijutsuDefence = 450000; + user.bukijutsuOffence = 450000; + user.bukijutsuDefence = 450000; + user.medicalExperience = 400000; + } + // Add highest offence name to user const offences = { ninjutsuOffence: user.ninjutsuOffence, @@ -1120,7 +1145,7 @@ export const processUsersForBattle = (info: { // If in own village, add defence bonus const ownSector = user.sector === user.village?.sector; const inVillage = calcIsInVillage({ x: user.longitude, y: user.latitude }); - if (ownSector && inVillage && battleType !== "ARENA") { + if (ownSector && inVillage && battleType !== "ARENA" && battleType !== "RANKED") { const boost = structureBoost("villageDefencePerLvl", user.village?.structures); const effect = DecreaseDamageTakenTag.parse({ target: "SELF", @@ -1144,7 +1169,7 @@ export const processUsersForBattle = (info: { } // Add bloodline efects - if (user.bloodline?.effects) { + if (user.bloodline?.effects && battleType !== "RANKED") { user.bloodline.effects.forEach((effect) => { const realized = realizeTag({ tag: effect as UserEffect, @@ -1238,7 +1263,7 @@ export const processUsersForBattle = (info: { effects .filter((e) => e.type === "summon") .forEach((e) => "aiId" in e && allSummons.push(e.aiId)); - if (itemType === "ARMOR" || itemType === "ACCESSORY") { + if ((itemType === "ARMOR" || itemType === "ACCESSORY") && battleType !== "RANKED") { if (useritem.item.effects && useritem.equipped !== "NONE") { effects.forEach((effect) => { const realized = realizeTag({ @@ -1257,7 +1282,9 @@ export const processUsersForBattle = (info: { } } else { useritem.lastUsedRound = -useritem.item.cooldown; - items.push(useritem); + if (itemType !== "ARMOR" && itemType !== "ACCESSORY") { + items.push(useritem); + } } }); user.items = items; diff --git a/app/src/libs/menus.tsx b/app/src/libs/menus.tsx index 3b16c77aa..6dcba19fa 100644 --- a/app/src/libs/menus.tsx +++ b/app/src/libs/menus.tsx @@ -1,6 +1,6 @@ import { type ReactNode } from "react"; import Image from "next/image"; -import { Atom, Bug, User, Globe2, BookOpenText, Users } from "lucide-react"; +import { Atom, Bug, User, Globe2, BookOpenText, Users, Swords } from "lucide-react"; import { Paintbrush, MessagesSquare, Newspaper, Scale, Receipt } from "lucide-react"; import { Inbox, Flag } from "lucide-react"; import { calcIsInVillage } from "./travel/controls"; @@ -112,6 +112,13 @@ export const useGameMenu = (userData: UserWithRelations) => { name: "Points", icon: , }, + { + id: "ranked", + href: "/ranked", + name: "Ranked", + requireAwake: true, + icon: , + }, ]; // Get information from the sector the user is currently in. No stale time diff --git a/app/src/libs/train.ts b/app/src/libs/train.ts index 971a16a95..ee0069769 100644 --- a/app/src/libs/train.ts +++ b/app/src/libs/train.ts @@ -342,6 +342,8 @@ export const battleJutsuExp = (battleType: BattleType, experienceGain: number, u return experienceGain * 0.0; case "TRAINING": return 10; + case "RANKED": + return 0; } return 0; }; diff --git a/app/src/server/api/root.ts b/app/src/server/api/root.ts index 926fef4b6..e9dbd1cc7 100644 --- a/app/src/server/api/root.ts +++ b/app/src/server/api/root.ts @@ -38,6 +38,7 @@ import { marriageRouter } from "./routers/marriage"; import { staffRouter } from "./routers/staff"; import { backgroundSchemaRouter } from "./routers/backgroundSchema"; import { linkPromotionRouter } from "./routers/linkpromotion"; +import { rankedpvpRouter } from "@/server/api/routers/rankedpvp"; /** * This is the primary router for your server. @@ -84,6 +85,7 @@ export const appRouter = createTRPCRouter({ backgroundSchema: backgroundSchemaRouter, staff: staffRouter, linkPromotion: linkPromotionRouter, + rankedpvp: rankedpvpRouter, }); // export type definition of API diff --git a/app/src/server/api/routers/combat.ts b/app/src/server/api/routers/combat.ts index 0a3916ed7..7678dbc55 100644 --- a/app/src/server/api/routers/combat.ts +++ b/app/src/server/api/routers/combat.ts @@ -7,7 +7,7 @@ import { hasUserMiddleware, } from "@/api/trpc"; import { serverError, baseServerResponse, errorResponse } from "@/api/trpc"; -import { eq, or, and, sql, gt, ne, isNotNull, isNull, inArray, gte } from "drizzle-orm"; +import { eq, or, and, sql, gt, ne, isNotNull, isNull, inArray, gte, lt } from "drizzle-orm"; import { alias } from "drizzle-orm/mysql-core"; import { desc } from "drizzle-orm"; import { COMBAT_HEIGHT, COMBAT_WIDTH } from "@/libs/combat/constants"; @@ -28,7 +28,7 @@ import { } from "@/libs/combat/database"; import { fetchUpdatedUser, fetchUser } from "./profile"; import { performAIaction } from "@/libs/combat/ai_v2"; -import { userData, questHistory, quest, gameSetting } from "@/drizzle/schema"; +import { userData, questHistory, quest, gameSetting, rankedPvpQueue } from "@/drizzle/schema"; import { battle, battleAction, battleHistory } from "@/drizzle/schema"; import { villageAlliance, village, tournamentMatch } from "@/drizzle/schema"; import { performActionSchema, statSchema } from "@/libs/combat/types"; @@ -65,6 +65,73 @@ const debug = false; // Pusher instance const pusher = getServerPusher(); +// Ranked PvP stat distribution +const RANKED_PVP_STATS = { + strength: 200000, + intelligence: 200000, + willpower: 200000, + speed: 200000, + ninjutsuOffence: 450000, + ninjutsuDefence: 450000, + genjutsuOffence: 450000, + genjutsuDefence: 450000, + taijutsuOffence: 450000, + taijutsuDefence: 450000, + bukijutsuOffence: 450000, + bukijutsuDefence: 450000, +}; + +// Helper function to initiate a ranked battle +const initiateRankedBattle = async ( + client: DrizzleClient, + player1Id: string, + player2Id: string, +) => { + const result = await initiateBattle( + { + userIds: [player1Id], + targetIds: [player2Id], + client: client, + asset: "arena", + statDistribution: RANKED_PVP_STATS, + }, + "RANKED", + ); + + if (result.success && result.battleId) { + // Remove both users from queue + await Promise.all([ + client + .delete(rankedPvpQueue) + .where(eq(rankedPvpQueue.userId, player1Id)), + client + .delete(rankedPvpQueue) + .where(eq(rankedPvpQueue.userId, player2Id)), + // Update both users' status + client + .update(userData) + .set({ + status: "BATTLE", + battleId: result.battleId, + updatedAt: new Date(), + }) + .where(eq(userData.userId, player1Id)), + client + .update(userData) + .set({ + status: "BATTLE", + battleId: result.battleId, + updatedAt: new Date(), + }) + .where(eq(userData.userId, player2Id)), + ]); + + return { success: true, message: "Match found!", battleId: result.battleId }; + } + + return { success: false, message: "Failed to initiate battle" }; +}; + export const combatRouter = createTRPCRouter({ getBattle: protectedProcedure .input(z.object({ battleId: z.string().optional().nullable() })) @@ -665,6 +732,217 @@ export const combatRouter = createTRPCRouter({ } return errorResponse("Failed to update battle state after multiple attempts"); }), + getRankedPvpQueue: protectedProcedure + .query(async ({ ctx }) => { + const queueEntry = await ctx.drizzle.query.rankedPvpQueue.findFirst({ + where: eq(rankedPvpQueue.userId, ctx.userId), + columns: { + createdAt: true, + }, + }); + + const queueCount = await ctx.drizzle + .select({ count: sql`count(*)` }) + .from(rankedPvpQueue) + .then(result => result[0]?.count ?? 0); + + return { + inQueue: !!queueEntry, + createdAt: queueEntry?.createdAt, + queueCount, + }; + }), + queueForRankedPvp: protectedProcedure + .output(baseServerResponse.extend({ battleId: z.string().optional() })) + .mutation(async ({ ctx }) => { + // Check if user is already in queue + const existingQueue = await ctx.drizzle.query.rankedPvpQueue.findFirst({ + where: eq(rankedPvpQueue.userId, ctx.userId), + }); + if (existingQueue) { + return errorResponse("Already in queue"); + } + + // Get user's current LP + const user = await ctx.drizzle.query.userData.findFirst({ + where: eq(userData.userId, ctx.userId), + columns: { rankedLp: true }, + }); + if (!user) { + return errorResponse("User not found"); + } + + // Add to queue + await ctx.drizzle.insert(rankedPvpQueue).values({ + id: nanoid(), + userId: ctx.userId, + rankedLp: user.rankedLp, + createdAt: new Date(), + }); + + // Update user status + await ctx.drizzle + .update(userData) + .set({ status: "QUEUED" }) + .where(eq(userData.userId, ctx.userId)); + + // Try to find a match + const baseRange = 100; + const potentialOpponents = await ctx.drizzle.query.rankedPvpQueue.findMany({ + where: ne(rankedPvpQueue.userId, ctx.userId), + orderBy: desc(rankedPvpQueue.createdAt), + }); + + // Find opponent considering queue time + for (const opponent of potentialOpponents) { + // Calculate additional range based on queue time for both players + const opponentQueueTimeMinutes = (new Date().getTime() - opponent.createdAt.getTime()) / (1000 * 60); + const opponentAdditionalRange = opponentQueueTimeMinutes >= 15 ? 10000 : + opponentQueueTimeMinutes >= 2 ? 25 + Math.floor((opponentQueueTimeMinutes - 2) / 2) * 25 : 0; + const opponentTotalRange = baseRange + opponentAdditionalRange; + + // Check if either player is within range of the other + const lpDiff = Math.abs(user.rankedLp - opponent.rankedLp); + // Allow matching any 1000+ LP players together + if (user.rankedLp >= 1000 && opponent.rankedLp >= 1000) { + return await initiateRankedBattle(ctx.drizzle, ctx.userId, opponent.userId); + } + // Prevent matching players below 300 LP with players above 900 LP + if ((user.rankedLp < 300 && opponent.rankedLp > 900) || + (opponent.rankedLp < 300 && user.rankedLp > 900)) { + continue; + } + if (lpDiff <= opponentTotalRange) { + return await initiateRankedBattle(ctx.drizzle, ctx.userId, opponent.userId); + } + } + + return { success: true, message: "Queued for ranked PvP" }; + }), + leaveRankedPvpQueue: protectedProcedure + .output(baseServerResponse) + .mutation(async ({ ctx }) => { + // Remove from queue + await ctx.drizzle + .delete(rankedPvpQueue) + .where(eq(rankedPvpQueue.userId, ctx.userId)); + + // Update user status + await ctx.drizzle + .update(userData) + .set({ status: "AWAKE" }) + .where(eq(userData.userId, ctx.userId)); + + return { success: true, message: "Left ranked PvP queue" }; + }), + checkRankedPvpMatches: protectedProcedure + .output(baseServerResponse.extend({ battleId: z.string().optional() })) + .mutation(async ({ ctx }) => { + // Get all queued players + const queuedPlayers = await ctx.drizzle.query.rankedPvpQueue.findMany({ + orderBy: desc(rankedPvpQueue.createdAt), + }); + + console.log("Queued players:", queuedPlayers.map(p => ({ userId: p.userId, lp: p.rankedLp }))); + + // Try to match players + for (const player of queuedPlayers) { + if (!player) continue; + + // Skip if player already matched + const stillQueued = await ctx.drizzle.query.rankedPvpQueue.findFirst({ + where: eq(rankedPvpQueue.userId, player.userId), + }); + if (!stillQueued) continue; + + // Calculate base range and additional range based on queue time + const baseRange = 100; + const queueTimeMinutes = (new Date().getTime() - player.createdAt.getTime()) / (1000 * 60); + const additionalRange = queueTimeMinutes >= 30 ? 10000 : + queueTimeMinutes >= 5 ? 25 + Math.floor((queueTimeMinutes - 5) / 2) * 25 : 0; + const totalRange = baseRange + additionalRange; + + // Find potential opponents considering expanded range + const potentialOpponents = queuedPlayers.filter( + (opponent) => { + if (opponent.userId === player.userId) return false; + + // Calculate opponent's range + const opponentQueueTimeMinutes = (new Date().getTime() - opponent.createdAt.getTime()) / (1000 * 60); + const opponentAdditionalRange = opponentQueueTimeMinutes >= 30 ? 10000 : + opponentQueueTimeMinutes >= 5 ? 25 + Math.floor((opponentQueueTimeMinutes - 5) / 2) * 25 : 0; + const opponentBaseRange = 100; + const opponentTotalRange = opponentBaseRange + opponentAdditionalRange; + + // Check if either player is within range of the other + const lpDiff = Math.abs(opponent.rankedLp - player.rankedLp); + // Allow matching any 1000+ LP players together + if (player.rankedLp >= 1000 && opponent.rankedLp >= 1000) { + return true; + } + // Prevent matching players below 300 LP with players above 900 LP + if ((player.rankedLp < 300 && opponent.rankedLp > 900) || + (opponent.rankedLp < 300 && player.rankedLp > 900)) { + return false; + } + return lpDiff <= totalRange || lpDiff <= opponentTotalRange; + } + ); + + console.log(`Potential opponents for ${player.userId} (LP: ${player.rankedLp}, Range: ${totalRange}):`, + potentialOpponents.map(p => ({ userId: p.userId, lp: p.rankedLp })) + ); + + if (potentialOpponents.length > 0) { + // Get the opponent who has been waiting the longest + const opponent = potentialOpponents[0]; + if (!opponent) continue; + + // Double check both players are still in queue + const [playerStillQueued, opponentStillQueued] = await Promise.all([ + ctx.drizzle.query.rankedPvpQueue.findFirst({ + where: eq(rankedPvpQueue.userId, player.userId), + }), + ctx.drizzle.query.rankedPvpQueue.findFirst({ + where: eq(rankedPvpQueue.userId, opponent.userId), + }), + ]); + + if (!playerStillQueued || !opponentStillQueued) { + console.log("One or both players no longer in queue"); + continue; + } + + console.log(`Attempting to match ${player.userId} with ${opponent.userId}`); + + const result = await initiateRankedBattle(ctx.drizzle, player.userId, opponent.userId); + + if (result.success && result.battleId) { + console.log(`Match found! Battle ID: ${result.battleId}`); + // Notify both players about the match + const pusher = getServerPusher(); + await Promise.all([ + pusher.trigger(player.userId, "event", { + type: "battle", + battleId: result.battleId + }), + pusher.trigger(opponent.userId, "event", { + type: "battle", + battleId: result.battleId + }), + ]); + + return result; + } else { + console.log("Failed to initiate battle:", result); + } + } else { + console.log(`No potential opponents found for ${player.userId} (Range: ${totalRange})`); + } + } + + return { success: true, message: "Checked for matches" }; + }), }); /*********************************************** @@ -765,6 +1043,11 @@ export const initiateBattle = async ( where: (jutsus) => eq(jutsus.equipped, 1), orderBy: (table, { desc }) => [desc(table.level)], }, + // rankedUserJutsus: { + // with: { jutsu: true }, + // where: (rankedUserJutsus) => eq(rankedUserJutsus.equipped, 1), + // orderBy: (table, { desc }) => [desc(table.level)], + // }, userQuests: { where: or( and(isNull(questHistory.endAt), eq(questHistory.completed, 0)), @@ -803,9 +1086,9 @@ export const initiateBattle = async ( // Check if user is asleep if ( - ((user.status !== "AWAKE" && battleType !== "CLAN_BATTLE") || - (user.status !== "QUEUED" && battleType === "CLAN_BATTLE")) && - !AutoBattleTypes.includes(battleType) + (battleType === "CLAN_BATTLE" && user.status !== "QUEUED") || + (battleType === "RANKED" && user.status !== "QUEUED") || + (!["CLAN_BATTLE", "RANKED"].includes(battleType) && user.status !== "AWAKE" && !AutoBattleTypes.includes(battleType)) ) { return { success: false, message: `User ${user.username} is not awake` }; } @@ -1015,7 +1298,7 @@ export const initiateBattle = async ( defenderId: t, createdAt: new Date(), })), - ), + ) ), client .update(userData) @@ -1041,7 +1324,7 @@ export const initiateBattle = async ( inArray(userData.userId, userIds), ...(!AutoBattleTypes.includes(battleType) ? [inArray(userData.userId, targetIds)] - : []), + : []) ), or(eq(userData.status, "AWAKE"), eq(userData.status, "QUEUED")), ...(battleType === "COMBAT" @@ -1049,7 +1332,7 @@ export const initiateBattle = async ( and( ...(sector ? [eq(userData.sector, sector)] : []), ...(longitude ? [eq(userData.longitude, longitude)] : []), - ...(latitude ? [eq(userData.latitude, latitude)] : []), + ...(latitude ? [eq(userData.latitude, latitude)] : []) ), ] : []), diff --git a/app/src/server/api/routers/jutsu.ts b/app/src/server/api/routers/jutsu.ts index 8e67b5923..d86f948bf 100644 --- a/app/src/server/api/routers/jutsu.ts +++ b/app/src/server/api/routers/jutsu.ts @@ -8,6 +8,7 @@ import { actionLog, jutsuLoadout, bloodline, + rankedUserJutsu, } from "@/drizzle/schema"; import { fetchUser, fetchUpdatedUser } from "./profile"; import { canTrainJutsu } from "@/libs/train"; @@ -34,7 +35,7 @@ import { protectedProcedure, publicProcedure } from "@/server/api/trpc"; import { serverError, baseServerResponse } from "@/server/api/trpc"; import { fedJutsuLoadouts } from "@/utils/paypal"; import { IMG_AVATAR_DEFAULT } from "@/drizzle/constants"; -import { JUTSU_MAX_RESIDUAL_EQUIPPED } from "@/drizzle/constants"; +import { JUTSU_MAX_RESIDUAL_EQUIPPED, JUTSU_MAX_SHIELD_EQUIPPED, JUTSU_MAX_GROUND_EQUIPPED, JUTSU_MAX_MOVEPREVENT_EQUIPPED } from "@/drizzle/constants"; import { calculateContentDiff } from "@/utils/diff"; import { jutsuFilteringSchema } from "@/validators/jutsu"; import { QuestTracker } from "@/validators/objectives"; @@ -445,6 +446,22 @@ export const jutsuRouter = createTRPCRouter({ ); }); }), + + getRankedUserJutsus: protectedProcedure + .input(jutsuFilteringSchema) + .query(async ({ ctx, input }) => { + const [user, results] = await Promise.all([ + fetchUser(ctx.drizzle, ctx.userId), + fetchRankedUserJutsus(ctx.drizzle, ctx.userId, input), + ]); + return results.filter((userjutsu) => { + return ( + userjutsu.jutsu?.bloodlineId === "" || + user?.bloodlineId === userjutsu.jutsu?.bloodlineId + ); + }); + }), + // Get jutsus of public user getPublicUserJutsus: protectedProcedure .input(z.object({ userId: z.string() })) @@ -464,6 +481,7 @@ export const jutsuRouter = createTRPCRouter({ // Return return results; }), + // Adjust jutsu level of public user adjustJutsuLevel: protectedProcedure .input(z.object({ userId: z.string(), jutsuId: z.string(), level: z.number() })) @@ -507,6 +525,7 @@ export const jutsuRouter = createTRPCRouter({ ]); return { success: true, message: `Jutsu level adjusted to ${input.level}` }; }), + // Start training a given jutsu startTraining: protectedProcedure .input(z.object({ jutsuId: z.string() })) @@ -540,6 +559,12 @@ export const jutsuRouter = createTRPCRouter({ uj.jutsu.effects.some((e) => "residualModifier" in e && e.residualModifier), ); + const shieldJutsus = userjutsus.filter( + (uj) => + uj.equipped && + uj.jutsu.effects.some((e) => e.type === "shield"), + ); + if (!info) return errorResponse("Jutsu not found"); if (!canTrainJutsu(info, user)) return errorResponse("Jutsu not for you"); if (user.status !== "AWAKE") return errorResponse("Must be awake"); @@ -596,7 +621,7 @@ export const jutsuRouter = createTRPCRouter({ jutsuId: input.jutsuId, finishTraining: new Date(Date.now() + trainTime), equipped: - curEquip < maxEquip && residualJutsus.length <= JUTSU_MAX_RESIDUAL_EQUIPPED + curEquip < maxEquip && residualJutsus.length <= JUTSU_MAX_RESIDUAL_EQUIPPED && shieldJutsus.length <= JUTSU_MAX_SHIELD_EQUIPPED ? 1 : 0, }); @@ -701,21 +726,77 @@ export const jutsuRouter = createTRPCRouter({ const newEquippedState = isEquipped ? 0 : 1; const loadout = loadouts.find((l) => l.id === user.jutsuLoadout); const isLoaded = userjutsuObj && loadout?.jutsuIds.includes(userjutsuObj.jutsuId); + + // Get counts of different jutsu types const residualJutsus = userjutsus.filter( (uj) => uj.equipped && uj.jutsu.effects.some((e) => "residualModifier" in e && e.residualModifier), ); + const shieldJutsus = userjutsus.filter( + (uj) => + uj.equipped && + uj.jutsu.effects.some((e) => e.type === "shield"), + ); + + const groundDotJutsus = userjutsus.filter( + (uj) => + uj.equipped && + uj.jutsu.effects.some((e) => e.type === "ground"), + ); + + const movepreventJutsus = userjutsus.filter( + (uj) => + uj.equipped && + uj.jutsu.effects.some((e) => e.type === "moveprevent"), + ); + + const curJutsuIsGround = userjutsuObj?.jutsu.effects.some( + (e) => e.type === "ground", + ); + + const curJutsuIsMovePrevent = userjutsuObj?.jutsu.effects.some( + (e) => e.type === "moveprevent", + ); + // Guards if ( residualJutsus.length >= JUTSU_MAX_RESIDUAL_EQUIPPED && - newEquippedState === 1 + newEquippedState === 1 && + userjutsuObj?.jutsu.effects.some((e) => "residualModifier" in e && e.residualModifier) ) { return errorResponse( `You cannot equip more than ${JUTSU_MAX_RESIDUAL_EQUIPPED} residual jutsu. Please unequip first.`, ); } + if ( + shieldJutsus.length >= JUTSU_MAX_SHIELD_EQUIPPED && + newEquippedState === 1 && + userjutsuObj?.jutsu.effects.some((e) => e.type === "shield") + ) { + return errorResponse( + `You cannot equip more than ${JUTSU_MAX_SHIELD_EQUIPPED} shield jutsu. Please unequip first.`, + ); + } + if ( + groundDotJutsus.length >= JUTSU_MAX_GROUND_EQUIPPED && + newEquippedState === 1 && + curJutsuIsGround + ) { + return errorResponse( + `You cannot equip more than ${JUTSU_MAX_GROUND_EQUIPPED} ground dot jutsu. Please unequip first.`, + ); + } + if ( + movepreventJutsus.length >= JUTSU_MAX_MOVEPREVENT_EQUIPPED && + newEquippedState === 1 && + curJutsuIsMovePrevent + ) { + return errorResponse( + `You cannot equip more than ${JUTSU_MAX_MOVEPREVENT_EQUIPPED} move prevent jutsu. Please unequip first.`, + ); + } if (!userjutsuObj) return errorResponse("Jutsu not found"); if (!isEquipped && curEquip >= maxEquip) { return errorResponse("You cannot equip more jutsu"); @@ -787,6 +868,166 @@ export const jutsuRouter = createTRPCRouter({ return { success: true, message: `Order updated` }; }), + + toggleRankedEquip: protectedProcedure + .input(z.object({ jutsuId: z.string() })) + .mutation(async ({ ctx, input }) => { + const userData = await fetchUser(ctx.drizzle, ctx.userId); + const userJutsu = await ctx.drizzle.query.rankedUserJutsu.findFirst({ + where: and( + eq(rankedUserJutsu.userId, ctx.userId), + eq(rankedUserJutsu.jutsuId, input.jutsuId), + ), + }); + + // If equipped, just unequip + if (userJutsu?.equipped) { + await ctx.drizzle + .update(rankedUserJutsu) + .set({ equipped: 0 }) + .where( + and( + eq(rankedUserJutsu.userId, ctx.userId), + eq(rankedUserJutsu.jutsuId, input.jutsuId), + ), + ); + return { + success: true, + message: "Jutsu unequipped", + }; + } + + // If not equipped, check limits before equipping + // Get all equipped jutsu + const equippedJutsu = await ctx.drizzle + .select() + .from(rankedUserJutsu) + .innerJoin(jutsu, eq(rankedUserJutsu.jutsuId, jutsu.id)) + .where( + and( + eq(rankedUserJutsu.userId, ctx.userId), + eq(rankedUserJutsu.equipped, 1), + ), + ); + + // Get the jutsu being equipped + const jutsuToEquip = await ctx.drizzle.query.jutsu.findFirst({ + where: eq(jutsu.id, input.jutsuId), + }); + + if (!jutsuToEquip) { + return { + success: false, + message: "Jutsu not found", + }; + } + + // Count jutsu by effect type + const shieldJutsus = equippedJutsu.filter(({ Jutsu }) => + Jutsu.effects.some((e) => e.type === "shield") + ); + const groundJutsus = equippedJutsu.filter(({ Jutsu }) => + Jutsu.effects.some((e) => e.type === "ground") + ); + const movepreventJutsus = equippedJutsu.filter(({ Jutsu }) => + Jutsu.effects.some((e) => e.type === "moveprevent") + ); + + // Define type limits for ranked battles + const RANKED_TYPE_LIMITS = { + SHIELD: 2, + GROUND: 1, + MOVEPREVENT: 1, + }; + + // Check if we're at the limit for this jutsu's effects + if (jutsuToEquip.effects.some((e) => e.type === "shield") && + shieldJutsus.length >= RANKED_TYPE_LIMITS.SHIELD) { + return { + success: false, + message: `You can only equip ${RANKED_TYPE_LIMITS.SHIELD} shield jutsu in ranked battles`, + }; + } + if (jutsuToEquip.effects.some((e) => e.type === "ground") && + groundJutsus.length >= RANKED_TYPE_LIMITS.GROUND) { + return { + success: false, + message: `You can only equip ${RANKED_TYPE_LIMITS.GROUND} ground jutsu in ranked battles`, + }; + } + if (jutsuToEquip.effects.some((e) => e.type === "moveprevent") && + movepreventJutsus.length >= RANKED_TYPE_LIMITS.MOVEPREVENT) { + return { + success: false, + message: `You can only equip ${RANKED_TYPE_LIMITS.MOVEPREVENT} move prevent jutsu in ranked battles`, + }; + } + + // If not equipped, equip it + if (userJutsu) { + await ctx.drizzle + .update(rankedUserJutsu) + .set({ equipped: 1 }) + .where( + and( + eq(rankedUserJutsu.userId, ctx.userId), + eq(rankedUserJutsu.jutsuId, input.jutsuId), + ), + ); + } else { + await ctx.drizzle + .insert(rankedUserJutsu) + .values({ + id: nanoid(), + userId: ctx.userId, + jutsuId: input.jutsuId, + equipped: 1, + level: 1, + }); + } + + return { + success: true, + message: "Jutsu equipped", + }; + }), + + unequipAllRanked: protectedProcedure + .output(baseServerResponse) + .mutation(async ({ ctx }) => { + await ctx.drizzle + .update(rankedUserJutsu) + .set({ equipped: 0 }) + .where(eq(rankedUserJutsu.userId, ctx.userId)); + + return { success: true, message: "All jutsu unequipped from ranked loadout" }; + }), + + massUnequipAll: protectedProcedure + .output(baseServerResponse) + .mutation(async ({ ctx }) => { + // Check if user has permission to edit users + const user = await fetchUser(ctx.drizzle, ctx.userId); + if (!user || !canEditPublicUser(user)) { + return errorResponse("You do not have permission to perform this action"); + } + + // Unequip all jutsu from all users + await ctx.drizzle + .update(userJutsu) + .set({ equipped: 0}); + + return { success: true, message: "All jutsu have been unequipped from all users" }; + }), + + massUnequipAllRanked: protectedProcedure + .output(baseServerResponse) + .mutation(async ({ ctx }) => { + await ctx.drizzle + .update(rankedUserJutsu) + .set({ equipped: 0 }); + return { success: true, message: "All ranked jutsu have been unequipped from all users" }; + }), }); /** @@ -835,6 +1076,35 @@ export const fetchUserJutsus = async ( })); }; +export const fetchRankedUserJutsus = async ( + client: DrizzleClient, + userId: string, + input?: JutsuFilteringSchema, +) => { + // Grab all rankedUserJutsus with Jutsu data + const userjutsus = await client + .select() + .from(rankedUserJutsu) + .innerJoin(jutsu, eq(rankedUserJutsu.jutsuId, jutsu.id)) + .leftJoin(bloodline, eq(jutsu.bloodlineId, bloodline.id)) + .where( + and( + eq(rankedUserJutsu.userId, userId), + ne(jutsu.jutsuType, "AI"), + ...jutsuDatabaseFilter(input), + ), + ) + .orderBy(desc(rankedUserJutsu.equipped), desc(rankedUserJutsu.level)); + + return userjutsus.map((result) => ({ + ...result.RankedUserJutsu, + jutsu: { + ...result.Jutsu, + bloodline: result.Bloodline, + }, + })); +}; + /** * Build the DB filtering array, including new EXCLUSIONS. */ diff --git a/app/src/server/api/routers/profile.ts b/app/src/server/api/routers/profile.ts index 36c704677..fd300ac59 100644 --- a/app/src/server/api/routers/profile.ts +++ b/app/src/server/api/routers/profile.ts @@ -120,7 +120,7 @@ export const profileRouter = createTRPCRouter({ message: `Battle descriptions ${input.showBattleDescription ? "enabled" : "disabled"}`, }; }), - // Toggle audio setting + // Toggle audio setting toggleAudio: protectedProcedure .output( baseServerResponse.extend({ @@ -988,6 +988,7 @@ export const profileRouter = createTRPCRouter({ username: true, villageId: true, tavernMessages: true, + rankedLp: true, }, with: { village: true, @@ -1629,8 +1630,14 @@ export const fetchPublicUsers = async ( return [desc(userData.villagePrestige)]; case "Community": return [desc(userData.tavernMessages)]; + case "Ranked": + return [desc(userData.rankedLp)]; } }; + + // Set limit to 10 for specific categories + const effectiveLimit = ["Strongest", "Outlaws", "Ranked"].includes(input.orderBy) ? 10 : input.limit; + const [users, user] = await Promise.all([ client.query.userData.findMany({ where: and( @@ -1677,6 +1684,7 @@ export const fetchPublicUsers = async ( username: true, villagePrestige: true, tavernMessages: true, + rankedLp: true, }, // If AI, also include relations information with: { @@ -1699,7 +1707,7 @@ export const fetchPublicUsers = async ( : {}), }, offset: skip, - limit: input.limit, + limit: effectiveLimit, orderBy: getOrder(), }), ...(userId diff --git a/app/src/server/api/routers/rankedpvp.ts b/app/src/server/api/routers/rankedpvp.ts new file mode 100644 index 000000000..631562644 --- /dev/null +++ b/app/src/server/api/routers/rankedpvp.ts @@ -0,0 +1,79 @@ +import { createTRPCRouter, publicProcedure, protectedProcedure } from "@/api/trpc"; +import { drizzleDB } from "@/server/db"; +import { RankedDivisions } from "@/drizzle/constants"; +import { userData } from "@/drizzle/schema"; +import { gte, desc, inArray } from "drizzle-orm"; +import { z } from "zod"; + +export const rankedpvpRouter = createTRPCRouter({ + getPvpRank: publicProcedure + .input(z.object({ userId: z.string(), rankedLp: z.number() })) + .query(async ({ input }) => { + const { userId, rankedLp } = input; + + // Fetch top 10 players with rankedLp >= 900 + const topSannins = await drizzleDB + .select({ userId: userData.userId }) + .from(userData) + .where(gte(userData.rankedLp, 900)) + .orderBy(desc(userData.rankedLp)) + .limit(10); + + // Check if the user is in the top 10 + const isTopSannin = topSannins.some(player => player.userId === userId); + + if (isTopSannin) return "Sannin"; + if (rankedLp >= 900) return "Legend"; + + return ( + RankedDivisions + .slice() + .reverse() + .find(rank => rankedLp >= rank.rankedLp)?.name || "Wood" + ); + }), + + getPvpRanks: publicProcedure + .input(z.object({ userIds: z.array(z.string()) })) + .query(async ({ input }) => { + // If no userIds provided, return empty object + if (input.userIds.length === 0) return {}; + + // Fetch all users with their rankedLp + const users = await drizzleDB + .select({ userId: userData.userId, rankedLp: userData.rankedLp }) + .from(userData) + .where(inArray(userData.userId, input.userIds)); + + // Fetch top 10 players with rankedLp >= 900 + const topSannins = await drizzleDB + .select({ userId: userData.userId }) + .from(userData) + .where(gte(userData.rankedLp, 900)) + .orderBy(desc(userData.rankedLp)) + .limit(10); + + // Create a map of userId to rank + const ranks: Record = {}; + for (const user of users) { + if (!user.rankedLp) { + ranks[user.userId] = "Wood"; + continue; + } + + const isTopSannin = topSannins.some(player => player.userId === user.userId); + if (isTopSannin) { + ranks[user.userId] = "Sannin"; + } else if (user.rankedLp >= 900) { + ranks[user.userId] = "Legend"; + } else { + ranks[user.userId] = RankedDivisions + .slice() + .reverse() + .find(rank => user.rankedLp >= rank.rankedLp)?.name || "Wood"; + } + } + + return ranks; + }), +}); diff --git a/app/src/utils/rankedpvp.ts b/app/src/utils/rankedpvp.ts new file mode 100644 index 000000000..3b663163b --- /dev/null +++ b/app/src/utils/rankedpvp.ts @@ -0,0 +1,11 @@ +import { RankedDivisions } from "@/drizzle/constants"; + +// Get PvP Rank by LP +export const getPvpRank = (rating: number): string => { + return ( + RankedDivisions + .slice() + .reverse() + .find(rank => rating >= rank.rankedLp)?.name || "Wood" + ); +}; diff --git a/app/src/validators/user.ts b/app/src/validators/user.ts index f134eeac3..bd3f25f06 100644 --- a/app/src/validators/user.ts +++ b/app/src/validators/user.ts @@ -95,6 +95,7 @@ export const getPublicUsersSchema = z.object({ "Staff", "Outlaws", "Community", + "Ranked", ]), username: z.string().optional(), ip: z.string().optional(),