% HP trả
về
deletedAt) + cronjob dọn sau 24h để tránh race
condition
6 cơ chế cốt lõi — 4 cũ từ v1, 2 mới trong v2. Mỗi cơ chế độc lập nhưng ăn khớp nhau thành một vòng lặp gameplay.
sha256(content).
statusPeak 🔥.
Thay vì 3 mốc cứng (0 / +5 / −1), hệ thống mới dùng thang 0–10 với 6 bậc HP delta + EXP delta riêng cho từng bậc. Phong phú hơn, tạo incentive rõ ràng hơn.
| Score | Label | HP Δ | EXP Δ | Tiêu chí nhận diện |
|---|---|---|---|---|
| 9 – 10 | 🔥 Masterpiece | +5 HP | +10 EXP | Tranh luận sâu sắc, kiến thức chuyên môn, trải nghiệm cá nhân độc đáo, đặt câu hỏi mở kích thích thảo luận tiếp theo. Bài dài ≥ 100 từ, có cấu trúc rõ. |
| 7 – 8 | 💬 Deep Talk | +3 HP | +6 EXP | Ý kiến rõ ràng có lập luận, đóng góp hữu ích cho chủ đề nhóm, không lan man. Có thể ngắn nhưng phải substantive. |
| 5 – 6 | ✅ Solid | +1 HP | +2 EXP | Bình luận có nội dung, đúng chủ đề, không đặc sắc nhưng không hại. Câu hỏi thật sự muốn biết đáp án cũng tính vào đây. |
| 3 – 4 | 😐 Neutral | 0 HP | 0 EXP | Câu ngắn hợp lệ, không hại không giúp ích. "Cảm ơn", "Mình hiểu rồi", những phản hồi giao tiếp bình thường. |
| 1 – 2 | 🗑️ Noise | −1 HP | 0 EXP | Hời hợt, lạc đề nhẹ, nội dung quá sơ sài. "ok", "hay đấy", "chấm", react rác. Loãng chủ đề nhưng chưa đến mức spam. |
| 0 | ☠️ Toxic | −3 HP | 0 EXP | Spam, copypaste không chỉnh sửa, emoji đơn lẻ, quảng cáo trắng trợn, vi phạm nội quy nhóm, nội dung thù địch. |
Score 5–6 (Solid) chỉ +1 HP nhưng vẫn cộng — tránh tạo cảm giác "bài thường là vô nghĩa". Bài score 0 (Toxic) trừ −3 HP nặng hơn Noise −1 để tạo deterrent rõ ràng. EXP chỉ tăng khi score ≥ 5 — không khuyến khích farm bài neutral.
SYSTEM: Bạn là bộ lọc chất lượng nội dung cho mạng xã hội cộng đồng. Chấm điểm đoạn text từ 0–10 dựa trên 5 tiêu chí sau: [1] Độ sâu nội dung — có kiến thức / trải nghiệm thật không? [2] Tính kích thích — có thể trigger thảo luận tiếp theo không? [3] Tính liên quan — có đúng chủ đề nhóm không? (context nhóm được cung cấp) [4] Originality — có phải copypaste / boilerplate không? [5] Constructiveness — có hướng đến giải pháp / chia sẻ không? Rubric nhanh: 9-10 → Masterpiece: 3-5 tiêu chí đều xuất sắc 7-8 → Deep Talk: ít nhất 3 tiêu chí tốt 5-6 → Solid: relevant + có nội dung thật 3-4 → Neutral: không hại, không đặc sắc 1-2 → Noise: hời hợt hoặc lạc đề nhẹ 0 → Toxic: spam / vi phạm / vô nghĩa hoàn toàn Quan trọng: đánh giá theo INTENT và SUBSTANCE, không phải độ dài thuần túy. Chỉ trả về JSON, không markdown, không text thêm. USER: Tên nhóm: {{circleName}} Chủ đề nhóm: {{circleDescription}} --- Nội dung cần đánh giá: {{content}} OUTPUT FORMAT: { "score": number (0-10, có thể dùng số thập phân như 6.5), "category": "masterpiece"|"deep_talk"|"solid"|"neutral"|"noise"|"toxic", "reason": "lý do ngắn gọn bằng tiếng Việt, tối đa 1 câu", "flags": ["spam"|"copypaste"|"off_topic"|"hate"] (array, có thể rỗng) }
Nhóm bắt đầu ở cấp 1. EXP tích lũy từ nhiều nguồn. Mỗi lần lên cấp:
max HP tăng, drain giảm nhẹ, và unlock tính năng mới. Level 5 là đỉnh
— kích hoạt statusPeak 🔥.
| Level | Tên | EXP cần tích lũy | Max HP | Drain rate | Unlock / Bonus |
|---|---|---|---|---|---|
| Lv.1 🌱 | Newborn | 0 EXP (mặc định) | 500 HP | −10 HP/giờ | Tính năng cơ bản. Tồn tại tối đa ~50h nếu không có bài nào. |
| Lv.2 🌿 | Growing | 200 EXP | 650 HP | −9 HP/giờ | Unlock CPR 1 lần/tuần. Custom circle description dài hơn. |
| Lv.3 🌳 | Thriving | 500 EXP | 800 HP | −8 HP/giờ | CPR 2 lần/tuần. Badge nhóm nổi bật trong feed. Invite link tự tạo. |
| Lv.4 🏆 | Veteran | 1000 EXP | 950 HP | −7 HP/giờ | Museum priority — Soul Stone được highlight. CPR ngưỡng thấp hơn 20%. |
| Lv.5 🔥 | Legend | 2000 EXP | 1000 HP | −6 HP/giờ |
statusPeak = true 🔥 — hiệu ứng ngọn lửa trên tên
nhóm. Được pin lên discover page.
|
Level-up phải dùng prisma.$transaction với
SELECT FOR UPDATE trên CircleEnergy row.
Tránh race condition khi 2 bài đồng thời trigger level-up cùng lúc.
Chỉ 1 transaction thành công update level, cái còn lại sẽ đọc lại và
thấy level đã đúng → bỏ qua.
Cơ chế kiếm Karma duy nhất trong MVP. Chủ ý thiết kế chậm — max 3 Karma/ngày — để Burning Karma (1 Karma = 10 HP) luôn là quyết định có trọng lượng.
Để đốt 10 Karma cứu nhóm (= +100 HP), user cần tối thiểu 4 ngày hoàn thành đủ 3 quest mỗi ngày. Điều này tạo ra:
Nếu Karma dễ kiếm hơn → nhóm gần như bất tử → mất toàn bộ drama và urgency của cơ chế. Karma phải đủ hiếm để việc đốt trở thành hi sinh thật sự.
Tất cả users trong cùng ngày đều nhận cùng 3 quest (không random
per-user). Điều này tạo ra "social quest" — mọi người cùng làm mục
tiêu chung và có thể nhắc nhau. Đơn giản hóa cron logic: chỉ cần 1
bản DailyQuest active cho cả ngày, không cần generate
per-user.
7 slice theo thứ tự phụ thuộc. Slice 1–2 là nền tảng bắt buộc. Slice 3–7 có thể song song sau khi schema xong. Slice 4 (Quest/Karma) chạy song song với Slice 3 (Energy).
Tạo toàn bộ 10 table, Prisma migration MySQL. Seed dữ liệu test
đủ để FE làm việc độc lập. Viết tất cả route handlers với body
501 Not Implemented — contract phải xong trước mọi
thứ khác. FE có thể mock từ đây.
Logic cộng/trừ HP khi post bài. Kết nối LLM, viết JUDGE_PROMPT.
Cron drain-energy chạy mỗi giờ trừ 10 HP nếu im
lặng. Level-up service sau mỗi EXP gain. Soft-delete khi HP = 0.
Quest progress update sau AI judge job. Claim API với idempotency check. KarmaTransaction log. Cron reset quest 00:00 ICT. Có thể làm song song với Slice 2 sau khi schema xong.
FE đọc energyPercent + level từ API và
render hiệu ứng lão hóa. >30% = normal, 10–30% = grayscale +
font glitch CSS, <10% = blur bài đăng. Polling 30s, không cần
WebSocket.
Admin UI kích hoạt CPR session. Cron check 24h sau tính kết quả. Burning Karma: validate → SELECT FOR UPDATE → trừ karma → cộng HP → restriction 48h → HeroBadge create. Hiển thị badge trong profile.
Khi nhóm chết: gọi LLM async tóm tắt "cuộc đời" nhóm. Lưu
CircleSoulStone. Trang /museum liệt kê
Soul Stones. Level 4+ được highlight. Purge vật lý sau 24h từ
soft-delete.
Kiểm tra toàn bộ race conditions. Verify idempotency trên claim, sacrifice, level-up. Test cron trên staging với fast-forward time. Kiểm tra mobile glitch performance. Document API cho FE.
20 endpoints đầy đủ cho FE và BE. Tất cả route prefix
/api/v1. Auth = JWT Bearer. Routes có
auth
bắt buộc token. Routes
pub
public. Thiết kế xong là cắm vô dùng ngay.
| Method | Route | Auth | Response codes | Mô tả / Request body / Notes |
|---|---|---|---|---|
| ⚡ Energy & Circle State | ||||
| GET | /circles/:publicId/energy | pub | 200 | 404 |
Trả về
{current, max, percent, level, exp, expToNext, stage,
drainRate, lastDrainAt}. FE dùng để render HP bar và glitch CSS. Không cache — luôn
fresh.
|
| GET | /circles/:publicId/exp-log | pub | 200 | 404 |
Lịch sử EXP của nhóm. Query params:
?limit=20&cursor=. Trả về
{logs: [{expDelta, reason, createdAt, userId}],
nextCursor}.
|
| 📝 Posts & AI Judge | ||||
| POST | /circles/:publicId/posts | auth | 202 | 400 | 403 | 429 |
Body: {content: string, parentId?: number}. Lưu bài
ngay, đẩy judge job vào queue. 202 Accepted → bài
đang được đánh giá. 429 khi vượt rate limit 5
posts/giờ. 403 khi user bị restriction.
|
| GET | /circles/:publicId/posts | pub | 200 | 404 |
Cursor-based pagination. Query:
?limit=20&cursor=&sort=latest|quality. Response
include qualityScore và
judgeStatus: pending|done. Khi nhóm dying, FE dùng
stage để blur posts.
|
| GET | /posts/:postId/judge-status | pub | 200 | 404 |
Polling endpoint. FE gọi sau 202 để check tiến độ. Trả về
{status: "pending"|"done", score?, category?, hpDelta?,
expDelta?}. FE poll mỗi 3s, max 10 lần.
|
| 🚑 CPR Session | ||||
| POST | /circles/:publicId/cpr | admin | 201 | 400 | 403 | 409 |
Admin kích hoạt CPR. 400 nếu HP ≥ 10%.
409 nếu đã có session active. 400 nếu
vượt CPR quota tuần (level 2: 1x, level 3+: 2x). Body:
{targetComments: number}. Trả về
{sessionId, expiresAt, postId}.
|
| GET | /circles/:publicId/cpr/status | pub | 200 | 404 |
Trả về CPR session hiện tại (nếu có).
{active: bool, currentComments, targetComments, timeLeft,
expiresAt}. FE dùng để hiện countdown và progress bar.
|
| 🔥 Burning Karma — Sacrifice | ||||
| POST | /circles/:publicId/sacrifice | auth | 200 | 400 | 409 |
Body: {karmaAmount: number}. Validate: karma đủ,
không đang bị restriction. SELECT FOR UPDATE → atomic.
409 nếu race condition. Response:
{hpGained, newKarma, restrictionUntil, badgeGranted}.
|
| 🎯 Daily Quest | ||||
| GET | /quests/daily | auth | 200 |
Trả về 3 quest của hôm nay + progress của user.
{quests: [{id, code, description, karmaReward, requirement,
progress, completed, claimedAt}]}. FE hiển thị progress bar và nút Claim khi
completed=true && claimedAt=null.
|
| POST | /quests/:questId/claim | auth | 200 | 400 | 409 |
400 nếu quest chưa completed. 409 nếu
đã claimed hôm nay (idempotency). Transaction:
claimedAt = now() →
KarmaTransaction.create →
UserKarma.increment. Response:
{karmaEarned, newTotal}.
|
| ⚡ Karma | ||||
| GET | /users/me/karma | auth | 200 |
Số dư + lịch sử giao dịch.
{balance, transactions: [{delta, reason, circleId?,
createdAt}], restriction?}. Kèm restriction nếu đang bị hạn chế đăng bài.
|
| GET | /users/me/badges | auth | 200 |
Danh sách HeroBadge.
{badges: [{type, circleId, circleName, createdAt}]}. FE hiện badge trong profile và hover tooltip "Đã hi sinh cho
nhóm X".
|
| 🪨 Museum of Echoes | ||||
| GET | /museum | pub | 200 |
Danh sách Soul Stone. Query:
?limit=20&cursor=&sort=recent|longestLived. Trả về
tóm tắt ngắn. Level 4+ circle được featured: true →
hiển thị trên cùng.
|
| GET | /museum/:publicId | pub | 200 | 404 |
Chi tiết Soul Stone:
{summary, peakHp, livedDays, topPostIds, maxLevel,
totalMembers, heroes: [{userId, badgeType}]}. Top posts render dưới dạng archive (blur nếu private circle).
|
| 🔔 Internal / Webhook | ||||
| POST | /internal/judge-complete | internal | 200 |
BullMQ worker gọi sau khi judge xong. Body:
{postId, score, category, hpDelta, expDelta, flags[]}. Update HP, EXP, CirclePostQualityLog, trigger quest progress,
check level-up. Internal key authentication.
|
| GET | /circles/:publicId/members | auth | 200 | 403 | Danh sách thành viên + role + karma balance cá nhân (chỉ hiện với admin). FE dùng để show top contributors và heroes. |
| GET | /circles/:publicId/stats | pub | 200 |
Stats tổng hợp:
{totalPosts, deepTalkCount, averageScore, topContributors[],
cprCount, sacrificeCount}. FE hiện trong tab "Thống kê" của circle.
|
Luồng chính: POST bài → AI Judge async → Cập nhật HP + EXP + Quest Progress → Check Level Up → Cron drain → Kill hoặc hồi sinh. Quest / Karma là luồng song song.
Luồng xanh lá = hồi sinh · Luồng đỏ = giết nhóm / soft-delete · Luồng tím = AI + Quest/Karma · Đường nét đứt = async / cron
4 trạng thái UI chính. Client chỉ nhận energyPercent +
level + stage và tự áp dụng class. Quest
Panel hiển thị dựa vào GET /quests/daily.
Khi nhóm chết, LLM async tóm tắt "cuộc đời" thành Soul Stone. Level cao hơn → Soul Stone được featured trong museum. Level 4+ được highlight đặc biệt.
6 đoạn code quan trọng nhất: AI Judge, Energy Drain, Sacrifice, Level-up, Quest Claim, Soul Stone. Mỗi đoạn giải quyết 1 vấn đề cụ thể.
// Pre-filter trước khi gọi LLM function preFilter(content: string): number | null { if (content.trim().length < 10) return 0; if (/^[\p{Emoji}\s]+$/u.test(content)) return 0; if ((content.match(/https?:\/\//g)||[]).length > 3) return 0; return null; // pass to LLM } export async function judgePost( content: string, circleCtx: { name: string; description: string } ) { // 1. Pre-filter cheap checks const preScore = preFilter(content); if (preScore !== null) return { score: 0, hp: -3, exp: 0 }; // 2. Content hash cache const hash = sha256(content.trim().toLowerCase()); const cached = await redis.get(`judge:${hash}`); if (cached) return JSON.parse(cached); // 3. Call LLM const result = await llm.complete(JUDGE_PROMPT, { circleName: circleCtx.name, content }); const { score } = JSON.parse(result); // 4. Map score → HP/EXP delta const hp = score >= 9 ? 5 : score >= 7 ? 3 : score >= 5 ? 1 : score >= 3 ? 0 : score >= 1 ? -1 : -3; const exp = score >= 9 ? 10 : score >= 7 ? 6 : score >= 5 ? 2 : 0; await redis.setEx(`judge:${hash}`, 3600, JSON.stringify({ score, hp, exp })); return { score, hp, exp }; }
const LEVEL_CONFIG = [ { level: 1, expRequired: 0, maxHp: 500, drain: 10 }, { level: 2, expRequired: 200, maxHp: 650, drain: 9 }, { level: 3, expRequired: 500, maxHp: 800, drain: 8 }, { level: 4, expRequired: 1000, maxHp: 950, drain: 7 }, { level: 5, expRequired: 2000, maxHp: 1000, drain: 6 }, ]; export async function checkLevelUp(circleId: number) { await prisma.$transaction(async (tx) => { // SELECT FOR UPDATE — prevent race condition const energy = await tx.$queryRaw<CircleEnergy[]> `SELECT * FROM circle_energy WHERE circle_id = ${circleId} FOR UPDATE`; const { level, exp } = energy[0]; const next = LEVEL_CONFIG[level]; // next level config if (!next || exp < next.expRequired) return; await tx.circleEnergy.update({ where: { circleId }, data: { level: { increment: 1 }, max: next.maxHp, // current HP không đổi, chỉ max tăng } }); // Level 5 → bật statusPeak if (next.level === 5) { await tx.circle.update({ where: { id: circleId }, data: { statusPeak: true } }); } await notifyAllMembers(circleId, "LEVEL_UP"); }); }
// Chạy mỗi giờ — cron "0 * * * *" export async function drainEnergy() { const now = new Date(); const oneHourAgo = new Date(now - 3_600_000); // Nhóm im lặng: lastPostAt cũ hơn 1h const silentCircles = await prisma.circle.findMany({ where: { deletedAt: null, lastPostAt: { lt: oneHourAgo } }, include: { circleEnergy: true } }); for (const circle of silentCircles) { const e = circle.circleEnergy; const drainAmount = LEVEL_CONFIG[e.level - 1].drain; const newHp = Math.max(0, e.current - drainAmount); await prisma.circleEnergy.update({ where: { circleId: circle.id }, data: { current: newHp, lastDrainAt: now } }); if (newHp === 0) { await triggerGroupDeath(circle.id); } } }
export async function claimQuest( userId: string, questId: number ) { return prisma.$transaction(async (tx) => { // Idempotency: check đã claim chưa const log = await tx.userQuestLog.findUnique({ where: { userId_questId_date: { userId, questId, date: todayICT() } } }); if (!log?.completed) throw new Error("Quest chưa hoàn thành"); if (log.claimedAt) throw new Error("Đã claim rồi"); // 409 // Atomic: mark claimed + credit karma await tx.userQuestLog.update({ where: { id: log.id }, data: { claimedAt: new Date() } }); await tx.karmaTransaction.create({ data: { userId, delta: 1, reason: "QUEST_REWARD" } }); await tx.userKarma.upsert({ where: { userId }, update: { karma: { increment: 1 } }, create: { userId, karma: 1 } }); return { karmaEarned: 1 }; }); }
router.post("/:id/sacrifice", auth, async (req, res) => { const { karmaAmount } = req.body; const userId = req.user.id; if (!karmaAmount || karmaAmount < 1) return res.status(400).json({ error: "Invalid" }); try { const result = await prisma.$transaction(async (tx) => { // SELECT FOR UPDATE on karma row const [karma] = await tx.$queryRaw<UserKarma[]> `SELECT * FROM user_karma WHERE user_id = ${userId} FOR UPDATE`; if (!karma || karma.karma < karmaAmount) throw new Error("INSUFFICIENT_KARMA"); const hpGain = Math.min(karmaAmount * 10, 500); await Promise.all([ tx.userKarma.update({ where: { userId }, data: { karma: { decrement: karmaAmount } } }), tx.circleEnergy.update({ where: { circleId: req.params.id }, data: { current: { increment: hpGain } } }), tx.karmaTransaction.create({ data: { userId, delta: -karmaAmount, reason: "BURN_FOR_CIRCLE", circleId: parseInt(req.params.id) } }), tx.userRestriction.create({ data: { userId, type: "POST_BAN", expiresAt: new Date(Date.now() + 172_800_000) } }), tx.heroBadge.create({ data: { userId, circleId: parseInt(req.params.id), type: "KARMA_SACRIFICE" } }) ]); return hpGain; }); res.json({ hpGained: result }); } catch (e) { if (e.message === "INSUFFICIENT_KARMA") return res.status(400).json({ error: e.message }); res.status(409).json({ error: "Race condition" }); } });
export async function generateSoulStone( circleId: string ) { const circle = await prisma.circle.findUnique({ where: { publicId: circleId }, include: { circlePostQualityLogs: { orderBy: { score: "desc" }, take: 10 }, circleMembers: true, circleEnergy: true, heroBadges: true } }); const summary = await llm.complete(` Tóm tắt lịch sử nhóm "${circle.name}" thành văn bia ~100 chữ. Tập trung: đỉnh cao (HP ${circle.circleEnergy.peak}), level đạt được (${circle.circleEnergy.level}), lý do tử vong, di sản để lại. Văn phong trang trọng, bi tráng nhưng không bi thảm. `); await prisma.$transaction([ prisma.circleSoulStone.create({ data: { circleId, summary, peakHp: circle.circleEnergy.peak, livedDays: calcDays(circle.createdAt), topPostIds: circle.circlePostQualityLogs .map(p => p.postId) } }), prisma.circle.update({ where: { publicId: circleId }, data: { deletedAt: new Date() } }) ]); }
Chỉ liệt kê các table mới và các field bổ sung. Bảng cũ giữ nguyên. Tổng cộng 10 table, tất cả đều có index trên FK.
model DailyQuest { id Int @id @default(autoincrement()) code String @unique // "DEEP_COMMENT" description String @db.VarChar(255) karmaReward Int @default(1) requirement Int // số lần cần hoàn thành minScore Int // AI score threshold isActive Boolean @default(true) questLogs UserQuestLog[] @@map("daily_quests") } model UserQuestLog { id Int @id @default(autoincrement()) userId String @map("user_id") questId Int @map("quest_id") progress Int @default(0) completed Boolean @default(false) claimedAt DateTime? @map("claimed_at") date DateTime @db.Date // ICT date, reset key user User @relation(fields: [userId], references: [id]) quest DailyQuest @relation(fields: [questId], references: [id]) @@unique([userId, questId, date]) @@index([userId, date]) @@map("user_quest_logs") }
model KarmaTransaction { id Int @id @default(autoincrement()) userId String @map("user_id") delta Int // +1 quest, -5 đốt karma reason KarmaReason circleId Int? @map("circle_id") createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id]) @@index([userId]) @@map("karma_transactions") } enum KarmaReason { QUEST_REWARD BURN_FOR_CIRCLE } model HeroBadge { id Int @id @default(autoincrement()) userId String @map("user_id") circleId Int @map("circle_id") type BadgeType createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id]) circle Circle @relation(fields: [circleId], references: [id]) @@index([userId]) @@map("hero_badges") } enum BadgeType { KARMA_SACRIFICE // đốt karma cứu nhóm CPR_HERO // comment trong CPR thành công FOUNDER // người tạo nhóm }
model CircleEnergy { id Int @id @default(autoincrement()) circleId Int @map("circle_id") @unique level Int @default(1) exp Int @default(0) current Int @default(500) max Int @default(500) peak Int @default(500) // NEW fields: lastDrainAt DateTime? @map("last_drain_at") // Dùng để compensate nếu cron bị miss // Logic: (now - lastDrainAt) / 1h * drainRate createdAt DateTime @default(now()) updatedAt DateTime @updatedAt circle Circle @relation(fields: [circleId], references: [id]) @@index([circleId]) @@map("circle_energy") }
model CprSession { id String @id @default(cuid()) circleId String // publicId of circle activatedBy String // userId of admin expiresAt DateTime resolved Boolean @default(false) success Boolean? // NEW fields: targetComments Int @default(10) // ngưỡng deep-talk comments để CPR thành công currentComments Int @default(0) // realtime count, tăng sau mỗi judge xong postId Int? // bài CPR cụ thể createdAt DateTime @default(now()) circle Circle @relation( fields: [circleId], references: [publicId]) @@map("cpr_sessions") } // CircleExpLog thêm reason enum: enum ExpReason { POST_QUALITY // bài score 5+ MEMBER_JOIN // thành viên mới join CPR_SURVIVED // sống sót qua CPR KARMA_SACRIFICE // có member đốt karma }
Liệt kê đầy đủ các điểm dễ bug nhất của hệ thống và chiến lược phòng ngừa cụ thể. Đây là section quan trọng nhất để làm đúng từ đầu.
202 Accepted. AI judge chạy async
qua BullMQ. UI hiển thị badge "Đang đánh giá" và poll
/posts/:id/judge-status mỗi 3s, tối đa 10 lần.
sha256(content.trim().toLowerCase()) trong Redis 1h.
Content trùng nhận score 0 ngay, không gọi LLM. Rate limit 5
posts/giờ/user/circle.
prisma.$transaction với
SELECT FOR UPDATE trên các row quan trọng. Mỗi
critical path có idempotency check. Double-click → 409 Conflict
thay vì double-execute. Chi tiết xem Section 12.
DailyQuest.minScore — có thể điều chỉnh không cần
redeploy. Monitor karma velocity tuần đầu, tăng threshold nếu cần.
lastDrainAt vào CircleEnergy. Khi
cron chạy, tính số giờ bị bỏ qua và trừ bù catchup. Dùng
persistent cron (BullMQ repeatable job) thay vì in-memory nếu
production.
WHERE soulStone IS NOT NULL. User thấy
"Đang lưu di sản..." badge trong vài giây. Không ảnh hưởng UX
chính.
GET /quests/daily mỗi 15s. Sau mỗi
hành động có thể trigger quest (post, comment), FE gọi lại API
ngay để update progress. Latency tối đa ~LLM judge time (2–5s).
prefers-reduced-motion. Glitch chỉ áp dụng
trên desktop. Mobile chỉ đổi màu thanh HP sang đỏ + warning text,
không dùng CSS filter. Level 5 statusPeak flame effect dùng CSS,
không canvas.
targetComments admin set khi
activate CPR.