Implementation Plan · The Liquidation Stakes · Next.js + Prisma + MySQL + Express.js
✦ v2 — Karma Quest System · Circle Level · Scoring Engine · Full API Contract

The Liquidation Stakes — Hội nhóm có thời hạn và sự đánh đổi

Feature brief Phá vỡ tư duy "nhóm là vĩnh viễn". Mỗi nhóm được sinh ra với một lượng Năng lượng (Energy HP), có cấp độ từ 1–5 và tích lũy EXP qua thời gian. AI chấm điểm bài đăng theo thang 0–10 để cộng/trừ HP. Karma kiếm được qua nhiệm vụ hàng ngày (3 quest/ngày), có thể đốt để cứu nhóm với hệ quả cá nhân. Khi HP về 0, nhóm tự hủy vĩnh viễn và để lại Soul Stone.
DB Tables mới
10 tables
API Routes
20 endpoints
Stack chính
Next.js · Prisma · MySQL
AI Layer
Claude / LLM · Cron Job
Circle Levels
5 cấp · HP scale
⚠ Không cam kết trong MVP
01

Feature Overview

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.

Energy System — Hệ thống Năng lượng
Mỗi nhóm có HP (0–max theo level). Deep Talk cộng HP, im lặng rút 10 HP/giờ. Giao diện render trạng thái Healthy → Sick → Dying → Dead.
CircleEnergy table cron: drain-energy GET /energy
🧠
AI Scoring Engine — Bộ lọc chất lượng
Bài đăng được LLM chấm 0–10 theo 5 tiêu chí. Score quyết định HP delta (+5 / +3 / +1 / 0 / -1 / -3) và EXP delta. Cache theo sha256(content).
LLM Judge CirclePostQualityLog async queue
🚑
CPR Post — Hồi sức cấp cứu
Khi HP < 10%, admin kích hoạt CPR Post. Bài mở trong 24h. Nếu tổng deep-talk comments đủ ngưỡng → hồi 50% HP. Nhóm level 3+ được CPR 2 lần/tuần.
CprSession table POST /cpr cron: check-cpr
🔥
Burning Karma — Hiến tế cá nhân
Thành viên đốt Karma để cứu nhóm. 1 Karma → 10 HP, max 50% HP/lần. Sau hiến tế: bị hạn chế đăng bài 48h, nhận HeroBadge.
KarmaTransaction POST /sacrifice HeroBadge
Circle Level System — Hệ thống cấp độ nhóm NEW
Nhóm tích lũy EXP từ bài đăng chất lượng, thành viên mới, CPR sống sót. Cấp 1–5, mỗi cấp mở thêm max HP và giảm drain rate. Level 5 kích hoạt statusPeak 🔥.
CircleExpLog 5 levels Auto level-up
🎯
Daily Quest & Karma NEW
3 nhiệm vụ hàng ngày (reset 00:00 ICT). Mỗi quest hoàn thành → +1 Karma. Max 3 Karma/ngày — thiết kế chậm để Burning Karma có ý nghĩa. Quest tính sau AI judge xong.
DailyQuest table UserQuestLog table GET /quests/daily
02

AI Scoring Engine — Cách tính điểm chi tiết

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.
📐 Ghi chú thiết kế

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.

Prompt Structure — AI Judge

server/services/ai-judge.service.ts — JUDGE_PROMPT
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)
}

Anti-Gaming trong Scoring

🔒 Content Hash Cache
Vấn đề: user copypaste bài cũ trong nhóm khác để farm HP.
Fix: Cache kết quả judge theo sha256(content.trim().toLowerCase()) trong Redis 1h. Content trùng → nhận score cached = 0, không gọi LLM.
📏 Pre-filter Checks
Vấn đề: gọi LLM tốn tiền cho những bài rõ ràng là rác.
Fix: Trước khi gọi LLM, check: nội dung < 10 ký tự → score 0 ngay. Chỉ emoji → score 0. URL spam pattern (≥ 3 links) → score 0. Tiết kiệm ~30% LLM calls.
⏱ Rate Limiting
Vấn đề: flood bài trong 1 giờ để farm HP trước khi drain cron chạy.
Fix: Max 5 posts/giờ/user/circle. Bài thứ 6+ trong giờ → auto score 0, không trừ HP nhóm, chỉ lưu log. Dùng Redis sliding window.
🔄 Async Queue — Không Block Post
Vấn đề: LLM chậm 2–5s, user đợi mới thấy bài là UX tệ.
Fix: POST bài → trả 202 Accepted ngay. Đẩy vào BullMQ job queue. Judge xong → update HP + emit SSE event. UI hiển thị badge "Đang đánh giá..." trong meantime.
03

Circle Level & EXP System

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.

Nguồn EXP — ExpReason

POST_QUALITY
+2 / +6 / +10 EXP
Bài đăng được AI judge. EXP theo bậc score: Solid (+2), Deep Talk (+6), Masterpiece (+10).
Trigger: sau khi judge xong, async
MEMBER_JOIN
+5 EXP
Thành viên mới chấp nhận lời mời hoặc join circle public. Mỗi user chỉ count 1 lần.
Trigger: CircleMember.create
CPR_SURVIVED
+50 EXP
Nhóm sống sót qua CPR session thành công. Phần thưởng lớn — khuyến khích cộng đồng cứu nhóm.
Trigger: CprSession resolved = true, success = true
KARMA_SACRIFICE
+5 EXP
Khi có thành viên đốt Karma để cứu nhóm. EXP về circle, không về user. Tạo incentive nhóm.
Trigger: sacrifice endpoint success

Level-up Logic — Luồng xử lý

EXP gained CircleExpLog.create checkLevelUp(circleId) if total_exp ≥ threshold[level+1] level++, maxHp update notify all members
⚠ Implementation Note — Level Up Transaction

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.

04

Daily Quest & Karma Acquisition

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.

🧮 Karma Economy — Tại sao thiết kế chậm

Để đố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:

Min 4 ngày farming → 1 lần cứu nhóm đáng kể (100 HP)

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ự.

Quest Pool — Danh sách nhiệm vụ

💬
DEEP_COMMENT
Bình luận sâu
Viết 1 bình luận được AI judge đạt score ≥ 6 trong bất kỳ circle nào bạn là thành viên.
+1 Karma khi claim
⚠ Progress chỉ update sau AI judge async xong (không realtime)
✍️
QUALITY_POST
Bài viết chất lượng
Đăng 1 bài được AI judge đạt score ≥ 7 trong circle. Chỉ tính 1 lần đầu tiên trong ngày dù post nhiều.
+1 Karma khi claim
⚠ Không tính nếu bài bị rate-limit (flood guard)
🤝
HELP_OTHERS
Tương tác cộng đồng
Reply vào 3 thread khác nhau, mỗi reply được AI judge score ≥ 5. Phải là reply vào bài của người khác.
+1 Karma khi claim
⚠ Không count reply vào bài của chính mình

Quest Selection & Reset Mechanism

00:00 ICT Cron: reset-daily-quests mark completed=false UserQuestLog date = today 3 quests ready
📋 Ghi chú thiết kế — All users cùng pool

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.

Quest Progress Flow — Phân luồng chi tiết

User đăng bài / comment POST /posts (202 Accepted) BullMQ: judge-post job
Judge job xong score ≥ threshold? updateQuestProgress(userId, questCode) progress == requirement? quest.completed = true (claimable)
User vào trang quest POST /quests/:id/claim KarmaTransaction.create (+1) UserKarma.increment
🔐 Idempotency — Claim 2 lần
Vấn đề: user double-click claim, 2 request cùng lúc → Karma cộng 2 lần.
Fix: Set claimedAt ngay trong transaction. Check WHERE claimedAt IS NULL trước khi insert KarmaTransaction. Race condition → 1 request succeed, 1 trả 409.
📅 Date Boundary — Timezone
Vấn đề: Server UTC, user Việt Nam GMT+7 → ngày "hôm nay" lệch 7 tiếng.
Fix: Lưu date trong UserQuestLog theo ICT (UTC+7). Cron reset chạy lúc 17:00 UTC = 00:00 ICT. Query date dùng DATE_TZ helper.
05

Milestones — 5-Day Sprint

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).

Slice 1
Ngày 1

Schema + Prisma + API Skeleton

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.

prisma/schema.prisma server/routes/circles.ts server/routes/quests.ts server/routes/karma.ts prisma/seed.ts
Slice 2
Ngày 1–2

Energy System + AI Judge + Level Up

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.

server/services/energy.service.ts server/services/ai-judge.service.ts server/services/level.service.ts server/cron/drain-energy.cron.ts
Slice 3
Ngày 2–3

Daily Quest + Karma Service NEW

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.

server/services/quest.service.ts server/services/karma.service.ts server/cron/reset-quests.cron.ts server/routes/quests.ts
Slice 4
Ngày 3

Glitchy UI — Aging Interface (Next.js)

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.

components/CircleHeader.tsx components/PostCard.tsx hooks/useCircleEnergy.ts components/QuestPanel.tsx
Slice 5
Ngày 3–4

CPR Post + Burning Karma + HeroBadge

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.

server/cron/check-cpr.cron.ts server/services/sacrifice.service.ts components/SacrificeModal.tsx components/HeroBadge.tsx
Slice 6
Ngày 4

Museum of Echoes — Soul Stone

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.

server/services/soul-stone.service.ts app/museum/page.tsx app/museum/[publicId]/page.tsx server/cron/purge-dead.cron.ts
Slice 7
Ngày 5

Polish + Anti-Bug Pass + Smoke Test

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.

test/energy.spec.ts test/quest-claim.spec.ts test/sacrifice.spec.ts test/level-up.spec.ts
06

API Contract — Full Route Table

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 qualityScorejudgeStatus: 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.createUserKarma.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.
07

Data Flow — Luồng dữ liệu tổng thể

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.

User Posts Next.js form Express API POST /posts 202 Accepted → BullMQ judge-post job queue async AI Judge LLM score 0–10 6 categories HP + EXP Update +5/+3/+1/0/−1/−3 HP +10/+6/+2/0 EXP MySQL DB CircleEnergy Quest Progress Update UserQuestLog.progress++ Level Up? checkLevelUp() Cron: hourly drain −10 HP / giờ im lặng → trừ HP HP = 0 → Die soft-delete circle trigger soul-stone Soul Stone LLM async summary Museum Page /museum (public) CPR / Sacrifice Admin CPR hoặc Burning Karma Heal +HP CPR: +50% HP Karma: 1K → 10HP Daily Quest 3 quests / ngày reset 00:00 ICT Karma Earned +1 Karma / claim max 3 / ngày đốt karma → cứu nhóm

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

08

UI Mockups

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.

Trạng thái: Healthy (HP > 30%) · Level 2
threads.net/circles/dev-vietnam
/circles/dev-vietnam
Dev Vietnam 🇻🇳 Lv.2 🌿
842 thành viên · 14 ngày · 340 / 500 EXP to Lv.3
⚡ Năng lượng 470 / 650 HP
● Healthy −9 HP/giờ (Lv.2) +3HP Deep Talk
Trạng thái: Dying (HP < 10%) — Glitch UI
threads.net/circles/startup-saigon
/circles/startup-saigon
St̷a̷r̷tup̶ Saigon
247 thành viên · 30 ngày · Lv.1
⚡ Năng lượng 42 / 500 HP
⚠ Hấp hối Còn ~4h
"Mình đang build một SaaS về HR, ai có kinh nghiệm về hiring trong startup..."
NHẤN GIỮ ĐỂ ĐỌC
Quest Panel — Daily Karma (GET /quests/daily)
🎯 Nhiệm vụ hôm nay
Reset 00:00 ICT
✓ Bình luận sâu
+1 Karma
Đã nhận · 09:32 AM
Tương tác cộng đồng
2 / 3
Reply thêm 1 thread nữa để nhận
Bài viết chất lượng
Chưa hoàn thành (0/1)
Karma hiện tại
7 ⚡
Burning Karma — Sacrifice Modal
threads.net/circles/startup-saigon — sacrifice
/circles/startup-saigon/sacrifice
🔥 Burning Karma — Hiến tế cá nhân
Đốt Karma để cứu nhóm. Sau hiến tế: bị hạn chế đăng bài 48h và nhận Hero Badge 🦸.
7 Karma 2 Karma = +50 HP cho nhóm
Xác nhận Hiến tế 5 Karma Hủy
09

Museum of Echoes — Soul Stone

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.

🪨 Soul Stone · Museum of Echoes · Lv.4 Veteran — Featured
Startup Saigon — Đã tan rã · 47 ngày tồn tại
Thời gian sống
47 ngày
Thành viên
1,240 người
Đỉnh HP
924 / 950
Level đạt được
Lv.4 🏆
Tử vong
Bỏ bê 72h
Deep Posts
312 bài
10

Key Code Snippets

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ể.

server/services/ai-judge.service.ts
// 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 };
}
server/services/level.service.ts — checkLevelUp
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");
  });
}
server/cron/drain-energy.cron.ts
// 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);
    }
  }
}
server/services/quest.service.ts — claimQuest
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 };
  });
}
server/routes/circles.ts — POST /:id/sacrifice
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" });
  }
});
server/services/soul-stone.service.ts
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() }
    })
  ]);
}
11

DB Schema — Prisma (New & Updated Tables)

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.

prisma/schema.prisma — DailyQuest + UserQuestLog
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")
}
prisma/schema.prisma — KarmaTransaction + HeroBadge
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
}
prisma/schema.prisma — CircleEnergy (updated fields)
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")
}
prisma/schema.prisma — CprSession (updated fields)
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
}
12

Anti-Bug & Race Condition Guard

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.

⚡ Race: Sacrifice cùng lúc
Scenario: User double-tap nút sacrifice → 2 request cùng đến, cả 2 đọc karma = 7 → cả 2 trừ 5 → karma = -3.
Fix: SELECT ... FOR UPDATE trên user_karma row trong transaction. Chỉ 1 transaction lock được row, cái sau chờ → khi chờ xong đọc lại karma đã bị trừ → throw INSUFFICIENT_KARMA → trả 409.
⭐ Race: Level Up 2 lần
Scenario: 2 bài đăng gần nhau, cả 2 judge xong cùng lúc, cả 2 thấy exp = 195 → cả 2 trigger level-up → level tăng 2 lần.
Fix: SELECT ... FOR UPDATE trên circle_energy trong checkLevelUp(). Transaction 1 lock, update level. Transaction 2 khi lock được → đọc lại thấy level đã đúng → bỏ qua.
🎯 Race: Quest Claim 2 lần
Scenario: User click "Nhận thưởng" 2 lần nhanh → 2 request cùng check claimedAt IS NULL → cả 2 credit karma.
Fix: Update claimedAt trong transaction đầu tiên. Unique constraint trên (userId, questId, date). Request thứ 2 update thấy claimedAt != null → throw 409. Ngoài ra FE disable button sau click đầu tiên.
⏱ Cron Miss — Drift HP
Scenario: Server restart lúc 14:00, cron 14:00 bị miss. Nhóm không bị trừ HP 1 giờ → "được sống miễn phí".
Fix: Field lastDrainAt trong CircleEnergy. Khi cron chạy: tính hoursElapsed = (now - lastDrainAt) / 3600000 → trừ hoursElapsed * drainRate. Catch-up tự động.
🔁 Double Death — HP = 0 trigger 2 lần
Scenario: Cron drain + post spam −3 HP cùng lúc → cả 2 thấy HP đang = 3 → cả 2 trigger group death → SoulStone bị generate 2 lần.
Fix: triggerGroupDeath() dùng conditional update: UPDATE ... SET deletedAt=now() WHERE deletedAt IS NULL. Nếu 0 rows affected → bỏ qua, không generate SoulStone. Idempotent death.
📅 Quest Progress Rollback
Scenario: Bài đạt quest → progress++ → bài bị report và score bị override xuống 0 → progress đã count nhưng bài không còn "valid".
Design decision: Không rollback quest progress khi bài bị report. Lý do: report flow phức tạp, không block core MVP. Note rõ trong code. V2 sẽ xử lý với QuestProgressHistory table.
💸 HP Overflow — Heal vượt max
Scenario: Nhóm HP = 480/500 (Lv.1), user đốt 10 karma → +100 HP → HP = 580 > max 500.
Fix: Khi update HP dùng MIN(current + hpGain, max) ở tầng DB hoặc service. Check trước sacrifice: nếu nhóm HP ≥ max → báo lỗi "Nhóm đang đầy HP". Không cho lãng phí karma.
🏃 Rate Limit Bypass
Scenario: User dùng nhiều account (multi-account) để spam bài từ các account khác nhau trong cùng 1 nhóm.
Fix MVP: Rate limit theo userId (không phải IP). Đây là enough cho MVP. V2 thêm device fingerprint và content similarity check cross-user (same content hash từ nhiều user = coordinated spam).
13

Risks & Mitigations

Risk
Sev
Mitigation
LLM chậm làm post bị delay — UX tệ khi user đợi score xong mới thấy bài.
HIGH
POST bài ngay, trả 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.
Spam cùng nội dung để bypass AI judge — clone content để farm HP.
HIGH
Cache kết quả judge theo 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.
Race condition: sacrifice / level-up / quest claim đồng thời → data sai.
HIGH
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.
Karma quá dễ kiếm nếu quest threshold quá thấp — nhóm gần bất tử.
HIGH
Giữ max 3 Karma/ngày cứng trong schema. Quest minScore được set từ 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.
Cron drain chạy khi server restart → bỏ qua chu kỳ, nhóm không bị trừ HP đúng hẹn.
MED
Lưu 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.
Soul Stone generation gọi LLM mất 5–10s — user thấy nhóm "đã chết" nhưng museum chưa có.
MED
Soft-delete group ngay. Tạo Soul Stone async trong background job. Museum filter 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.
Quest progress không sync realtime — user hoàn thành quest nhưng chưa thấy claimable ngay.
MED
Quest Panel poll 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).
Level up notification spam — nhóm active level up nhiều lần trong ngày làm phiền thành viên.
LOW
Level up là sự kiện hiếm (cần 200+ EXP). Notification chỉ 1 lần, không repeat. Hiển thị in-app notification, không push notification. Nếu cần, add cooldown 24h trên notification per-user per-circle.
Glitch UI quá nặng trên mobile — filter + blur CSS làm chậm máy yếu.
LOW
Detect 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.
14

Open Questions

Drain rate −10 HP/giờ có hợp lý với Lv.1 max HP 500?
Nhóm im lặng hoàn toàn sẽ chết sau 50h (~2 ngày). Đây là intentional — nhóm mới phải prove giá trị ngay. Nếu quá nhanh → nhóm thật chết oan trước khi build được momentum. Đề xuất: theo dõi median lifetime của Lv.1 circles tuần đầu, target 5–7 ngày với hoạt động bình thường.
Quyết định trước Slice 2 · ảnh hưởng toàn bộ cron logic
CPR targetComments ngưỡng mặc định là 10 — có hợp lý không?
10 deep-talk comments trong 24h là khá cao cho nhóm nhỏ. Có thể scale theo số thành viên: targetComments = max(5, memberCount * 0.05). Nhóm 100 người cần 5 comments, nhóm 200 người cần 10. Không block Slice 4 — field targetComments admin set khi activate CPR.
Quyết định cuối Slice 3 · không block Slice 4
Quest pool 3 quest — có nên random per-user hay all users cùng pool?
All users cùng pool (thiết kế hiện tại) đơn giản hơn và tạo hiệu ứng social. Nhưng random per-user tạo experience cá nhân hóa hơn và khó game hơn (user không thể coordinate để farm quest cùng nhau). Trong 5 ngày: giữ all-same-pool cho đơn giản. V2 xem xét seed random per userId.
Giữ all-same-pool cho MVP · Review sau tuần đầu
Karma balance có nên public không?
Public karma tạo status symbol — user với 50 karma được ngưỡng mộ, user đốt hết được gọi là "anh hùng". Nhưng cũng tạo áp lực xã hội. Đề xuất: hiển thị trong profile nhưng không show số tuyệt đối, chỉ show tier (Newcomer / Active / Veteran / Legend) và HeroBadge count. Bảo vệ user mới.
Confirm với product trước Slice 5
Museum nên public hay private theo từng nhóm?
Public museum tạo drama tốt — người ngoài vào xem "nhóm này chết vì ai". Private thì bảo vệ thành viên. Đề xuất: admin chọn visibility khi tạo nhóm, default public sau 30 ngày từ lúc chết. Trong MVP: tất cả public để maximize drama và discovery.
Confirm với product trước Slice 6