Conversation
Add Sage Mode - a new combat feature similar to bloodlines that requires manual activation during battle and provides temporary power boosts. Database: - Add sageMode and sageModeRolls tables - Add sageModeId and sageMasteryExperience to userData - Add sage mode constants (roll types, costs, swaps, mastery ranks) Combat Integration: - Add "sageMode" to fromType union for effect tracking - Protect sage mode effects from clear/copy/mirror in tags.ts - Add battle state tracking fields for sage mode activation - Add sageModes to ExtraState API: - Create full sageMode tRPC router with CRUD, roll, purchase, swap endpoints - Add SageModeValidator for content editing - Register router in root.ts Admin UI: - Create sage mode list page at /manual/sageMode - Create sage mode edit page with effects and afterEffects editors - Add SageModeFiltering component - Update ItemWithEffects and EditContent to support sageMode type Quest Integration: - Add reward_sage_mastery_experience to reward fields - Process sage mastery rewards on quest completion Resolves #664 Co-authored-by: theeneon <theeneon@users.noreply.github.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughIntroduces a complete Sage Mode system with database schema, configuration constants, API endpoints, frontend UI, validators, and integration into combat and quest mechanics. Enables users to unlock, swap, roll, and master sage modes with cooldown mechanics and reputation costs. Changes
Sequence DiagramsequenceDiagram
participant User as Client
participant API as API Router
participant DB as Database
participant Combat as Combat Engine
participant Discord as Discord Webhook
User->>API: Roll Sage Mode (natural/item)
activate API
API->>DB: Fetch user & rollable sage modes
DB-->>API: User data + filtered modes
API->>API: Check cooldowns, rank filters
API->>API: Random selection or pity logic
API->>DB: Create SageModeRolls entry
DB-->>API: Roll recorded
API->>DB: Update UserData.sageModeId
DB-->>API: User updated
API-->>User: Sage Mode granted + toast
deactivate API
User->>API: Swap Sage Mode
activate API
API->>DB: Check monthly swap count & cooldown
DB-->>API: Swap history
API->>API: Validate free swaps or charge cost
API->>DB: Update UserData.sageModeId + log
DB-->>API: Swap recorded
API->>Discord: Log swap (non-dev)
Discord-->>API: Logged
API-->>User: Swap confirmed
deactivate API
User->>Combat: Enter Battle
activate Combat
Combat->>DB: Fetch user with sage mode
DB-->>Combat: User + SageMode data
Combat->>Combat: Initialize BattleUserState.sageModeActivated
Note over Combat: Track activation round & expiration
Combat->>Combat: Apply sage mode effects each round
Combat-->>User: Battle with sage mode active
deactivate Combat
Estimated code review effort🎯 4 (Complex) | ⏱️ ~65 minutes The PR introduces substantial new functionality across the codebase with dense logic (particularly the 692-line Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile OverviewConfidence Score: 2/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant UI
participant SageModeRouter
participant Database
participant QuestSystem
participant CombatSystem
Note over User,Database: Sage Mode Acquisition Flow
User->>UI: Roll for sage mode
UI->>SageModeRouter: roll()
SageModeRouter->>Database: Check existing rolls
SageModeRouter->>SageModeRouter: Calculate random rank (S/A/B/C)
alt Got sage mode
SageModeRouter->>Database: Update userData.sageModeId
SageModeRouter->>Database: Insert sageModeRolls record
SageModeRouter-->>User: Success with sage mode name
else No sage mode
SageModeRouter->>Database: Insert empty roll (pity tracking)
SageModeRouter-->>User: Unable to connect with natural energy
end
Note over User,CombatSystem: Sage Mode Usage Flow
User->>UI: Purchase sage mode
UI->>SageModeRouter: purchaseSageMode()
SageModeRouter->>Database: Verify cost & village
SageModeRouter->>Database: Deduct reputation points
SageModeRouter->>Database: Update userData.sageModeId
Note over User,CombatSystem: Combat Integration (MISSING)
User->>CombatSystem: Enter battle
CombatSystem->>Database: Load user data
Note right of CombatSystem: ❌ Missing: sageMode relation
Note right of CombatSystem: ❌ Missing: extraState.sageModes population
CombatSystem-->>User: Sage mode not available in combat
Note over User,QuestSystem: Experience & Mastery Flow
User->>QuestSystem: Complete quest
QuestSystem->>Database: Award sage_mastery_experience
Database->>Database: Increment userData.sageMasteryExperience
Note right of Database: Mastery ranks: INITIATE → ADEPT → MASTER → LEGENDARY
|
📦 Next.js Bundle Analysis for tnrThis analysis was generated by the Next.js Bundle Analysis action. 🤖 This PR introduced no changes to the JavaScript bundle! 🙌 |
| case "SILVER": | ||
| return 0; | ||
| case "NORMAL": |
There was a problem hiding this comment.
SILVER supporters get 0 free swaps but logic shows they receive free swaps. Either update constant to 1 or fix the logic check.
The function returns 0 for SILVER but isFreeSwap on line 171 will be true if freeSwapsUsed < freeSwaps (0 < 0 = false), so this is correct. However, the code on line 204 suggests SILVER supporters should get free swaps based on the log message.
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/server/api/routers/sageMode.ts
Line: 658:660
Comment:
`SILVER` supporters get 0 free swaps but logic shows they receive free swaps. Either update constant to 1 or fix the logic check.
The function returns 0 for SILVER but `isFreeSwap` on line 171 will be true if `freeSwapsUsed < freeSwaps` (0 < 0 = false), so this is correct. However, the code on line 204 suggests SILVER supporters should get free swaps based on the log message.
How can I resolve this? If you propose a fix, please make it concise.| export const SAGE_MODE_SWAP_FREE_SILVER = 0; | ||
| export const SAGE_MODE_SWAP_FREE_GOLD = 1; |
There was a problem hiding this comment.
Verify intended values match the bloodline swap constants pattern (BLOODLINE_SWAP_FREE_SILVER = 0, BLOODLINE_SWAP_FREE_GOLD = 1)
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/drizzle/constants.ts
Line: 884:885
Comment:
Verify intended values match the bloodline swap constants pattern (BLOODLINE_SWAP_FREE_SILVER = 0, BLOODLINE_SWAP_FREE_GOLD = 1)
How can I resolve this? If you propose a fix, please make it concise.
Additional Comments (2)
Prompt To Fix With AIThis is a comment left during a code review.
Path: app/src/server/api/routers/combat.ts
Line: 1422:1422
Comment:
Missing `sageMode` relation in the combat user query. The `sageMode` field needs to be loaded here (like `bloodline`) for it to be available during combat initialization.
```suggestion
bloodline: true,
sageMode: true,
```
How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix With AIThis is a comment left during a code review.
Path: app/src/server/api/routers/combat.ts
Line: 2930:2930
Comment:
Need to add sage mode to `extraState` here, similar to how bloodline is added.
```suggestion
}
// Add sage mode
if (
user.sageMode &&
user.sageModeId &&
!extraState.sageModes[user.sageModeId]
) {
extraState.sageModes[user.sageModeId] = user.sageMode;
}
```
How can I resolve this? If you propose a fix, please make it concise. |
There was a problem hiding this comment.
Actionable comments posted: 10
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
app/src/libs/combat/types.ts (1)
296-303: Sage Mode data is not loaded into battle state.
TheextraState.sageModesmap is initialized as empty ininitiateBattle()(lines 1494, 2857) but never populated. Unlike other static data (jutsus, items, bloodlines, villages), there is no database query to fetch SageMode records. If combat logic attempts to accessextraState.sageModes[sageModeId], it will encounter undefined data.Add a sageMode database query to the
Promise.all()block and populateextraState.sageModesduring user processing, similar to how other static lookups are handled.
🤖 Fix all issues with AI agents
In `@app/drizzle/constants.ts`:
- Around line 1756-1757: The constant IMG_MANUAL_SAGE_MODE is pointing to the
same URL as the bloodline asset, causing the Sage Mode manual tile to reuse the
bloodline icon; update IMG_MANUAL_SAGE_MODE to the correct Sage Mode image URL
(or the intended asset identifier) so it references the proper image, ensuring
you change the value assigned to the IMG_MANUAL_SAGE_MODE constant in this file.
In `@app/drizzle/schema.ts`:
- Around line 642-654: The pityRolls column uses tinyint which cannot hold
PITY_SAGE_MODE_ROLLS (200); change the column definition in schema.ts
(pityRolls) from tinyint("pityRolls").default(0).notNull() to
smallint("pityRolls").unsigned().default(0).notNull() so it can store values up
to/above 200 while preserving default and notNull constraints.
- Around line 2017-2019: The battle initialization is missing sage mode data:
update the client.query.userData.findMany() call inside initiateBattle() to
include sageMode: true in its with clause, then map the returned userData
entries to populate extraState.sageModes using each user's sageModeId and
sageMasteryExperience (ensure keys match existing lookups so combat logic can
access sageModes by user id without undefined references).
In `@app/src/app/manual/sageMode/edit/`[sagemodeid]/page.tsx:
- Line 31: The call to setNullsToEmptyStrings(data) mutates the tRPC cache
object during render; instead create a sanitized, non-mutating copy (e.g. using
structuredClone or a deep clone) and memoize it with useMemo based on the
original data, then pass that sanitizedData to the edit form (the component
rendering the form in this page, locate where data is passed into the form).
Replace the in-place call to setNullsToEmptyStrings(data) with a pure sanitizer
function that returns a cloned object, or call setNullsToEmptyStrings on the
cloned object inside useMemo, ensuring the original data from the query is never
mutated.
In `@app/src/layout/SageModeFiltering.tsx`:
- Around line 1-10: This file uses React hooks (useUserData,
useContentFiltering) but is missing the client directive; add "use client"; as
the very first line of SageModeFiltering.tsx so the component is treated as a
client component and hooks like useUserData, useContentFiltering, buildFilter,
and defineFilteringSchema can run without crashing.
In `@app/src/server/api/routers/quests.ts`:
- Around line 1519-1520: Add a cap constant SAGE_MASTERY_EXP_CAP to the
constants module (same place as MEDNIN_EXP_CAP and MAX_SKILL_POINTS in
`@/drizzle/constants.ts`) and apply it when computing sageMasteryExperience in the
quests update logic: replace the current accumulation
(user.sageMasteryExperience + (rewards.reward_sage_mastery_experience ?? 0))
with Math.min(user.sageMasteryExperience +
(rewards.reward_sage_mastery_experience ?? 0), SAGE_MASTERY_EXP_CAP) so the
pattern matches existing capped fields like medicalExperience and skillPoints.
In `@app/src/server/api/routers/sageMode.ts`:
- Around line 682-690: The public filter sageModeDatabaseFilter currently honors
input.hidden allowing clients to fetch hidden entries; modify the filter to
prevent that by adding an authentication flag (e.g., add a boolean parameter
like isAuthenticated or isAdmin to sageModeDatabaseFilter) and change the hidden
branch so that when the caller is unauthenticated (flag false or omitted) you
always apply eq(sageMode.hidden, false); only allow honoring input.hidden when
isAuthenticated/isAdmin is true (or move the admin-only filtering to a protected
endpoint that calls a separate admin-only filter). Ensure you update callers
(e.g., the public getAll route) to pass the proper flag.
- Around line 58-63: The findMany call to ctx.drizzle.query.sageMode in the
pagination path lacks an orderBy, which makes offset paging non-deterministic;
update the call (the ctx.drizzle.query.sageMode.findMany invocation that uses
baseFilters, skip, and input.limit) to include a stable orderBy (for example by
id asc or createdAt desc or name asc) so pages are returned in a consistent
order across requests.
- Around line 40-44: The publicProcedure getAllNames currently returns all
sageMode records via ctx.drizzle.query.sageMode.findMany and may leak hidden
entries; update the query to only return non-hidden records by adding a filter
for hidden: false (or alternatively enforce an authorization check/role guard
before executing getAllNames) so that only allowed users can see hidden
modes—locate getAllNames and modify the call to findMany (or wrap it with the
existing auth/role utilities) to apply the hidden=false constraint or require
proper role scope.
- Around line 526-545: The update-and-log are run in parallel causing a log to
be inserted even when the guarded update affects 0 rows; change the Promise.all
to a sequential flow: first await the result of
client.update(userData).set(...).where(...) (the update call in this diff),
check its returned affected/updated-rows value (or boolean) and only if it's >0
proceed to client.insert(actionLog).values(...); if the update affected 0 rows,
do not insert the actionLog and return or throw an appropriate error/response to
indicate the operation failed. Ensure you use the actual result field returned
by your DB client (e.g., affectedRows/rowCount/numUpdated) to decide success.
🧹 Nitpick comments (2)
app/src/libs/quest.ts (1)
290-325: Consider adding tracker task for sage mastery experience.The experience tracker tasks section (lines 290-314) creates tasks for
medical_experience_gained,crafting_experience_gained,hunting_experience_gained, andgathering_experience_gained, but there's no equivalentsage_mastery_experience_gainedtask.If there are or will be quest objectives that track when a user gains sage mastery experience (similar to how quests can track "gain X medical experience"), a task entry would be needed here:
if (rewards.reward_sage_mastery_experience > 0) { experienceTrackerTasks.push({ task: "sage_mastery_experience_gained" as const, increment: rewards.reward_sage_mastery_experience, }); }If no such quest objectives are planned, this can be safely deferred.
app/src/hooks/sageMode.ts (1)
26-64: Rename loading flags for clarity.
l1/l2obscure intent; using descriptive boolean names improves readability.♻️ Proposed rename
- const { data: villages, isPending: l1 } = api.village.getAllNames.useQuery(undefined); + const { data: villages, isPending: isVillagesLoading } = + api.village.getAllNames.useQuery(undefined); - const { mutate: updateSageMode, isPending: l2 } = api.sageMode.update.useMutation({ + const { mutate: updateSageMode, isPending: isUpdatePending } = + api.sageMode.update.useMutation({ onSuccess: (data) => { showMutationToast(data); refetch(); }, }); - const loading = l1 || l2; + const loading = isVillagesLoading || isUpdatePending;As per coding guidelines: Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError).
| export const IMG_MANUAL_SAGE_MODE = | ||
| "https://uploadthing.b-cdn.net/f/Hzww9EQvYURJaCMo8gYYfKMcJ2B5EmWt6VsNgqxpG8OSXAQk"; |
There was a problem hiding this comment.
Verify the Sage Mode manual image asset.
IMG_MANUAL_SAGE_MODE currently matches the bloodline image URL, so the manual tile will reuse the bloodline icon. If that’s not intentional, swap in the correct asset.
🤖 Prompt for AI Agents
In `@app/drizzle/constants.ts` around lines 1756 - 1757, The constant
IMG_MANUAL_SAGE_MODE is pointing to the same URL as the bloodline asset, causing
the Sage Mode manual tile to reuse the bloodline icon; update
IMG_MANUAL_SAGE_MODE to the correct Sage Mode image URL (or the intended asset
identifier) so it references the proper image, ensuring you change the value
assigned to the IMG_MANUAL_SAGE_MODE constant in this file.
| id: varchar("id", { length: 191 }).primaryKey().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(), | ||
| userId: varchar("userId", { length: 191 }).notNull(), | ||
| sageModeId: varchar("sageModeId", { length: 191 }), | ||
| used: smallint("used").default(0).notNull(), | ||
| pityRolls: tinyint("pityRolls").default(0).notNull(), | ||
| type: mysqlEnum("type", consts.SAGE_MODE_ROLL_TYPES).default("NATURAL").notNull(), | ||
| goal: mysqlEnum("rank", consts.LetterRanks), |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -type f -name "*.ts" -o -name "*.tsx" | head -20Repository: studie-tech/TheNinjaRPG
Length of output: 647
🏁 Script executed:
fd -t f "constants" | grep -E "\.(ts|tsx)$"Repository: studie-tech/TheNinjaRPG
Length of output: 124
🏁 Script executed:
rg "PITY_SAGE_MODE_ROLLS" -t tsRepository: studie-tech/TheNinjaRPG
Length of output: 280
🏁 Script executed:
cat -n app/drizzle/schema.ts | sed -n '635,660p'Repository: studie-tech/TheNinjaRPG
Length of output: 1205
🏁 Script executed:
rg "smallint\|tinyint" app/drizzle/schema.ts -A 1 -B 1Repository: studie-tech/TheNinjaRPG
Length of output: 49
🏁 Script executed:
cat -n app/drizzle/constants.tsRepository: studie-tech/TheNinjaRPG
Length of output: 50380
🏁 Script executed:
rg "unsigned" app/drizzle/schema.ts -A 2 -B 2Repository: studie-tech/TheNinjaRPG
Length of output: 5214
🏁 Script executed:
rg "smallint\|tinyint" app/drizzle/schema.ts | head -20Repository: studie-tech/TheNinjaRPG
Length of output: 49
Increase pityRolls column range.
PITY_SAGE_MODE_ROLLS is 200, which exceeds a signed tinyint range (-128 to 127). This risks overflow or failed writes. Change to smallint with unsigned to safely store the value.
Proposed fix
- pityRolls: tinyint("pityRolls").default(0).notNull(),
+ pityRolls: smallint("pityRolls", { unsigned: true }).default(0).notNull(),📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| id: varchar("id", { length: 191 }).primaryKey().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(), | |
| userId: varchar("userId", { length: 191 }).notNull(), | |
| sageModeId: varchar("sageModeId", { length: 191 }), | |
| used: smallint("used").default(0).notNull(), | |
| pityRolls: tinyint("pityRolls").default(0).notNull(), | |
| type: mysqlEnum("type", consts.SAGE_MODE_ROLL_TYPES).default("NATURAL").notNull(), | |
| goal: mysqlEnum("rank", consts.LetterRanks), | |
| id: varchar("id", { length: 191 }).primaryKey().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(), | |
| userId: varchar("userId", { length: 191 }).notNull(), | |
| sageModeId: varchar("sageModeId", { length: 191 }), | |
| used: smallint("used").default(0).notNull(), | |
| pityRolls: smallint("pityRolls", { unsigned: true }).default(0).notNull(), | |
| type: mysqlEnum("type", consts.SAGE_MODE_ROLL_TYPES).default("NATURAL").notNull(), | |
| goal: mysqlEnum("rank", consts.LetterRanks), |
🤖 Prompt for AI Agents
In `@app/drizzle/schema.ts` around lines 642 - 654, The pityRolls column uses
tinyint which cannot hold PITY_SAGE_MODE_ROLLS (200); change the column
definition in schema.ts (pityRolls) from
tinyint("pityRolls").default(0).notNull() to
smallint("pityRolls").unsigned().default(0).notNull() so it can store values up
to/above 200 while preserving default and notNull constraints.
| sageModeId: varchar("sageModeId", { length: 191 }), | ||
| sageMasteryExperience: int("sageMasteryExperience").default(0).notNull(), | ||
| status: mysqlEnum("status", consts.UserStatuses).default("AWAKE").notNull(), |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find initiateBattle implementation in combat router
rg -n "initiateBattle" app/src/server/api/routers/combat.ts -A 50Repository: studie-tech/TheNinjaRPG
Length of output: 8886
🏁 Script executed:
#!/bin/bash
# Search for sageModeId and sageMasteryExperience usage in combat files
rg -n "sageModeId|sageMasteryExperience" app/src/server/api/routers/combat.ts app/src/libs/combat/Repository: studie-tech/TheNinjaRPG
Length of output: 164
🏁 Script executed:
#!/bin/bash
# Get the rest of initiateBattle function, specifically the usersState construction
rg -n "usersState" app/src/server/api/routers/combat.ts -B 5 -A 10 | head -100Repository: studie-tech/TheNinjaRPG
Length of output: 4070
🏁 Script executed:
#!/bin/bash
# Find processUsersForBattle function definition
rg -n "processUsersForBattle" app/src/libs/combat/ -A 50 | head -150Repository: studie-tech/TheNinjaRPG
Length of output: 49
🏁 Script executed:
#!/bin/bash
# Search for processUsersForBattle across entire app
rg -n "processUsersForBattle" app/src/ | head -20Repository: studie-tech/TheNinjaRPG
Length of output: 452
🏁 Script executed:
#!/bin/bash
# Get the processUsersForBattle function starting at line 2131
sed -n '2131,2250p' app/src/server/api/routers/combat.tsRepository: studie-tech/TheNinjaRPG
Length of output: 4100
🏁 Script executed:
#!/bin/bash
# Search for CombatQueryUser type definition
rg -n "type CombatQueryUser|interface CombatQueryUser" app/src/ -A 40Repository: studie-tech/TheNinjaRPG
Length of output: 5891
🏁 Script executed:
#!/bin/bash
# Find UserData type/interface definition
rg -n "type UserData|interface UserData" app/src/ -A 100 | head -150Repository: studie-tech/TheNinjaRPG
Length of output: 49
🏁 Script executed:
#!/bin/bash
# Check the User table definition around the specified lines
sed -n '2010,2030p' app/drizzle/schema.tsRepository: studie-tech/TheNinjaRPG
Length of output: 1436
🏁 Script executed:
#!/bin/bash
# Search for the query that fetches users for combat battles
rg -n "fetchedUsers|query.userData|userData.findMany" app/src/server/api/routers/combat.ts -B 5 -A 15 | head -200Repository: studie-tech/TheNinjaRPG
Length of output: 5880
🏁 Script executed:
#!/bin/bash
# Get more of the userData.findMany query around line 1420
sed -n '1420,1530p' app/src/server/api/routers/combat.tsRepository: studie-tech/TheNinjaRPG
Length of output: 4023
🏁 Script executed:
#!/bin/bash
# Check schema for sageMode relation definition
rg -n "sageMode" app/drizzle/schema.ts -B 2 -A 2Repository: studie-tech/TheNinjaRPG
Length of output: 2262
🏁 Script executed:
#!/bin/bash
# Search for sageMode usage in combat logic
rg -n "\.sageMode\.|sageMode\." app/src/server/api/routers/combat.ts app/src/libs/combat/ | head -30Repository: studie-tech/TheNinjaRPG
Length of output: 49
🏁 Script executed:
#!/bin/bash
# Search more broadly for sage in combat context
rg -n "sage" app/src/server/api/routers/combat.ts app/src/libs/combat/ -iRepository: studie-tech/TheNinjaRPG
Length of output: 7903
🏁 Script executed:
#!/bin/bash
# Get context around line 2857 where sageModes is initialized
sed -n '2840,2880p' app/src/server/api/routers/combat.tsRepository: studie-tech/TheNinjaRPG
Length of output: 1049
🏁 Script executed:
#!/bin/bash
# Search for where sageModes entries are added to extraState
rg -n "extraState.sageModes|sageModes\[" app/src/server/api/routers/combat.ts -A 3Repository: studie-tech/TheNinjaRPG
Length of output: 49
Load sageMode data into battle state during initiateBattle().
The sageModeId and sageMasteryExperience fields are not being included in the battle initialization. Add sageMode: true to the with clause of the client.query.userData.findMany() query (around line 1420) and populate extraState.sageModes with sage mode data so combat logic can access these values without encountering undefined references.
🤖 Prompt for AI Agents
In `@app/drizzle/schema.ts` around lines 2017 - 2019, The battle initialization is
missing sage mode data: update the client.query.userData.findMany() call inside
initiateBattle() to include sageMode: true in its with clause, then map the
returned userData entries to populate extraState.sageModes using each user's
sageModeId and sageMasteryExperience (ensure keys match existing lookups so
combat logic can access sageModes by user id without undefined references).
| { retry: false, enabled: !!sageModeId && !!userData }, | ||
| ); | ||
|
|
||
| setNullsToEmptyStrings(data); |
There was a problem hiding this comment.
Don’t mutate query data during render.
setNullsToEmptyStrings(data) mutates the tRPC cache object in render, which can cause subtle cache/state bugs. Create a sanitized copy (memoized) and pass that to the edit form instead.
♻️ Suggested change (memoized clone)
-import { useEffect, use } from "react";
+import { useEffect, useMemo } from "react";
...
- setNullsToEmptyStrings(data);
+ const normalizedData = useMemo(() => {
+ if (!data) return null;
+ const copy = {
+ ...data,
+ effects: [...data.effects],
+ afterEffects: [...data.afterEffects],
+ };
+ setNullsToEmptyStrings(copy as unknown as Record<string, unknown>);
+ return copy;
+ }, [data]);
...
- if (isPending || !userData || !canChangeContent(userData.role) || !data) {
+ if (isPending || !userData || !canChangeContent(userData.role) || !normalizedData) {
return <Loader explanation="Loading data" />;
}
- return <SingleEditSageMode sageMode={data} refetch={refetch} />;
+ return <SingleEditSageMode sageMode={normalizedData} refetch={refetch} />;🤖 Prompt for AI Agents
In `@app/src/app/manual/sageMode/edit/`[sagemodeid]/page.tsx at line 31, The call
to setNullsToEmptyStrings(data) mutates the tRPC cache object during render;
instead create a sanitized, non-mutating copy (e.g. using structuredClone or a
deep clone) and memoize it with useMemo based on the original data, then pass
that sanitizedData to the edit form (the component rendering the form in this
page, locate where data is passed into the form). Replace the in-place call to
setNullsToEmptyStrings(data) with a pure sanitizer function that returns a
cloned object, or call setNullsToEmptyStrings on the cloned object inside
useMemo, ensuring the original data from the query is never mutated.
| import { LetterRanks } from "@/drizzle/constants"; | ||
| import { useUserData } from "@/utils/UserContext"; | ||
| import { canChangeContent } from "@/utils/permissions"; | ||
| import { | ||
| ContentFiltering, | ||
| useContentFiltering, | ||
| buildFilter, | ||
| defineFilteringSchema, | ||
| } from "@/layout/ContentFiltering"; | ||
| import type { LetterRank } from "@/drizzle/constants"; |
There was a problem hiding this comment.
Add "use client"; — hooks in a server component will crash.
This file uses React hooks, so it must be marked as a client component.
✅ Required fix
+"use client";
+
import { LetterRanks } from "@/drizzle/constants";
import { useUserData } from "@/utils/UserContext";📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| import { LetterRanks } from "@/drizzle/constants"; | |
| import { useUserData } from "@/utils/UserContext"; | |
| import { canChangeContent } from "@/utils/permissions"; | |
| import { | |
| ContentFiltering, | |
| useContentFiltering, | |
| buildFilter, | |
| defineFilteringSchema, | |
| } from "@/layout/ContentFiltering"; | |
| import type { LetterRank } from "@/drizzle/constants"; | |
| "use client"; | |
| import { LetterRanks } from "@/drizzle/constants"; | |
| import { useUserData } from "@/utils/UserContext"; | |
| import { canChangeContent } from "@/utils/permissions"; | |
| import { | |
| ContentFiltering, | |
| useContentFiltering, | |
| buildFilter, | |
| defineFilteringSchema, | |
| } from "@/layout/ContentFiltering"; | |
| import type { LetterRank } from "@/drizzle/constants"; |
🤖 Prompt for AI Agents
In `@app/src/layout/SageModeFiltering.tsx` around lines 1 - 10, This file uses
React hooks (useUserData, useContentFiltering) but is missing the client
directive; add "use client"; as the very first line of SageModeFiltering.tsx so
the component is treated as a client component and hooks like useUserData,
useContentFiltering, buildFilter, and defineFilteringSchema can run without
crashing.
| sageMasteryExperience: | ||
| user.sageMasteryExperience + (rewards.reward_sage_mastery_experience ?? 0), |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
rg -n "SAGE.*CAP|SAGE.*MAX|sageMastery.*Max" --type=tsRepository: studie-tech/TheNinjaRPG
Length of output: 49
🏁 Script executed:
cat -n app/drizzle/constants.ts | head -100Repository: studie-tech/TheNinjaRPG
Length of output: 3475
🏁 Script executed:
sed -n '1485,1525p' app/src/server/api/routers/quests.tsRepository: studie-tech/TheNinjaRPG
Length of output: 1845
🏁 Script executed:
rg -n "MEDNIN_EXP_CAP|MAX_SKILL_POINTS" --type=tsRepository: studie-tech/TheNinjaRPG
Length of output: 1598
🏁 Script executed:
rg -n "sageMastery|sage_mastery" --type=ts -A 2 | head -80Repository: studie-tech/TheNinjaRPG
Length of output: 2662
🏁 Script executed:
fd "schema" -e ts --type f | head -10Repository: studie-tech/TheNinjaRPG
Length of output: 88
🏁 Script executed:
rg -n "sageMastery|sage.*rank|mastery.*rank" --type=ts -B 2 -A 2 | head -100Repository: studie-tech/TheNinjaRPG
Length of output: 6349
🏁 Script executed:
rg -n "sageMode|sage" app/drizzle/schema.ts -B 2 -A 2Repository: studie-tech/TheNinjaRPG
Length of output: 4772
🏁 Script executed:
rg -n "sage" app/drizzle/constants.ts -iRepository: studie-tech/TheNinjaRPG
Length of output: 1089
🏁 Script executed:
rg -n "sageMasteryExperience" --type=ts -B 3 -A 3 | grep -E "(CAP|MAX|THRESHOLD|>|>=|<|<=)" -B 2 -A 2Repository: studie-tech/TheNinjaRPG
Length of output: 49
🏁 Script executed:
fd "sage" -e ts --type f | xargs rg -l "sageMastery" | head -10Repository: studie-tech/TheNinjaRPG
Length of output: 49
🏁 Script executed:
rg -n "sageMastery" app/src/server/api/routers/sageMode.ts -B 2 -A 2Repository: studie-tech/TheNinjaRPG
Length of output: 49
🏁 Script executed:
sed -n '888,920p' app/drizzle/constants.tsRepository: studie-tech/TheNinjaRPG
Length of output: 1022
🏁 Script executed:
rg -n "SAGE_MASTERY" app/drizzle/constants.ts -A 10Repository: studie-tech/TheNinjaRPG
Length of output: 628
🏁 Script executed:
rg -n "sageMasteryExperience" app/src --type=ts -B 2 -A 2 | head -80Repository: studie-tech/TheNinjaRPG
Length of output: 664
🏁 Script executed:
rg -n "SAGE_MASTERY_REQUIRED_EXP" --type=ts -B 2 -A 2Repository: studie-tech/TheNinjaRPG
Length of output: 385
🏁 Script executed:
rg -n "sageMasteryExperience" app/src --type=ts -C 3Repository: studie-tech/TheNinjaRPG
Length of output: 828
🏁 Script executed:
rg -n "sageMastery" app/src --type=tsx -l | head -10Repository: studie-tech/TheNinjaRPG
Length of output: 94
🏁 Script executed:
rg -n "sageMastery" app/src/app --type=tsx -B 2 -A 2 | head -60Repository: studie-tech/TheNinjaRPG
Length of output: 94
🏁 Script executed:
fd "sageMastery" app/src -e tsx -e ts 2>/dev/null | head -10Repository: studie-tech/TheNinjaRPG
Length of output: 49
🏁 Script executed:
rg -n "sageMastery" app/src -t ts -B 2 -A 2 | head -100Repository: studie-tech/TheNinjaRPG
Length of output: 664
🏁 Script executed:
sed -n '100,130p' app/src/server/api/routers/hospital.tsRepository: studie-tech/TheNinjaRPG
Length of output: 1413
🏁 Script executed:
sed -n '110,130p' app/src/server/api/routers/hospital.tsRepository: studie-tech/TheNinjaRPG
Length of output: 1102
Add a cap constant for sage mastery experience.
The code accumulates sageMasteryExperience without a cap, unlike medicalExperience (capped at MEDNIN_EXP_CAP = 4,000,000) and skillPoints (capped at MAX_SKILL_POINTS = 100). However, the sage mastery feature defines rank thresholds with a maximum at LEGENDARY: 400,000. Add SAGE_MASTERY_EXP_CAP to @/drizzle/constants.ts and apply it using Math.min() to match the established pattern, or confirm that uncapped experience is intentional.
🤖 Prompt for AI Agents
In `@app/src/server/api/routers/quests.ts` around lines 1519 - 1520, Add a cap
constant SAGE_MASTERY_EXP_CAP to the constants module (same place as
MEDNIN_EXP_CAP and MAX_SKILL_POINTS in `@/drizzle/constants.ts`) and apply it when
computing sageMasteryExperience in the quests update logic: replace the current
accumulation (user.sageMasteryExperience +
(rewards.reward_sage_mastery_experience ?? 0)) with
Math.min(user.sageMasteryExperience + (rewards.reward_sage_mastery_experience ??
0), SAGE_MASTERY_EXP_CAP) so the pattern matches existing capped fields like
medicalExperience and skillPoints.
| getAllNames: publicProcedure.query(async ({ ctx }) => { | ||
| return await ctx.drizzle.query.sageMode.findMany({ | ||
| columns: { id: true, name: true, image: true }, | ||
| orderBy: (table, { asc }) => [asc(table.name)], | ||
| }); |
There was a problem hiding this comment.
Public getAllNames can expose hidden content.
If hidden modes are meant to stay private, this endpoint should filter hidden = false or be protected/role-checked.
🔒 Example guard
return await ctx.drizzle.query.sageMode.findMany({
- columns: { id: true, name: true, image: true },
+ columns: { id: true, name: true, image: true },
+ where: eq(sageMode.hidden, false),
orderBy: (table, { asc }) => [asc(table.name)],
});🤖 Prompt for AI Agents
In `@app/src/server/api/routers/sageMode.ts` around lines 40 - 44, The
publicProcedure getAllNames currently returns all sageMode records via
ctx.drizzle.query.sageMode.findMany and may leak hidden entries; update the
query to only return non-hidden records by adding a filter for hidden: false (or
alternatively enforce an authorization check/role guard before executing
getAllNames) so that only allowed users can see hidden modes—locate getAllNames
and modify the call to findMany (or wrap it with the existing auth/role
utilities) to apply the hidden=false constraint or require proper role scope.
| const results = await ctx.drizzle.query.sageMode.findMany({ | ||
| with: { village: { columns: { name: true } } }, | ||
| where: and(...baseFilters), | ||
| offset: skip, | ||
| limit: input.limit, | ||
| }); |
There was a problem hiding this comment.
Add a deterministic orderBy for pagination.
Offset pagination without orderBy can return inconsistent pages as the dataset changes. Add a stable order (e.g., name/id/createdAt) to keep paging deterministic.
♻️ Suggested change
const results = await ctx.drizzle.query.sageMode.findMany({
with: { village: { columns: { name: true } } },
where: and(...baseFilters),
+ orderBy: (table, { asc }) => [asc(table.name)],
offset: skip,
limit: input.limit,
});🤖 Prompt for AI Agents
In `@app/src/server/api/routers/sageMode.ts` around lines 58 - 63, The findMany
call to ctx.drizzle.query.sageMode in the pagination path lacks an orderBy,
which makes offset paging non-deterministic; update the call (the
ctx.drizzle.query.sageMode.findMany invocation that uses baseFilters, skip, and
input.limit) to include a stable orderBy (for example by id asc or createdAt
desc or name asc) so pages are returned in a consistent order across requests.
| await Promise.all([ | ||
| client | ||
| .update(userData) | ||
| .set({ | ||
| sageModeId: mode?.id || null, | ||
| reputationPoints: user.reputationPoints - repCost, | ||
| }) | ||
| .where( | ||
| and(eq(userData.userId, user.userId), gte(userData.reputationPoints, repCost)), | ||
| ), | ||
| client.insert(actionLog).values({ | ||
| id: nanoid(), | ||
| userId: user.userId, | ||
| tableName: "user", | ||
| changes: [logMsg], | ||
| relatedId: user.userId, | ||
| relatedMsg: "SageMode Changed", | ||
| relatedImage: user.avatarLight || user.avatar || IMG_AVATAR_DEFAULT, | ||
| }), | ||
| ]); |
There was a problem hiding this comment.
Ensure update success before logging.
updateSageMode logs in Promise.all even if the guarded update updates 0 rows (e.g., concurrent rep spend). That can produce incorrect logs and “success” responses. Check the update result and only log when it succeeds.
✅ Safer sequencing
- await Promise.all([
- client
- .update(userData)
- .set({
- sageModeId: mode?.id || null,
- reputationPoints: user.reputationPoints - repCost,
- })
- .where(
- and(eq(userData.userId, user.userId), gte(userData.reputationPoints, repCost)),
- ),
- client.insert(actionLog).values({
- id: nanoid(),
- userId: user.userId,
- tableName: "user",
- changes: [logMsg],
- relatedId: user.userId,
- relatedMsg: "SageMode Changed",
- relatedImage: user.avatarLight || user.avatar || IMG_AVATAR_DEFAULT,
- }),
- ]);
+ const updateResult = await client
+ .update(userData)
+ .set({
+ sageModeId: mode?.id || null,
+ reputationPoints: user.reputationPoints - repCost,
+ })
+ .where(and(eq(userData.userId, user.userId), gte(userData.reputationPoints, repCost)));
+
+ if (!updateResult.rowsAffected) {
+ throw serverError("BAD_REQUEST", "Unable to update sage mode");
+ }
+
+ await client.insert(actionLog).values({
+ id: nanoid(),
+ userId: user.userId,
+ tableName: "user",
+ changes: [logMsg],
+ relatedId: user.userId,
+ relatedMsg: "SageMode Changed",
+ relatedImage: user.avatarLight || user.avatar || IMG_AVATAR_DEFAULT,
+ });🤖 Prompt for AI Agents
In `@app/src/server/api/routers/sageMode.ts` around lines 526 - 545, The
update-and-log are run in parallel causing a log to be inserted even when the
guarded update affects 0 rows; change the Promise.all to a sequential flow:
first await the result of client.update(userData).set(...).where(...) (the
update call in this diff), check its returned affected/updated-rows value (or
boolean) and only if it's >0 proceed to client.insert(actionLog).values(...); if
the update affected 0 rows, do not insert the actionLog and return or throw an
appropriate error/response to indicate the operation failed. Ensure you use the
actual result field returned by your DB client (e.g.,
affectedRows/rowCount/numUpdated) to decide success.
| export const sageModeDatabaseFilter = (input?: SageModeFilteringSchema) => { | ||
| return [ | ||
| ...(input?.name ? [like(sageMode.name, `%${input.name}%`)] : []), | ||
| ...(input?.village ? [eq(sageMode.villageId, input.village)] : []), | ||
| ...(input?.rank ? [eq(sageMode.rank, input.rank)] : [isNotNull(sageMode.rank)]), | ||
| ...(input?.level ? [eq(sageMode.level, input.level)] : []), | ||
| ...(input?.hidden !== undefined | ||
| ? [eq(sageMode.hidden, input.hidden)] | ||
| : [eq(sageMode.hidden, false)]), |
There was a problem hiding this comment.
Public filtering allows fetching hidden modes by passing hidden=true.
Since getAll is public, a client can override the default and read hidden content. Consider forcing hidden=false for unauthenticated users or moving admin filtering to a protected endpoint.
🤖 Prompt for AI Agents
In `@app/src/server/api/routers/sageMode.ts` around lines 682 - 690, The public
filter sageModeDatabaseFilter currently honors input.hidden allowing clients to
fetch hidden entries; modify the filter to prevent that by adding an
authentication flag (e.g., add a boolean parameter like isAuthenticated or
isAdmin to sageModeDatabaseFilter) and change the hidden branch so that when the
caller is unauthenticated (flag false or omitted) you always apply
eq(sageMode.hidden, false); only allow honoring input.hidden when
isAuthenticated/isAdmin is true (or move the admin-only filtering to a protected
endpoint that calls a separate admin-only filter). Ensure you update callers
(e.g., the public getAll route) to pass the proper flag.
|
@MathiasGruber @Phrosfire will be working on this one. |
|
Let's keep it as draft until done then :) then I know I don't need to look |
Phros is out of commission this week and I know this PR has been opened for awhile you can complete it if you want to. |
This PR addresses issue #664
Generated with Claude Code
Summary by CodeRabbit
Release Notes
✏️ Tip: You can customize this high-level summary in your review settings.