feat: add frame
This commit is contained in:
@@ -169,6 +169,9 @@ func main() {
|
||||
r.Get("/carpets", ecoH.Carpets)
|
||||
r.Post("/shop/buy-carpet", ecoH.BuyCarpet)
|
||||
r.Post("/shop/select-carpet", ecoH.SelectCarpet)
|
||||
r.Get("/frames", ecoH.Frames)
|
||||
r.Post("/shop/buy-frame", ecoH.BuyFrame)
|
||||
r.Post("/shop/select-frame", ecoH.SelectFrame)
|
||||
r.Post("/shop/purchase", ecoH.Purchase)
|
||||
r.Post("/rewards/daily", ecoH.Daily)
|
||||
r.Post("/rewards/ad", ecoH.AdReward)
|
||||
|
||||
@@ -75,13 +75,13 @@ func (g gameSettler) RecordGameResult(userID int64, won bool) {
|
||||
g.eco.AddTournamentPoints(context.Background(), userID, won)
|
||||
}
|
||||
|
||||
// PlayerProfile سکه، نشانِ رتبه و آواتارِ بازیکن را برای نمایش سرِ میز برمیگرداند.
|
||||
func (g gameSettler) PlayerProfile(userID int64) (int64, string, string) {
|
||||
// PlayerProfile سکه، نشانِ رتبه، آواتار و قابِ بازیکن را برای نمایش سرِ میز برمیگرداند.
|
||||
func (g gameSettler) PlayerProfile(userID int64) (int64, string, string, string) {
|
||||
p, err := g.eco.GetProfile(context.Background(), userID)
|
||||
if err != nil || p == nil {
|
||||
return 0, "", ""
|
||||
return 0, "", "", ""
|
||||
}
|
||||
return p.Coins, p.RankTier, p.Avatar
|
||||
return p.Coins, p.RankTier, p.Avatar, p.SelectedFrame
|
||||
}
|
||||
|
||||
// ChargeTicket یک بلیت برای بُرِ مجدد مصرف میکند (خطا اگر بلیت کافی نباشد).
|
||||
|
||||
@@ -35,6 +35,10 @@ func (h *Handler) APIRoutes() http.Handler {
|
||||
r.Delete("/carpets", h.apiCarpetDelete)
|
||||
r.Post("/carpets/{id}/upload", h.carpetUpload) // multipart (اشتراکی)
|
||||
|
||||
r.Get("/frames", h.apiFrames)
|
||||
r.Post("/frames", h.apiFrameUpsert)
|
||||
r.Delete("/frames", h.apiFrameDelete)
|
||||
|
||||
r.Get("/chat", h.apiChat)
|
||||
r.Post("/chat/pack", h.apiChatPackUpsert)
|
||||
r.Delete("/chat/pack", h.apiChatPackDelete)
|
||||
@@ -282,6 +286,51 @@ func (h *Handler) apiCarpetDelete(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// --- قابهای آواتار ---
|
||||
|
||||
func (h *Handler) apiFrames(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.JSON(w, http.StatusOK, map[string]any{
|
||||
"frames": h.rows(r.Context(),
|
||||
`SELECT id,title,price_coins,vip,sort,enabled FROM avatar_frames ORDER BY sort`,
|
||||
"id", "title", "price_coins", "vip", "sort", "enabled"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) apiFrameUpsert(w http.ResponseWriter, r *http.Request) {
|
||||
var b struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PriceCoins int64 `json:"price_coins"`
|
||||
VIP bool `json:"vip"`
|
||||
Sort int `json:"sort"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" {
|
||||
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
vip := 0
|
||||
if b.VIP {
|
||||
vip = 1
|
||||
}
|
||||
enabled := 1
|
||||
if b.Enabled != nil && !*b.Enabled {
|
||||
enabled = 0
|
||||
}
|
||||
if _, err := h.db.ExecContext(r.Context(),
|
||||
`INSERT OR REPLACE INTO avatar_frames (id,title,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,?)`,
|
||||
b.ID, b.Title, b.PriceCoins, vip, b.Sort, enabled); err != nil {
|
||||
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (h *Handler) apiFrameDelete(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = h.db.ExecContext(r.Context(), `DELETE FROM avatar_frames WHERE id=?`, r.URL.Query().Get("id"))
|
||||
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// --- چت ---
|
||||
|
||||
func (h *Handler) apiChat(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -57,6 +57,7 @@ type Profile struct {
|
||||
VIPUntil *string `json:"vip_until"`
|
||||
SelectedCard string `json:"selected_card"`
|
||||
SelectedCarpet string `json:"selected_carpet"`
|
||||
SelectedFrame string `json:"selected_frame"`
|
||||
RankPoints int64 `json:"rank_points"`
|
||||
RankTier string `json:"rank_tier"` // bronze..king
|
||||
RankIndex int `json:"rank_index"` // ۰..۴
|
||||
@@ -72,8 +73,8 @@ func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error
|
||||
var p Profile
|
||||
var vipUntil, avatar, lastDaily sql.NullString
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT coins, tickets, xp, trophies, vip_until, selected_card, selected_carpet, rank_points, avatar, daily_streak, best_streak, last_daily_at FROM users WHERE id = ?`, userID).
|
||||
Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.SelectedCarpet, &p.RankPoints, &avatar, &p.DailyStreak, &p.BestStreak, &lastDaily)
|
||||
`SELECT coins, tickets, xp, trophies, vip_until, selected_card, selected_carpet, selected_frame, rank_points, avatar, daily_streak, best_streak, last_daily_at FROM users WHERE id = ?`, userID).
|
||||
Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.SelectedCarpet, &p.SelectedFrame, &p.RankPoints, &avatar, &p.DailyStreak, &p.BestStreak, &lastDaily)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package economy
|
||||
|
||||
import "context"
|
||||
|
||||
// Frame یک قابِ آواتار (کازمتیک). Owned یعنی قابلِ انتخاب. سبکِ رنگی سمتِ کلاینت است.
|
||||
type Frame struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PriceCoins int64 `json:"price_coins"`
|
||||
VIP bool `json:"vip"`
|
||||
Owned bool `json:"owned"`
|
||||
}
|
||||
|
||||
// Frames قابهای فعال را با وضعیتِ مالکیتِ کاربر برمیگرداند.
|
||||
func (s *Service) Frames(ctx context.Context, userID int64) ([]Frame, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, title, price_coins, vip FROM avatar_frames WHERE enabled = 1 ORDER BY sort`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var frames []Frame
|
||||
for rows.Next() {
|
||||
var f Frame
|
||||
var vip int
|
||||
if err := rows.Scan(&f.ID, &f.Title, &f.PriceCoins, &vip); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.VIP = vip == 1
|
||||
frames = append(frames, f)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
owned, err := s.ownedFrames(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
isVIP := s.isVIP(ctx, userID)
|
||||
for i := range frames {
|
||||
f := &frames[i]
|
||||
f.Owned = (f.PriceCoins == 0 && !f.VIP) || (f.VIP && isVIP) || owned[f.ID]
|
||||
}
|
||||
return frames, nil
|
||||
}
|
||||
|
||||
func (s *Service) ownedFrames(ctx context.Context, userID int64) (map[string]bool, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT frame_id FROM user_frames WHERE user_id = ?`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
owned := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owned[id] = true
|
||||
}
|
||||
return owned, rows.Err()
|
||||
}
|
||||
|
||||
// BuyFrame یک قاب را با سکه باز میکند.
|
||||
func (s *Service) BuyFrame(ctx context.Context, userID int64, frameID string) error {
|
||||
var price int64
|
||||
var vip, enabled int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT price_coins, vip, enabled FROM avatar_frames WHERE id = ?`, frameID).
|
||||
Scan(&price, &vip, &enabled)
|
||||
if err != nil || enabled != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var exists int
|
||||
_ = tx.QueryRowContext(ctx,
|
||||
`SELECT 1 FROM user_frames WHERE user_id = ? AND frame_id = ?`, userID, frameID).Scan(&exists)
|
||||
if exists == 1 {
|
||||
return ErrAlreadyOwned
|
||||
}
|
||||
if price > 0 {
|
||||
if err := adjustTx(ctx, tx, userID, CurrencyCoin, -price, "buy_frame", frameID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO user_frames (user_id, frame_id) VALUES (?, ?)`, userID, frameID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// SelectFrame قابِ انتخابیِ کاربر را تنظیم میکند (باید مالکش باشد یا رایگان/VIP).
|
||||
func (s *Service) SelectFrame(ctx context.Context, userID int64, frameID string) error {
|
||||
frames, err := s.Frames(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range frames {
|
||||
if f.ID == frameID {
|
||||
if !f.Owned {
|
||||
return ErrNotOwned
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE users SET selected_frame = ? WHERE id = ?`, frameID, userID)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return ErrNotFound
|
||||
}
|
||||
@@ -155,6 +155,70 @@ func (h *Handler) carpetAction(w http.ResponseWriter, r *http.Request, sel bool)
|
||||
}
|
||||
}
|
||||
|
||||
// Frames — GET /api/frames (قابهای آواتار + مالکیت + انتخابِ کاربر)
|
||||
func (h *Handler) Frames(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := uid(r)
|
||||
if !ok {
|
||||
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
frames, err := h.svc.Frames(r.Context(), id)
|
||||
if err != nil {
|
||||
httpx.Error(w, http.StatusInternalServerError, "server error")
|
||||
return
|
||||
}
|
||||
selected := ""
|
||||
if p, _ := h.svc.GetProfile(r.Context(), id); p != nil {
|
||||
selected = p.SelectedFrame
|
||||
}
|
||||
httpx.JSON(w, http.StatusOK, map[string]any{"frames": frames, "selected": selected})
|
||||
}
|
||||
|
||||
// BuyFrame — POST /api/shop/buy-frame
|
||||
func (h *Handler) BuyFrame(w http.ResponseWriter, r *http.Request) {
|
||||
h.frameAction(w, r, false)
|
||||
}
|
||||
|
||||
// SelectFrame — POST /api/shop/select-frame
|
||||
func (h *Handler) SelectFrame(w http.ResponseWriter, r *http.Request) {
|
||||
h.frameAction(w, r, true)
|
||||
}
|
||||
|
||||
func (h *Handler) frameAction(w http.ResponseWriter, r *http.Request, sel bool) {
|
||||
id, ok := uid(r)
|
||||
if !ok {
|
||||
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
FrameID string `json:"frame_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
var err error
|
||||
if sel {
|
||||
err = h.svc.SelectFrame(r.Context(), id, req.FrameID)
|
||||
} else {
|
||||
err = h.svc.BuyFrame(r.Context(), id, req.FrameID)
|
||||
}
|
||||
switch {
|
||||
case err == nil:
|
||||
httpx.JSON(w, http.StatusOK, map[string]string{"message": "ok"})
|
||||
case errors.Is(err, ErrNotFound):
|
||||
httpx.Error(w, http.StatusNotFound, "frame not found")
|
||||
case errors.Is(err, ErrAlreadyOwned):
|
||||
httpx.Error(w, http.StatusConflict, "already owned")
|
||||
case errors.Is(err, ErrNotOwned):
|
||||
httpx.Error(w, http.StatusForbidden, "frame not owned")
|
||||
case errors.Is(err, ErrInsufficient):
|
||||
httpx.Error(w, http.StatusPaymentRequired, "insufficient coins")
|
||||
default:
|
||||
httpx.Error(w, http.StatusInternalServerError, "server error")
|
||||
}
|
||||
}
|
||||
|
||||
// ChatPacks — GET /api/chat-packs (بستههای پیام/شکلک + مالکیتِ کاربر)
|
||||
func (h *Handler) ChatPacks(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := uid(r)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
-- قابهای آواتار (کازمتیکِ قابلِخرید). سبکِ هر قاب (رنگها) سمتِ کلاینت است؛
|
||||
-- اینجا فقط شناسه/قیمت/فعالبودن نگهداری میشود. 'none' یعنی قابِ پیشفرضِ رتبه.
|
||||
CREATE TABLE IF NOT EXISTS avatar_frames (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
price_coins INTEGER NOT NULL DEFAULT 0, -- ۰ یعنی رایگان (مگر vip)
|
||||
vip INTEGER NOT NULL DEFAULT 0, -- ۱ یعنی برای VIP رایگان
|
||||
sort INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_frames (
|
||||
user_id INTEGER NOT NULL,
|
||||
frame_id TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, frame_id)
|
||||
);
|
||||
|
||||
-- قابِ انتخابیِ کاربر؛ خالی/'none' یعنی قابِ پیشفرضِ رتبهای.
|
||||
ALTER TABLE users ADD COLUMN selected_frame TEXT NOT NULL DEFAULT '';
|
||||
|
||||
INSERT OR IGNORE INTO avatar_frames (id, title, price_coins, vip, sort) VALUES
|
||||
('none', 'پیشفرض (رتبه)', 0, 0, 1),
|
||||
('gold', 'قابِ طلایی', 5000, 0, 2),
|
||||
('fire', 'قابِ آتش', 12000, 0, 3),
|
||||
('emerald', 'قابِ زمرد', 12000, 0, 4),
|
||||
('ocean', 'قابِ اقیانوس', 15000, 0, 5),
|
||||
('rose', 'قابِ گلسرخ', 15000, 0, 6),
|
||||
('royal', 'قابِ سلطنتی', 0, 1, 7);
|
||||
+2
-1
@@ -391,10 +391,11 @@ func (h *Hub) removeFromTable(t *pendingTable, c *Client) {
|
||||
func (h *Hub) broadcastLobby(t *pendingTable) {
|
||||
players := make([]lobbyPlayer, len(t.clients))
|
||||
for i, cl := range t.clients {
|
||||
_, _, avatar := h.settler.PlayerProfile(cl.UserID)
|
||||
_, _, avatar, frame := h.settler.PlayerProfile(cl.UserID)
|
||||
players[i] = lobbyPlayer{
|
||||
Name: cl.Name,
|
||||
Avatar: avatar,
|
||||
Frame: frame,
|
||||
Host: cl.UserID == t.host,
|
||||
Seat: t.seatOf(i),
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ type chatMsg struct {
|
||||
type lobbyPlayer struct {
|
||||
Name string `json:"name"`
|
||||
Avatar string `json:"avatar"` // seed آواتارِ کاربر
|
||||
Frame string `json:"frame"` // قابِ آواتارِ انتخابی
|
||||
Host bool `json:"host"`
|
||||
Seat int `json:"seat"` // جایگاهِ مطلق (۰..۳)
|
||||
}
|
||||
@@ -66,6 +67,7 @@ type PlayerInfo struct {
|
||||
Coins int64 `json:"coins"`
|
||||
RankTier string `json:"rank_tier"` // bronze..king (برای نشانِ رتبه)
|
||||
Avatar string `json:"avatar"` // seed آواتارِ کاربر
|
||||
Frame string `json:"frame"` // قابِ آواتارِ انتخابی (کازمتیک)
|
||||
}
|
||||
|
||||
// matchedMsg به کلاینت پس از تشکیل میز.
|
||||
|
||||
+3
-2
@@ -40,6 +40,7 @@ type seatInfo struct {
|
||||
coins int64 // برای نمایش سرِ میز
|
||||
rankTier string // نشانِ رتبه برای نمایش سرِ میز
|
||||
avatar string // seed آواتارِ انتخابیِ کاربر (برای نمایش سرِ میز)
|
||||
frame string // قابِ آواتارِ انتخابیِ کاربر (کازمتیک)
|
||||
}
|
||||
|
||||
// auto مشخص میکند جایگاه باید خودکار بازی شود (بات یا قطعشده).
|
||||
@@ -147,7 +148,7 @@ func (r *Room) start() {
|
||||
if s.isBot || s.userID == 0 {
|
||||
continue
|
||||
}
|
||||
s.coins, s.rankTier, s.avatar = r.settler.PlayerProfile(s.userID)
|
||||
s.coins, s.rankTier, s.avatar, s.frame = r.settler.PlayerProfile(s.userID)
|
||||
}
|
||||
for seat, s := range r.seats {
|
||||
if s.client != nil {
|
||||
@@ -547,7 +548,7 @@ func (r *Room) playersInfo() []PlayerInfo {
|
||||
out[i] = PlayerInfo{
|
||||
Seat: i, Name: s.name, Bot: s.isBot,
|
||||
Connected: s.isBot || s.connected,
|
||||
Coins: s.coins, RankTier: s.rankTier, Avatar: s.avatar,
|
||||
Coins: s.coins, RankTier: s.rankTier, Avatar: s.avatar, Frame: s.frame,
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -12,8 +12,8 @@ type Settler interface {
|
||||
RecordHand(userID int64, won, kot, asHakem bool) // پایان یک هَند
|
||||
RecordCut(userID int64) // بُرِش با حکم
|
||||
RecordGameResult(userID int64, won bool) // پایان بازی (برد/باخت)
|
||||
// PlayerProfile سکه، نشانِ رتبه و seedِ آواتارِ بازیکن را برای نمایش سرِ میز برمیگرداند.
|
||||
PlayerProfile(userID int64) (coins int64, rankTier string, avatar string)
|
||||
// PlayerProfile سکه، نشانِ رتبه، آواتار و قابِ انتخابیِ بازیکن را برای نمایش سرِ میز برمیگرداند.
|
||||
PlayerProfile(userID int64) (coins int64, rankTier string, avatar string, frame string)
|
||||
// ChargeTicket یک بلیت برای بُرِ مجدد در فازِ انتخابِ حکم مصرف میکند.
|
||||
ChargeTicket(userID int64) error
|
||||
// ChargeCoins/AwardCoins برای شرطِ سکهایِ میزِ خصوصی (pot). Charge در صورتِ
|
||||
@@ -38,7 +38,7 @@ func (noopSettler) RecordGame(string, string, int, bool) {}
|
||||
func (noopSettler) RecordHand(int64, bool, bool, bool) {}
|
||||
func (noopSettler) RecordCut(int64) {}
|
||||
func (noopSettler) RecordGameResult(int64, bool) {}
|
||||
func (noopSettler) PlayerProfile(int64) (int64, string, string) { return 0, "", "" }
|
||||
func (noopSettler) PlayerProfile(int64) (int64, string, string, string) { return 0, "", "", "" }
|
||||
func (noopSettler) ChargeTicket(int64) error { return nil }
|
||||
func (noopSettler) ChargeCoins(int64, int64, string) error { return nil }
|
||||
func (noopSettler) AwardCoins(int64, int64, string) {}
|
||||
|
||||
@@ -481,7 +481,7 @@ func (s *recordingSettler) RecordGameResult(int64, bool) {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *recordingSettler) PlayerProfile(int64) (int64, string, string) { return 0, "", "" }
|
||||
func (s *recordingSettler) PlayerProfile(int64) (int64, string, string, string) { return 0, "", "", "" }
|
||||
func (s *recordingSettler) ChargeTicket(int64) error { return nil }
|
||||
func (s *recordingSettler) ChargeCoins(int64, int64, string) error { return nil }
|
||||
func (s *recordingSettler) AwardCoins(int64, int64, string) {}
|
||||
|
||||
Reference in New Issue
Block a user