feat: add characters
This commit is contained in:
+9
-1
@@ -90,6 +90,9 @@ func main() {
|
|||||||
slog.Info("IAP bazaar (RSA) configured")
|
slog.Info("IAP bazaar (RSA) configured")
|
||||||
}
|
}
|
||||||
eco := economy.New(st.DB, economy.DevAdVerifier{}, iap)
|
eco := economy.New(st.DB, economy.DevAdVerifier{}, iap)
|
||||||
|
eco.SetPublicURL(cfg.PublicURL) // برای ساختِ URL کاملِ تصاویر (آواتار/فرش)
|
||||||
|
// آواتارهای «svgِ رستری» (PNG داخلِ svg) را به PNG واقعی تبدیل کن (یکبار، idempotent).
|
||||||
|
admin.ConvertRasterAvatars(context.Background(), st.DB, cfg.AvatarsDir)
|
||||||
if err := eco.LoadCatalog(context.Background()); err != nil {
|
if err := eco.LoadCatalog(context.Background()); err != nil {
|
||||||
slog.Error("load catalog", "err", err)
|
slog.Error("load catalog", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -134,9 +137,11 @@ func main() {
|
|||||||
r.Handle("/cards/*", static.CardsHandler(cfg.CardsDir))
|
r.Handle("/cards/*", static.CardsHandler(cfg.CardsDir))
|
||||||
// تصاویرِ فرش (بهجای میز) — از پنل ادمین آپلود میشوند.
|
// تصاویرِ فرش (بهجای میز) — از پنل ادمین آپلود میشوند.
|
||||||
r.Handle("/carpets/*", static.CarpetsHandler(cfg.CarpetsDir))
|
r.Handle("/carpets/*", static.CarpetsHandler(cfg.CarpetsDir))
|
||||||
|
// تصاویرِ شخصیتِ آواتار — از پنل ادمین آپلود میشوند.
|
||||||
|
r.Handle("/avatars/*", static.AvatarsHandler(cfg.AvatarsDir))
|
||||||
|
|
||||||
// پنل ادمین (HTML سرورساید، پشت Basic Auth)
|
// پنل ادمین (HTML سرورساید، پشت Basic Auth)
|
||||||
r.Mount("/admin", admin.New(st.DB, eco, cfg.CardsDir, cfg.CarpetsDir).Routes(cfg.AdminUser, cfg.AdminPass))
|
r.Mount("/admin", admin.New(st.DB, eco, cfg.CardsDir, cfg.CarpetsDir, cfg.AvatarsDir).Routes(cfg.AdminUser, cfg.AdminPass))
|
||||||
|
|
||||||
// بازرسِ درخواستها (Telescope-lite) — پشتِ همان Basic Auth ادمین.
|
// بازرسِ درخواستها (Telescope-lite) — پشتِ همان Basic Auth ادمین.
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
@@ -172,6 +177,9 @@ func main() {
|
|||||||
r.Get("/frames", ecoH.Frames)
|
r.Get("/frames", ecoH.Frames)
|
||||||
r.Post("/shop/buy-frame", ecoH.BuyFrame)
|
r.Post("/shop/buy-frame", ecoH.BuyFrame)
|
||||||
r.Post("/shop/select-frame", ecoH.SelectFrame)
|
r.Post("/shop/select-frame", ecoH.SelectFrame)
|
||||||
|
r.Get("/avatars", ecoH.Avatars)
|
||||||
|
r.Post("/shop/buy-avatar", ecoH.BuyAvatar)
|
||||||
|
r.Post("/shop/select-avatar", ecoH.SelectAvatar)
|
||||||
r.Post("/shop/purchase", ecoH.Purchase)
|
r.Post("/shop/purchase", ecoH.Purchase)
|
||||||
r.Post("/rewards/daily", ecoH.Daily)
|
r.Post("/rewards/daily", ecoH.Daily)
|
||||||
r.Post("/rewards/ad", ecoH.AdReward)
|
r.Post("/rewards/ad", ecoH.AdReward)
|
||||||
|
|||||||
@@ -75,13 +75,14 @@ func (g gameSettler) RecordGameResult(userID int64, won bool) {
|
|||||||
g.eco.AddTournamentPoints(context.Background(), userID, won)
|
g.eco.AddTournamentPoints(context.Background(), userID, won)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlayerProfile سکه، نشانِ رتبه، آواتار و قابِ بازیکن را برای نمایش سرِ میز برمیگرداند.
|
// PlayerProfile سکه، نشانِ رتبه، آواتار، قاب و شخصیتِ آواتارِ بازیکن را برای نمایشِ
|
||||||
func (g gameSettler) PlayerProfile(userID int64) (int64, string, string, string) {
|
// سرِ میز برمیگرداند.
|
||||||
|
func (g gameSettler) PlayerProfile(userID int64) (int64, string, string, string, string) {
|
||||||
p, err := g.eco.GetProfile(context.Background(), userID)
|
p, err := g.eco.GetProfile(context.Background(), userID)
|
||||||
if err != nil || p == nil {
|
if err != nil || p == nil {
|
||||||
return 0, "", "", ""
|
return 0, "", "", "", ""
|
||||||
}
|
}
|
||||||
return p.Coins, p.RankTier, p.Avatar, p.SelectedFrame
|
return p.Coins, p.RankTier, p.Avatar, p.SelectedFrame, p.SelectedAvatarImg
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChargeTicket یک بلیت برای بُرِ مجدد مصرف میکند (خطا اگر بلیت کافی نباشد).
|
// ChargeTicket یک بلیت برای بُرِ مجدد مصرف میکند (خطا اگر بلیت کافی نباشد).
|
||||||
|
|||||||
+58
-2
@@ -33,11 +33,12 @@ type Handler struct {
|
|||||||
tpl *template.Template
|
tpl *template.Template
|
||||||
cardsDir string // پوشهی دیسکیِ آپلودِ اسکینها (همان مسیری که static از آن سرو میکند)
|
cardsDir string // پوشهی دیسکیِ آپلودِ اسکینها (همان مسیری که static از آن سرو میکند)
|
||||||
carpetsDir string // پوشهی دیسکیِ آپلودِ تصاویرِ فرش
|
carpetsDir string // پوشهی دیسکیِ آپلودِ تصاویرِ فرش
|
||||||
|
avatarsDir string // پوشهی دیسکیِ آپلودِ تصاویرِ شخصیتِ آواتار
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(db *sql.DB, eco *economy.Service, cardsDir, carpetsDir string) *Handler {
|
func New(db *sql.DB, eco *economy.Service, cardsDir, carpetsDir, avatarsDir string) *Handler {
|
||||||
tpl := template.Must(template.ParseFS(tplFS, "templates/*.html"))
|
tpl := template.Must(template.ParseFS(tplFS, "templates/*.html"))
|
||||||
return &Handler{db: db, eco: eco, tpl: tpl, cardsDir: cardsDir, carpetsDir: carpetsDir}
|
return &Handler{db: db, eco: eco, tpl: tpl, cardsDir: cardsDir, carpetsDir: carpetsDir, avatarsDir: avatarsDir}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Routes زیرروترِ /admin را با Basic Auth برمیگرداند.
|
// Routes زیرروترِ /admin را با Basic Auth برمیگرداند.
|
||||||
@@ -537,6 +538,61 @@ func (h *Handler) carpetUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/admin/carpets?saved=1", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/carpets?saved=1", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// avatarUpload یک تصویرِ png یا svg را برای یک شخصیتِ آواتار آپلود و با نامِ
|
||||||
|
// <id>.<ext> ذخیره میکند و img_ext را در دیتابیس بهروزرسانی میکند. نوعِ فایل از
|
||||||
|
// پسوندِ نامِ آپلودشده تشخیص داده میشود (svg → svg، وگرنه png). png برای شفافیت.
|
||||||
|
func (h *Handler) avatarUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if id == "" || strings.ContainsAny(id, "/\\.") {
|
||||||
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.avatarsDir == "" {
|
||||||
|
http.Error(w, "AVATARS_DIR تنظیم نشده است", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseMultipartForm(16 << 20); err != nil {
|
||||||
|
http.Error(w, "bad upload", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, hdr, err := r.FormFile("image")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "image لازم است", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
buf, err := io.ReadAll(io.LimitReader(file, 16<<20))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ext := "png"
|
||||||
|
if strings.HasSuffix(strings.ToLower(hdr.Filename), ".svg") {
|
||||||
|
ext = "svg"
|
||||||
|
// «SVGِ رستری» (PNG داخلِ svg) را به PNG واقعی تبدیل کن تا اپ رندرش کند.
|
||||||
|
if raw, rext, ok := extractEmbeddedRaster(buf); ok {
|
||||||
|
buf, ext = raw, rext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(h.avatarsDir, 0o755); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// نسخههای قبلی با پسوندِ دیگر را پاک کن تا چند نسخه نماند.
|
||||||
|
for _, e := range []string{"png", "jpg", "svg"} {
|
||||||
|
if e != ext {
|
||||||
|
_ = os.Remove(filepath.Join(h.avatarsDir, id+"."+e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(h.avatarsDir, id+"."+ext), buf, 0o644); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = h.db.ExecContext(r.Context(), `UPDATE avatars SET img_ext = ? WHERE id = ?`, ext, id)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||||
|
}
|
||||||
|
|
||||||
// --- مدیریتِ بستههای چت و پیامها ---
|
// --- مدیریتِ بستههای چت و پیامها ---
|
||||||
|
|
||||||
func (h *Handler) chatAdmin(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) chatAdmin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
+128
-61
@@ -39,6 +39,11 @@ func (h *Handler) APIRoutes() http.Handler {
|
|||||||
r.Post("/frames", h.apiFrameUpsert)
|
r.Post("/frames", h.apiFrameUpsert)
|
||||||
r.Delete("/frames", h.apiFrameDelete)
|
r.Delete("/frames", h.apiFrameDelete)
|
||||||
|
|
||||||
|
r.Get("/avatars", h.apiAvatars)
|
||||||
|
r.Post("/avatars", h.apiAvatarUpsert)
|
||||||
|
r.Delete("/avatars", h.apiAvatarDelete)
|
||||||
|
r.Post("/avatars/{id}/upload", h.avatarUpload) // multipart (تصویرِ png)
|
||||||
|
|
||||||
r.Get("/rank-tiers", h.apiRankTiers)
|
r.Get("/rank-tiers", h.apiRankTiers)
|
||||||
r.Post("/rank-tiers", h.apiRankTierUpsert)
|
r.Post("/rank-tiers", h.apiRankTierUpsert)
|
||||||
|
|
||||||
@@ -201,14 +206,14 @@ func (h *Handler) apiUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (h *Handler) apiUserCoins(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiUserCoins(w http.ResponseWriter, r *http.Request) {
|
||||||
var b struct {
|
var b struct {
|
||||||
UserID int64 `json:"user_id"`
|
UserID flexInt `json:"user_id"`
|
||||||
Amount int64 `json:"amount"`
|
Amount flexInt `json:"amount"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.UserID == 0 {
|
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.UserID == 0 {
|
||||||
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
httpx.Error(w, http.StatusBadRequest, "بدنهی نامعتبر")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.eco.Adjust(r.Context(), b.UserID, economy.CurrencyCoin, b.Amount, "admin_adjust", "panel"); err != nil {
|
if err := h.eco.Adjust(r.Context(), b.UserID.i(), economy.CurrencyCoin, b.Amount.i(), "admin_adjust", "panel"); err != nil {
|
||||||
httpx.Error(w, http.StatusBadRequest, err.Error())
|
httpx.Error(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -217,26 +222,26 @@ func (h *Handler) apiUserCoins(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (h *Handler) apiUserGrant(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiUserGrant(w http.ResponseWriter, r *http.Request) {
|
||||||
var b struct {
|
var b struct {
|
||||||
UserID int64 `json:"user_id"`
|
UserID flexInt `json:"user_id"`
|
||||||
Kind string `json:"kind"` // coin | ticket | vip
|
Kind string `json:"kind"` // coin | ticket | vip
|
||||||
Amount int64 `json:"amount"`
|
Amount flexInt `json:"amount"`
|
||||||
VIPDays int `json:"vip_days"`
|
VIPDays flexInt `json:"vip_days"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.UserID == 0 {
|
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.UserID == 0 {
|
||||||
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
httpx.Error(w, http.StatusBadRequest, "بدنهی نامعتبر")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
var err error
|
var err error
|
||||||
switch b.Kind {
|
switch b.Kind {
|
||||||
case "coin":
|
case "coin":
|
||||||
err = h.eco.Adjust(ctx, b.UserID, economy.CurrencyCoin, b.Amount, "admin_grant", "panel")
|
err = h.eco.Adjust(ctx, b.UserID.i(), economy.CurrencyCoin, b.Amount.i(), "admin_grant", "panel")
|
||||||
case "ticket":
|
case "ticket":
|
||||||
err = h.eco.Adjust(ctx, b.UserID, economy.CurrencyTicket, b.Amount, "admin_grant", "panel")
|
err = h.eco.Adjust(ctx, b.UserID.i(), economy.CurrencyTicket, b.Amount.i(), "admin_grant", "panel")
|
||||||
case "vip":
|
case "vip":
|
||||||
_, err = h.db.ExecContext(ctx,
|
_, err = h.db.ExecContext(ctx,
|
||||||
`UPDATE users SET vip_until = datetime('now', ?) WHERE id = ?`,
|
`UPDATE users SET vip_until = datetime('now', ?) WHERE id = ?`,
|
||||||
fmt.Sprintf("+%d days", b.VIPDays), b.UserID)
|
fmt.Sprintf("+%d days", b.VIPDays.i()), b.UserID.i())
|
||||||
default:
|
default:
|
||||||
httpx.Error(w, http.StatusBadRequest, "unknown kind")
|
httpx.Error(w, http.StatusBadRequest, "unknown kind")
|
||||||
return
|
return
|
||||||
@@ -260,15 +265,22 @@ func (h *Handler) apiCarpets(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (h *Handler) apiCarpetUpsert(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiCarpetUpsert(w http.ResponseWriter, r *http.Request) {
|
||||||
var b struct {
|
var b struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
PriceCoins int64 `json:"price_coins"`
|
PriceCoins flexInt `json:"price_coins"`
|
||||||
VIP bool `json:"vip"`
|
VIP flexBool `json:"vip"`
|
||||||
Sort int `json:"sort"`
|
Sort flexInt `json:"sort"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" ||
|
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
|
||||||
strings.ContainsAny(b.ID, `/\.`) {
|
httpx.Error(w, http.StatusBadRequest, "بدنهی نامعتبر")
|
||||||
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(b.ID) == "" {
|
||||||
|
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(b.ID, `/\.`) {
|
||||||
|
httpx.Error(w, http.StatusBadRequest, "شناسه نباید شاملِ نقطه، / یا \\ باشد")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
vip := 0
|
vip := 0
|
||||||
@@ -277,7 +289,7 @@ func (h *Handler) apiCarpetUpsert(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if _, err := h.db.ExecContext(r.Context(),
|
if _, err := h.db.ExecContext(r.Context(),
|
||||||
`INSERT OR REPLACE INTO carpets (id,title,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,1)`,
|
`INSERT OR REPLACE INTO carpets (id,title,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,1)`,
|
||||||
b.ID, b.Title, b.PriceCoins, vip, b.Sort); err != nil {
|
b.ID, b.Title, b.PriceCoins.i(), vip, b.Sort.i()); err != nil {
|
||||||
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -289,6 +301,57 @@ func (h *Handler) apiCarpetDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- شخصیتهای آواتار ---
|
||||||
|
|
||||||
|
func (h *Handler) apiAvatars(w http.ResponseWriter, r *http.Request) {
|
||||||
|
httpx.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"avatars": h.rows(r.Context(),
|
||||||
|
`SELECT id,title,price_coins,vip,sort,enabled,img_ext FROM avatars ORDER BY sort`,
|
||||||
|
"id", "title", "price_coins", "vip", "sort", "enabled", "img_ext"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) apiAvatarUpsert(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var b struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
PriceCoins flexInt `json:"price_coins"`
|
||||||
|
VIP flexBool `json:"vip"`
|
||||||
|
Sort flexInt `json:"sort"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
|
||||||
|
httpx.Error(w, http.StatusBadRequest, "بدنهی نامعتبر")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(b.ID) == "" {
|
||||||
|
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(b.ID, `/\.`) {
|
||||||
|
httpx.Error(w, http.StatusBadRequest, "شناسه نباید شاملِ نقطه، / یا \\ باشد")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vip := 0
|
||||||
|
if b.VIP {
|
||||||
|
vip = 1
|
||||||
|
}
|
||||||
|
// ON CONFLICT بهجای REPLACE تا img_ext (نوعِ فایلِ آپلودشده) هنگامِ ویرایش پاک نشود.
|
||||||
|
if _, err := h.db.ExecContext(r.Context(),
|
||||||
|
`INSERT INTO avatars (id,title,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,1)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET title=excluded.title, price_coins=excluded.price_coins,
|
||||||
|
vip=excluded.vip, sort=excluded.sort, enabled=1`,
|
||||||
|
b.ID, b.Title, b.PriceCoins.i(), vip, b.Sort.i()); err != nil {
|
||||||
|
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) apiAvatarDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = h.db.ExecContext(r.Context(), `DELETE FROM avatars WHERE id=?`, r.URL.Query().Get("id"))
|
||||||
|
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
// --- قابهای آواتار ---
|
// --- قابهای آواتار ---
|
||||||
|
|
||||||
func (h *Handler) apiFrames(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiFrames(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -301,17 +364,17 @@ func (h *Handler) apiFrames(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (h *Handler) apiFrameUpsert(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiFrameUpsert(w http.ResponseWriter, r *http.Request) {
|
||||||
var b struct {
|
var b struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
PriceCoins int64 `json:"price_coins"`
|
PriceCoins flexInt `json:"price_coins"`
|
||||||
VIP bool `json:"vip"`
|
VIP flexBool `json:"vip"`
|
||||||
C1 string `json:"c1"`
|
C1 string `json:"c1"`
|
||||||
C2 string `json:"c2"`
|
C2 string `json:"c2"`
|
||||||
Sort int `json:"sort"`
|
Sort flexInt `json:"sort"`
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *flexBool `json:"enabled"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" {
|
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" {
|
||||||
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
vip := 0
|
vip := 0
|
||||||
@@ -326,7 +389,7 @@ func (h *Handler) apiFrameUpsert(w http.ResponseWriter, r *http.Request) {
|
|||||||
clean := func(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "#") }
|
clean := func(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "#") }
|
||||||
if _, err := h.db.ExecContext(r.Context(),
|
if _, err := h.db.ExecContext(r.Context(),
|
||||||
`INSERT OR REPLACE INTO avatar_frames (id,title,price_coins,vip,c1,c2,sort,enabled) VALUES (?,?,?,?,?,?,?,?)`,
|
`INSERT OR REPLACE INTO avatar_frames (id,title,price_coins,vip,c1,c2,sort,enabled) VALUES (?,?,?,?,?,?,?,?)`,
|
||||||
b.ID, b.Title, b.PriceCoins, vip, clean(b.C1), clean(b.C2), b.Sort, enabled); err != nil {
|
b.ID, b.Title, b.PriceCoins.i(), vip, clean(b.C1), clean(b.C2), b.Sort.i(), enabled); err != nil {
|
||||||
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -350,20 +413,20 @@ func (h *Handler) apiRankTiers(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (h *Handler) apiRankTierUpsert(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiRankTierUpsert(w http.ResponseWriter, r *http.Request) {
|
||||||
var b struct {
|
var b struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
C1 string `json:"c1"`
|
C1 string `json:"c1"`
|
||||||
C2 string `json:"c2"`
|
C2 string `json:"c2"`
|
||||||
Sort int `json:"sort"`
|
Sort flexInt `json:"sort"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" {
|
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" {
|
||||||
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
clean := func(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "#") }
|
clean := func(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "#") }
|
||||||
if _, err := h.db.ExecContext(r.Context(),
|
if _, err := h.db.ExecContext(r.Context(),
|
||||||
`INSERT OR REPLACE INTO rank_tiers (id,label,c1,c2,sort) VALUES (?,?,?,?,?)`,
|
`INSERT OR REPLACE INTO rank_tiers (id,label,c1,c2,sort) VALUES (?,?,?,?,?)`,
|
||||||
b.ID, b.Label, clean(b.C1), clean(b.C2), b.Sort); err != nil {
|
b.ID, b.Label, clean(b.C1), clean(b.C2), b.Sort.i()); err != nil {
|
||||||
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -386,15 +449,15 @@ func (h *Handler) apiChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (h *Handler) apiChatPackUpsert(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiChatPackUpsert(w http.ResponseWriter, r *http.Request) {
|
||||||
var b struct {
|
var b struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
PriceCoins int64 `json:"price_coins"`
|
PriceCoins flexInt `json:"price_coins"`
|
||||||
VIP bool `json:"vip"`
|
VIP flexBool `json:"vip"`
|
||||||
Sort int `json:"sort"`
|
Sort flexInt `json:"sort"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" {
|
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" {
|
||||||
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if b.Kind != "emoji" {
|
if b.Kind != "emoji" {
|
||||||
@@ -406,7 +469,7 @@ func (h *Handler) apiChatPackUpsert(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if _, err := h.db.ExecContext(r.Context(),
|
if _, err := h.db.ExecContext(r.Context(),
|
||||||
`INSERT OR REPLACE INTO chat_packs (id,title,kind,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,?,1)`,
|
`INSERT OR REPLACE INTO chat_packs (id,title,kind,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,?,1)`,
|
||||||
b.ID, b.Title, b.Kind, b.PriceCoins, vip, b.Sort); err != nil {
|
b.ID, b.Title, b.Kind, b.PriceCoins.i(), vip, b.Sort.i()); err != nil {
|
||||||
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -422,16 +485,16 @@ func (h *Handler) apiChatPackDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (h *Handler) apiChatMessageAdd(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiChatMessageAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
var b struct {
|
var b struct {
|
||||||
PackID string `json:"pack_id"`
|
PackID string `json:"pack_id"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
Sort int `json:"sort"`
|
Sort flexInt `json:"sort"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.PackID == "" || b.Body == "" {
|
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.PackID == "" || b.Body == "" {
|
||||||
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
httpx.Error(w, http.StatusBadRequest, "بسته و متن لازم است")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := h.db.ExecContext(r.Context(),
|
if _, err := h.db.ExecContext(r.Context(),
|
||||||
`INSERT INTO chat_messages (pack_id,body,sort) VALUES (?,?,?)`, b.PackID, b.Body, b.Sort); err != nil {
|
`INSERT INTO chat_messages (pack_id,body,sort) VALUES (?,?,?)`, b.PackID, b.Body, b.Sort.i()); err != nil {
|
||||||
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -452,13 +515,13 @@ func (h *Handler) apiTournaments(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type tournamentBody struct {
|
type tournamentBody struct {
|
||||||
ID int64 `json:"id"`
|
ID flexInt `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
EntryFee int64 `json:"entry_fee"`
|
EntryFee flexInt `json:"entry_fee"`
|
||||||
Prizes []int64 `json:"prizes"`
|
Prizes []flexInt `json:"prizes"`
|
||||||
StartsAt string `json:"starts_at"` // datetime-local به وقتِ تهران
|
StartsAt string `json:"starts_at"` // datetime-local به وقتِ تهران
|
||||||
EndsAt string `json:"ends_at"`
|
EndsAt string `json:"ends_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// tournamentFromBody بدنه را به economy.Tournament (با زمانِ UTC) تبدیل میکند.
|
// tournamentFromBody بدنه را به economy.Tournament (با زمانِ UTC) تبدیل میکند.
|
||||||
@@ -471,9 +534,13 @@ func tournamentFromBody(b tournamentBody) economy.Tournament {
|
|||||||
}
|
}
|
||||||
return t.UTC()
|
return t.UTC()
|
||||||
}
|
}
|
||||||
|
prizes := make([]int64, len(b.Prizes))
|
||||||
|
for i, p := range b.Prizes {
|
||||||
|
prizes[i] = p.i()
|
||||||
|
}
|
||||||
return economy.Tournament{
|
return economy.Tournament{
|
||||||
Title: b.Title, Description: b.Description, EntryFee: b.EntryFee,
|
Title: b.Title, Description: b.Description, EntryFee: b.EntryFee.i(),
|
||||||
Prizes: b.Prizes, StartsAt: parse(b.StartsAt), EndsAt: parse(b.EndsAt),
|
Prizes: prizes, StartsAt: parse(b.StartsAt), EndsAt: parse(b.EndsAt),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,7 +563,7 @@ func (h *Handler) apiTournamentUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.eco.UpdateTournament(r.Context(), b.ID, tournamentFromBody(b)); err != nil {
|
if err := h.eco.UpdateTournament(r.Context(), b.ID.i(), tournamentFromBody(b)); err != nil {
|
||||||
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// برخی ابزارها (مثلِ خروجیِ AI) یک PNG را داخلِ یک <svg> با base64 قرار میدهند.
|
||||||
|
// flutter_svg نمیتواند این «SVGِ رستری» را رندر کند، پس آن را به PNG واقعی تبدیل
|
||||||
|
// میکنیم.
|
||||||
|
var rasterInSVG = regexp.MustCompile(`data:image/(png|jpe?g);base64,([A-Za-z0-9+/=\s]+)`)
|
||||||
|
|
||||||
|
// extractEmbeddedRaster اگر محتوای SVG صرفاً یک تصویرِ رستریِ base64 باشد، آن را
|
||||||
|
// استخراج میکند (بایتهای خام + پسوند). برای SVGهای واقعیِ برداری false برمیگرداند.
|
||||||
|
func extractEmbeddedRaster(svg []byte) (data []byte, ext string, ok bool) {
|
||||||
|
m := rasterInSVG.FindSubmatch(svg)
|
||||||
|
if m == nil {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
ext = "png"
|
||||||
|
if strings.HasPrefix(string(m[1]), "jp") {
|
||||||
|
ext = "jpg"
|
||||||
|
}
|
||||||
|
b64 := regexp.MustCompile(`\s`).ReplaceAll(m[2], nil)
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(string(b64))
|
||||||
|
if err != nil || len(raw) == 0 {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
return raw, ext, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertRasterAvatars فایلهای آواتارِ svg را که در واقع رستر هستند به png/jpg
|
||||||
|
// تبدیل میکند و img_ext را در DB بهروزرسانی میکند. یکبار در startup اجرا میشود
|
||||||
|
// (idempotent). برای رفعِ آواتارهایی که قبلاً بهصورتِ «svgِ رستری» آپلود شدهاند.
|
||||||
|
func convertRasterAvatars(ctx context.Context, db *sql.DB, dir string) {
|
||||||
|
if dir == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT id FROM avatars WHERE img_ext = 'svg'`)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ids []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if rows.Scan(&id) == nil {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
for _, id := range ids {
|
||||||
|
svgPath := filepath.Join(dir, id+".svg")
|
||||||
|
content, err := os.ReadFile(svgPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, ext, ok := extractEmbeddedRaster(content)
|
||||||
|
if !ok {
|
||||||
|
continue // SVGِ واقعیِ برداری — دست نزن.
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, id+"."+ext), raw, 0o644); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = os.Remove(svgPath)
|
||||||
|
_, _ = db.ExecContext(ctx, `UPDATE avatars SET img_ext = ? WHERE id = ?`, ext, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertRasterAvatars نسخهی عمومی برای فراخوانی از main در startup.
|
||||||
|
func ConvertRasterAvatars(ctx context.Context, db *sql.DB, dir string) {
|
||||||
|
convertRasterAvatars(ctx, db, dir)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// flexBool یک bool است که هنگامِ decode از JSON، مقادیرِ عددی/رشتهای را هم میپذیرد
|
||||||
|
// (true/false، 0/1، "1"/"true"). دلیل: SQLite بولینها را int برمیگرداند و پنل
|
||||||
|
// همان مقدار را دوباره میفرستد؛ decodeِ سختگیرِ Go عدد را در bool نمیپذیرد → 400.
|
||||||
|
type flexBool bool
|
||||||
|
|
||||||
|
func (f *flexBool) UnmarshalJSON(b []byte) error {
|
||||||
|
s := strings.Trim(strings.TrimSpace(string(b)), `"`)
|
||||||
|
*f = s == "1" || s == "true" || s == "1.0"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// flexInt یک int64 است که از عدد، رشتهی عددی، خالی یا null هم decode میشود
|
||||||
|
// (تا ورودیهای متنیِ عددی مثلِ "2000" باعثِ 400 نشوند).
|
||||||
|
type flexInt int64
|
||||||
|
|
||||||
|
func (f *flexInt) UnmarshalJSON(b []byte) error {
|
||||||
|
s := strings.Trim(strings.TrimSpace(string(b)), `"`)
|
||||||
|
if s == "" || s == "null" {
|
||||||
|
*f = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseFloat(s, 64) // "2000" و "2000.0" هر دو
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*f = flexInt(int64(v))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f flexInt) i() int64 { return int64(f) }
|
||||||
@@ -23,6 +23,8 @@ type Config struct {
|
|||||||
AdminPass string // پسوردِ پنل ادمین (Basic Auth)
|
AdminPass string // پسوردِ پنل ادمین (Basic Auth)
|
||||||
CardsDir string // پوشهی دیسکیِ اسکینهای آپلودی (روی volume ماندگار)
|
CardsDir string // پوشهی دیسکیِ اسکینهای آپلودی (روی volume ماندگار)
|
||||||
CarpetsDir string // پوشهی دیسکیِ تصاویرِ فرش (روی volume ماندگار)
|
CarpetsDir string // پوشهی دیسکیِ تصاویرِ فرش (روی volume ماندگار)
|
||||||
|
AvatarsDir string // پوشهی دیسکیِ تصاویرِ شخصیتِ آواتار (روی volume ماندگار)
|
||||||
|
PublicURL string // مبدأِ عمومیِ سرور (برای ساختِ URL کاملِ تصاویر مثلِ آواتار)
|
||||||
TelescopeSize int // تعدادِ درخواستی که بازرسِ حافظه نگه میدارد
|
TelescopeSize int // تعدادِ درخواستی که بازرسِ حافظه نگه میدارد
|
||||||
// خرید درونبرنامهای (IAP)
|
// خرید درونبرنامهای (IAP)
|
||||||
IAPPackage string // نامِ بستهی اپ (برای مایکت/کافه)
|
IAPPackage string // نامِ بستهی اپ (برای مایکت/کافه)
|
||||||
@@ -48,6 +50,8 @@ func Load() Config {
|
|||||||
AdminPass: env("ADMIN_PANEL_PASS", "admin"),
|
AdminPass: env("ADMIN_PANEL_PASS", "admin"),
|
||||||
CardsDir: env("CARDS_DIR", "carddata"),
|
CardsDir: env("CARDS_DIR", "carddata"),
|
||||||
CarpetsDir: env("CARPETS_DIR", "carpetdata"),
|
CarpetsDir: env("CARPETS_DIR", "carpetdata"),
|
||||||
|
AvatarsDir: env("AVATARS_DIR", "avatardata"),
|
||||||
|
PublicURL: strings.TrimRight(env("PUBLIC_URL", "https://hakem.approagency.ir"), "/"),
|
||||||
TelescopeSize: envInt("TELESCOPE_SIZE", 200),
|
TelescopeSize: envInt("TELESCOPE_SIZE", 200),
|
||||||
IAPPackage: env("IAP_PACKAGE", ""),
|
IAPPackage: env("IAP_PACKAGE", ""),
|
||||||
MyketAccessToken: env("MYKET_ACCESS_TOKEN", ""),
|
MyketAccessToken: env("MYKET_ACCESS_TOKEN", ""),
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package economy
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Avatar یک شخصیتِ آواتار (کازمتیک تصویری) که در کلِ بازی دیده میشود. تصویرِ آن
|
||||||
|
// از /avatars/<id>.png سرو میشود. Owned یعنی قابلِ انتخاب.
|
||||||
|
type Avatar struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
PriceCoins int64 `json:"price_coins"`
|
||||||
|
VIP bool `json:"vip"`
|
||||||
|
Img string `json:"img"` // URL کاملِ تصویرِ شخصیت (URL-safe)
|
||||||
|
Owned bool `json:"owned"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avatars آواتارهای فعال را با وضعیتِ مالکیتِ کاربر برمیگرداند.
|
||||||
|
func (s *Service) Avatars(ctx context.Context, userID int64) ([]Avatar, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT id, title, price_coins, vip, img_ext FROM avatars WHERE enabled = 1 ORDER BY sort`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var avatars []Avatar
|
||||||
|
for rows.Next() {
|
||||||
|
var a Avatar
|
||||||
|
var vip int
|
||||||
|
var ext string
|
||||||
|
if err := rows.Scan(&a.ID, &a.Title, &a.PriceCoins, &vip, &ext); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a.VIP = vip == 1
|
||||||
|
a.Img = s.imgURL("avatars", a.ID+"."+ext) // URL کاملِ URL-safe
|
||||||
|
avatars = append(avatars, a)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
owned, err := s.ownedAvatars(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
isVIP := s.isVIP(ctx, userID)
|
||||||
|
for i := range avatars {
|
||||||
|
a := &avatars[i]
|
||||||
|
a.Owned = (a.PriceCoins == 0 && !a.VIP) || (a.VIP && isVIP) || owned[a.ID]
|
||||||
|
}
|
||||||
|
return avatars, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ownedAvatars(ctx context.Context, userID int64) (map[string]bool, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT avatar_id FROM user_avatars 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuyAvatar یک شخصیتِ آواتار را با سکه باز میکند.
|
||||||
|
func (s *Service) BuyAvatar(ctx context.Context, userID int64, avatarID string) error {
|
||||||
|
var price int64
|
||||||
|
var vip, enabled int
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT price_coins, vip, enabled FROM avatars WHERE id = ?`, avatarID).
|
||||||
|
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_avatars WHERE user_id = ? AND avatar_id = ?`, userID, avatarID).Scan(&exists)
|
||||||
|
if exists == 1 {
|
||||||
|
return ErrAlreadyOwned
|
||||||
|
}
|
||||||
|
if price > 0 {
|
||||||
|
if err := adjustTx(ctx, tx, userID, CurrencyCoin, -price, "buy_avatar", avatarID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO user_avatars (user_id, avatar_id) VALUES (?, ?)`, userID, avatarID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectAvatar شخصیتِ انتخابیِ کاربر را تنظیم میکند (خالی ⇒ بازگشت به آواتارِ
|
||||||
|
// تصادفی؛ وگرنه باید مالکش باشد یا رایگان/VIP).
|
||||||
|
func (s *Service) SelectAvatar(ctx context.Context, userID int64, avatarID string) error {
|
||||||
|
if avatarID == "" {
|
||||||
|
_, err := s.db.ExecContext(ctx,
|
||||||
|
`UPDATE users SET selected_avatar = '' WHERE id = ?`, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
avatars, err := s.Avatars(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, a := range avatars {
|
||||||
|
if a.ID == avatarID {
|
||||||
|
if !a.Owned {
|
||||||
|
return ErrNotOwned
|
||||||
|
}
|
||||||
|
_, err := s.db.ExecContext(ctx,
|
||||||
|
`UPDATE users SET selected_avatar = ? WHERE id = ?`, avatarID, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ type Carpet struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
PriceCoins int64 `json:"price_coins"`
|
PriceCoins int64 `json:"price_coins"`
|
||||||
VIP bool `json:"vip"`
|
VIP bool `json:"vip"`
|
||||||
|
Img string `json:"img"` // URL کاملِ تصویرِ فرش (خالی برای classic که تصویری ندارد)
|
||||||
Owned bool `json:"owned"`
|
Owned bool `json:"owned"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +29,9 @@ func (s *Service) Carpets(ctx context.Context, userID int64) ([]Carpet, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c.VIP = vip == 1
|
c.VIP = vip == 1
|
||||||
|
if c.ID != "classic" {
|
||||||
|
c.Img = s.imgURL("carpets", c.ID+".jpg")
|
||||||
|
}
|
||||||
carpets = append(carpets, c)
|
carpets = append(carpets, c)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -36,6 +37,8 @@ type Service struct {
|
|||||||
ad AdVerifier
|
ad AdVerifier
|
||||||
iap IAPVerifier
|
iap IAPVerifier
|
||||||
|
|
||||||
|
publicURL string // مبدأِ عمومیِ سرور برای ساختِ URL کاملِ تصاویر
|
||||||
|
|
||||||
mu sync.RWMutex // محافظ کشِ کاتالوگ
|
mu sync.RWMutex // محافظ کشِ کاتالوگ
|
||||||
cat Catalog // کشِ کاتالوگ خواندهشده از DB
|
cat Catalog // کشِ کاتالوگ خواندهشده از DB
|
||||||
}
|
}
|
||||||
@@ -44,6 +47,18 @@ func New(db *sql.DB, ad AdVerifier, iap IAPVerifier) *Service {
|
|||||||
return &Service{db: db, ad: ad, iap: iap}
|
return &Service{db: db, ad: ad, iap: iap}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPublicURL مبدأِ عمومیِ سرور را تنظیم میکند (برای URLهای کاملِ تصاویر).
|
||||||
|
func (s *Service) SetPublicURL(u string) { s.publicURL = strings.TrimRight(u, "/") }
|
||||||
|
|
||||||
|
// imgURL یک URLِ کاملِ URL-safe برای فایلی در پوشهی [dir] میسازد ("" اگر نام خالی
|
||||||
|
// باشد). نامِ فایل encode میشود تا فاصله/کاراکترِ خاص مشکلی ایجاد نکند.
|
||||||
|
func (s *Service) imgURL(dir, name string) string {
|
||||||
|
if name == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.publicURL + "/" + dir + "/" + url.PathEscape(name)
|
||||||
|
}
|
||||||
|
|
||||||
// Profile وضعیت اقتصادی کاربر.
|
// Profile وضعیت اقتصادی کاربر.
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
Coins int64 `json:"coins"`
|
Coins int64 `json:"coins"`
|
||||||
@@ -58,6 +73,9 @@ type Profile struct {
|
|||||||
SelectedCard string `json:"selected_card"`
|
SelectedCard string `json:"selected_card"`
|
||||||
SelectedCarpet string `json:"selected_carpet"`
|
SelectedCarpet string `json:"selected_carpet"`
|
||||||
SelectedFrame string `json:"selected_frame"`
|
SelectedFrame string `json:"selected_frame"`
|
||||||
|
SelectedAvatar string `json:"selected_avatar"` // شناسهی شخصیتِ انتخابی (برای وضعیتِ انتخاب در فروشگاه)
|
||||||
|
SelectedAvatarImg string `json:"selected_avatar_img"` // URL کاملِ تصویرِ شخصیت — خالی ⇒ آواتارِ تصادفی
|
||||||
|
SelectedCarpetImg string `json:"selected_carpet_img"` // URL کاملِ تصویرِ فرش — خالی ⇒ میزِ کلاسیک
|
||||||
RankPoints int64 `json:"rank_points"`
|
RankPoints int64 `json:"rank_points"`
|
||||||
RankTier string `json:"rank_tier"` // bronze..king
|
RankTier string `json:"rank_tier"` // bronze..king
|
||||||
RankIndex int `json:"rank_index"` // ۰..۴
|
RankIndex int `json:"rank_index"` // ۰..۴
|
||||||
@@ -71,10 +89,12 @@ type Profile struct {
|
|||||||
// GetProfile وضعیت اقتصادی کاربر را میخواند.
|
// GetProfile وضعیت اقتصادی کاربر را میخواند.
|
||||||
func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error) {
|
func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error) {
|
||||||
var p Profile
|
var p Profile
|
||||||
var vipUntil, avatar, lastDaily sql.NullString
|
var vipUntil, avatar, lastDaily, avatarImg sql.NullString
|
||||||
err := s.db.QueryRowContext(ctx,
|
err := s.db.QueryRowContext(ctx,
|
||||||
`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).
|
`SELECT coins, tickets, xp, trophies, vip_until, selected_card, selected_carpet, selected_frame, selected_avatar,
|
||||||
Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.SelectedCarpet, &p.SelectedFrame, &p.RankPoints, &avatar, &p.DailyStreak, &p.BestStreak, &lastDaily)
|
(SELECT id || '.' || img_ext FROM avatars WHERE id = users.selected_avatar AND enabled = 1),
|
||||||
|
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.SelectedAvatar, &avatarImg, &p.RankPoints, &avatar, &p.DailyStreak, &p.BestStreak, &lastDaily)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -82,6 +102,11 @@ func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
p.Avatar = avatar.String
|
p.Avatar = avatar.String
|
||||||
|
p.SelectedAvatarImg = s.imgURL("avatars", avatarImg.String)
|
||||||
|
// فرش: 'classic' تصویری ندارد (میزِ ترسیمی)؛ بقیه از /carpets/<id>.jpg.
|
||||||
|
if p.SelectedCarpet != "" && p.SelectedCarpet != "classic" {
|
||||||
|
p.SelectedCarpetImg = s.imgURL("carpets", p.SelectedCarpet+".jpg")
|
||||||
|
}
|
||||||
p.Level, p.XPInto, p.XPNeed = LevelInfo(p.XP)
|
p.Level, p.XPInto, p.XPNeed = LevelInfo(p.XP)
|
||||||
p.RankTier, p.RankIndex = RankTier(p.RankPoints)
|
p.RankTier, p.RankIndex = RankTier(p.RankPoints)
|
||||||
if vipUntil.Valid {
|
if vipUntil.Valid {
|
||||||
|
|||||||
@@ -180,6 +180,73 @@ func (h *Handler) Frames(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BuyFrame — POST /api/shop/buy-frame
|
// BuyFrame — POST /api/shop/buy-frame
|
||||||
|
// Avatars — GET /api/avatars: کاتالوگِ شخصیتها + انتخابیِ کاربر.
|
||||||
|
func (h *Handler) Avatars(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := uid(r)
|
||||||
|
if !ok {
|
||||||
|
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
avatars, err := h.svc.Avatars(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.SelectedAvatar
|
||||||
|
}
|
||||||
|
httpx.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"avatars": avatars,
|
||||||
|
"selected": selected,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuyAvatar — POST /api/shop/buy-avatar
|
||||||
|
func (h *Handler) BuyAvatar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.avatarAction(w, r, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectAvatar — POST /api/shop/select-avatar (avatar_id خالی ⇒ آواتارِ پیشفرض)
|
||||||
|
func (h *Handler) SelectAvatar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.avatarAction(w, r, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) avatarAction(w http.ResponseWriter, r *http.Request, sel bool) {
|
||||||
|
id, ok := uid(r)
|
||||||
|
if !ok {
|
||||||
|
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
AvatarID string `json:"avatar_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.SelectAvatar(r.Context(), id, req.AvatarID)
|
||||||
|
} else {
|
||||||
|
err = h.svc.BuyAvatar(r.Context(), id, req.AvatarID)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
httpx.JSON(w, http.StatusOK, map[string]string{"message": "ok"})
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
httpx.Error(w, http.StatusNotFound, "avatar not found")
|
||||||
|
case errors.Is(err, ErrAlreadyOwned):
|
||||||
|
httpx.Error(w, http.StatusConflict, "already owned")
|
||||||
|
case errors.Is(err, ErrNotOwned):
|
||||||
|
httpx.Error(w, http.StatusForbidden, "avatar not owned")
|
||||||
|
case errors.Is(err, ErrInsufficient):
|
||||||
|
httpx.Error(w, http.StatusPaymentRequired, "insufficient coins")
|
||||||
|
default:
|
||||||
|
httpx.Error(w, http.StatusInternalServerError, "server error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) BuyFrame(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) BuyFrame(w http.ResponseWriter, r *http.Request) {
|
||||||
h.frameAction(w, r, false)
|
h.frameAction(w, r, false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,29 @@ func CardsHandler(diskDir string) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AvatarsHandler تصاویرِ شخصیتِ آواتار را سرو میکند: GET /avatars/<id>.png
|
||||||
|
// فقط از پوشهی دیسکیِ آپلودِ ادمین (بدونِ embed)؛ اگر نبود ۴۰۴ (اپ به آواتارِ
|
||||||
|
// تصادفیِ پیشفرض برمیگردد).
|
||||||
|
func AvatarsHandler(diskDir string) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=604800")
|
||||||
|
if diskDir != "" {
|
||||||
|
rel := filepath.Clean("/" + strings.TrimPrefix(r.URL.Path, "/avatars/"))
|
||||||
|
full := filepath.Join(diskDir, rel)
|
||||||
|
if st, err := os.Stat(full); err == nil && !st.IsDir() {
|
||||||
|
// SVG را با content-type درست بده تا مرورگر/پنل هم آن را نمایش دهد.
|
||||||
|
if strings.HasSuffix(strings.ToLower(full), ".svg") {
|
||||||
|
w.Header().Set("Content-Type", "image/svg+xml")
|
||||||
|
}
|
||||||
|
http.ServeFile(w, r, full)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// CarpetsHandler تصاویرِ فرش را سرو میکند: GET /carpets/<id>.jpg
|
// CarpetsHandler تصاویرِ فرش را سرو میکند: GET /carpets/<id>.jpg
|
||||||
// ابتدا از پوشهی دیسکی (آپلودِ ادمین)، سپس از فرشهای embedشدهی پیشفرض.
|
// ابتدا از پوشهی دیسکی (آپلودِ ادمین)، سپس از فرشهای embedشدهی پیشفرض.
|
||||||
// اگر هیچکدام نبود ۴۰۴ (اپ به میزِ ترسیمیِ classic برمیگردد).
|
// اگر هیچکدام نبود ۴۰۴ (اپ به میزِ ترسیمیِ classic برمیگردد).
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- شخصیتهای آواتار (کازمتیک تصویری): کاربر میخرد و در کلِ بازی نمایش داده میشود.
|
||||||
|
-- تصویرِ هر آواتار از پنل ادمین آپلود و در /avatars/<id>.png سرو میشود.
|
||||||
|
CREATE TABLE IF NOT EXISTS avatars (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
price_coins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
vip INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sort INTEGER NOT NULL DEFAULT 0,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
img_ext TEXT NOT NULL DEFAULT 'png' -- png یا svg (بر اساسِ فایلِ آپلودشده)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_avatars (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
avatar_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, avatar_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- شخصیتِ انتخابیِ کاربر؛ خالی ⇒ آواتارِ تصادفیِ پیشفرض (بر اساسِ seed).
|
||||||
|
ALTER TABLE users ADD COLUMN selected_avatar TEXT NOT NULL DEFAULT '';
|
||||||
@@ -19,8 +19,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxReqBody = 8 << 10 // ۸ کیلوبایت از payloadِ درخواست
|
maxReqBody = 8 << 10 // ۸ کیلوبایت از payloadِ درخواست (فقط برای نمایش)
|
||||||
maxRespBody = 16 << 10 // ۱۶ کیلوبایت از پاسخ
|
maxRespBody = 16 << 10 // ۱۶ کیلوبایت از پاسخ
|
||||||
|
// سقفِ خواندنِ بدنهی غیرِ multipart (JSON): کامل بازگردانده میشود تا هندلر
|
||||||
|
// همه را ببیند؛ فقط برای امنیت در برابرِ بدنهی بسیار بزرگ محدود شده است.
|
||||||
|
maxCaptureBody = 4 << 20 // ۴ مگابایت
|
||||||
)
|
)
|
||||||
|
|
||||||
// Entry یک درخواستِ ثبتشده را نگه میدارد.
|
// Entry یک درخواستِ ثبتشده را نگه میدارد.
|
||||||
@@ -177,9 +180,21 @@ func (t *Telescope) Middleware(next http.Handler) http.Handler {
|
|||||||
|
|
||||||
var reqBody string
|
var reqBody string
|
||||||
if req.Body != nil && req.Method != http.MethodGet {
|
if req.Body != nil && req.Method != http.MethodGet {
|
||||||
b, _ := io.ReadAll(io.LimitReader(req.Body, maxReqBody))
|
// آپلودِ فایل (multipart) را دست نزن؛ بدنه بزرگ است و باید کامل به
|
||||||
req.Body = io.NopCloser(bytes.NewReader(b))
|
// هندلر برسد (وگرنه ParseMultipartForm شکست میخورد ⇒ «bad upload»).
|
||||||
reqBody = maskSecrets(string(b))
|
if strings.HasPrefix(req.Header.Get("Content-Type"), "multipart/form-data") {
|
||||||
|
reqBody = "[multipart upload]"
|
||||||
|
} else {
|
||||||
|
// بدنه را کامل بخوان و کامل بازگردان (تا هندلر همهی آن را ببیند)،
|
||||||
|
// اما فقط بخشِ ابتدایی را برای نمایش در تلسکوپ نگهدار.
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(req.Body, maxCaptureBody))
|
||||||
|
req.Body = io.NopCloser(bytes.NewReader(b))
|
||||||
|
n := len(b)
|
||||||
|
if n > maxReqBody {
|
||||||
|
n = maxReqBody
|
||||||
|
}
|
||||||
|
reqBody = maskSecrets(string(b[:n]))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var uid int64
|
var uid int64
|
||||||
|
|||||||
+7
-6
@@ -391,13 +391,14 @@ func (h *Hub) removeFromTable(t *pendingTable, c *Client) {
|
|||||||
func (h *Hub) broadcastLobby(t *pendingTable) {
|
func (h *Hub) broadcastLobby(t *pendingTable) {
|
||||||
players := make([]lobbyPlayer, len(t.clients))
|
players := make([]lobbyPlayer, len(t.clients))
|
||||||
for i, cl := range t.clients {
|
for i, cl := range t.clients {
|
||||||
_, _, avatar, frame := h.settler.PlayerProfile(cl.UserID)
|
_, _, avatar, frame, avatarImg := h.settler.PlayerProfile(cl.UserID)
|
||||||
players[i] = lobbyPlayer{
|
players[i] = lobbyPlayer{
|
||||||
Name: cl.Name,
|
Name: cl.Name,
|
||||||
Avatar: avatar,
|
Avatar: avatar,
|
||||||
Frame: frame,
|
Frame: frame,
|
||||||
Host: cl.UserID == t.host,
|
AvatarImg: avatarImg,
|
||||||
Seat: t.seatOf(i),
|
Host: cl.UserID == t.host,
|
||||||
|
Seat: t.seatOf(i),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for i, cl := range t.clients {
|
for i, cl := range t.clients {
|
||||||
|
|||||||
@@ -27,10 +27,11 @@ type chatMsg struct {
|
|||||||
|
|
||||||
// lobbyPlayer یک بازیکن در اتاق انتظارِ میز خصوصی.
|
// lobbyPlayer یک بازیکن در اتاق انتظارِ میز خصوصی.
|
||||||
type lobbyPlayer struct {
|
type lobbyPlayer struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Avatar string `json:"avatar"` // seed آواتارِ کاربر
|
Avatar string `json:"avatar"` // seed آواتارِ کاربر
|
||||||
Frame string `json:"frame"` // قابِ آواتارِ انتخابی
|
Frame string `json:"frame"` // قابِ آواتارِ انتخابی
|
||||||
Host bool `json:"host"`
|
AvatarImg string `json:"avatar_img"` // شخصیتِ آواتارِ انتخابی (خالی ⇒ تصادفی)
|
||||||
|
Host bool `json:"host"`
|
||||||
Seat int `json:"seat"` // جایگاهِ مطلق (۰..۳)
|
Seat int `json:"seat"` // جایگاهِ مطلق (۰..۳)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,8 +67,9 @@ type PlayerInfo struct {
|
|||||||
Connected bool `json:"connected"`
|
Connected bool `json:"connected"`
|
||||||
Coins int64 `json:"coins"`
|
Coins int64 `json:"coins"`
|
||||||
RankTier string `json:"rank_tier"` // bronze..king (برای نشانِ رتبه)
|
RankTier string `json:"rank_tier"` // bronze..king (برای نشانِ رتبه)
|
||||||
Avatar string `json:"avatar"` // seed آواتارِ کاربر
|
Avatar string `json:"avatar"` // seed آواتارِ کاربر
|
||||||
Frame string `json:"frame"` // قابِ آواتارِ انتخابی (کازمتیک)
|
Frame string `json:"frame"` // قابِ آواتارِ انتخابی (کازمتیک)
|
||||||
|
AvatarImg string `json:"avatar_img"` // شخصیتِ آواتارِ انتخابی (خالی ⇒ تصادفی)
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchedMsg به کلاینت پس از تشکیل میز.
|
// matchedMsg به کلاینت پس از تشکیل میز.
|
||||||
|
|||||||
+3
-1
@@ -41,6 +41,7 @@ type seatInfo struct {
|
|||||||
rankTier string // نشانِ رتبه برای نمایش سرِ میز
|
rankTier string // نشانِ رتبه برای نمایش سرِ میز
|
||||||
avatar string // seed آواتارِ انتخابیِ کاربر (برای نمایش سرِ میز)
|
avatar string // seed آواتارِ انتخابیِ کاربر (برای نمایش سرِ میز)
|
||||||
frame string // قابِ آواتارِ انتخابیِ کاربر (کازمتیک)
|
frame string // قابِ آواتارِ انتخابیِ کاربر (کازمتیک)
|
||||||
|
avatarImg string // شناسهی شخصیتِ آواتارِ انتخابی (خالی ⇒ آواتارِ تصادفی)
|
||||||
}
|
}
|
||||||
|
|
||||||
// auto مشخص میکند جایگاه باید خودکار بازی شود (بات یا قطعشده).
|
// auto مشخص میکند جایگاه باید خودکار بازی شود (بات یا قطعشده).
|
||||||
@@ -148,7 +149,7 @@ func (r *Room) start() {
|
|||||||
if s.isBot || s.userID == 0 {
|
if s.isBot || s.userID == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.coins, s.rankTier, s.avatar, s.frame = r.settler.PlayerProfile(s.userID)
|
s.coins, s.rankTier, s.avatar, s.frame, s.avatarImg = r.settler.PlayerProfile(s.userID)
|
||||||
}
|
}
|
||||||
for seat, s := range r.seats {
|
for seat, s := range r.seats {
|
||||||
if s.client != nil {
|
if s.client != nil {
|
||||||
@@ -549,6 +550,7 @@ func (r *Room) playersInfo() []PlayerInfo {
|
|||||||
Seat: i, Name: s.name, Bot: s.isBot,
|
Seat: i, Name: s.name, Bot: s.isBot,
|
||||||
Connected: s.isBot || s.connected,
|
Connected: s.isBot || s.connected,
|
||||||
Coins: s.coins, RankTier: s.rankTier, Avatar: s.avatar, Frame: s.frame,
|
Coins: s.coins, RankTier: s.rankTier, Avatar: s.avatar, Frame: s.frame,
|
||||||
|
AvatarImg: s.avatarImg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ type Settler interface {
|
|||||||
RecordHand(userID int64, won, kot, asHakem bool) // پایان یک هَند
|
RecordHand(userID int64, won, kot, asHakem bool) // پایان یک هَند
|
||||||
RecordCut(userID int64) // بُرِش با حکم
|
RecordCut(userID int64) // بُرِش با حکم
|
||||||
RecordGameResult(userID int64, won bool) // پایان بازی (برد/باخت)
|
RecordGameResult(userID int64, won bool) // پایان بازی (برد/باخت)
|
||||||
// PlayerProfile سکه، نشانِ رتبه، آواتار و قابِ انتخابیِ بازیکن را برای نمایش سرِ میز برمیگرداند.
|
// PlayerProfile سکه، نشانِ رتبه، آواتار، قاب و شخصیتِ آواتارِ انتخابیِ بازیکن را
|
||||||
PlayerProfile(userID int64) (coins int64, rankTier string, avatar string, frame string)
|
// برای نمایش سرِ میز برمیگرداند (avatarImg خالی ⇒ آواتارِ تصادفیِ پیشفرض).
|
||||||
|
PlayerProfile(userID int64) (coins int64, rankTier, avatar, frame, avatarImg string)
|
||||||
// ChargeTicket یک بلیت برای بُرِ مجدد در فازِ انتخابِ حکم مصرف میکند.
|
// ChargeTicket یک بلیت برای بُرِ مجدد در فازِ انتخابِ حکم مصرف میکند.
|
||||||
ChargeTicket(userID int64) error
|
ChargeTicket(userID int64) error
|
||||||
// ChargeCoins/AwardCoins برای شرطِ سکهایِ میزِ خصوصی (pot). Charge در صورتِ
|
// ChargeCoins/AwardCoins برای شرطِ سکهایِ میزِ خصوصی (pot). Charge در صورتِ
|
||||||
@@ -38,7 +39,9 @@ func (noopSettler) RecordGame(string, string, int, bool) {}
|
|||||||
func (noopSettler) RecordHand(int64, bool, bool, bool) {}
|
func (noopSettler) RecordHand(int64, bool, bool, bool) {}
|
||||||
func (noopSettler) RecordCut(int64) {}
|
func (noopSettler) RecordCut(int64) {}
|
||||||
func (noopSettler) RecordGameResult(int64, bool) {}
|
func (noopSettler) RecordGameResult(int64, bool) {}
|
||||||
func (noopSettler) PlayerProfile(int64) (int64, string, string, string) { return 0, "", "", "" }
|
func (noopSettler) PlayerProfile(int64) (int64, string, string, string, string) {
|
||||||
|
return 0, "", "", "", ""
|
||||||
|
}
|
||||||
func (noopSettler) ChargeTicket(int64) error { return nil }
|
func (noopSettler) ChargeTicket(int64) error { return nil }
|
||||||
func (noopSettler) ChargeCoins(int64, int64, string) error { return nil }
|
func (noopSettler) ChargeCoins(int64, int64, string) error { return nil }
|
||||||
func (noopSettler) AwardCoins(int64, int64, string) {}
|
func (noopSettler) AwardCoins(int64, int64, string) {}
|
||||||
|
|||||||
@@ -481,7 +481,9 @@ func (s *recordingSettler) RecordGameResult(int64, bool) {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *recordingSettler) PlayerProfile(int64) (int64, string, string, string) { return 0, "", "", "" }
|
func (s *recordingSettler) PlayerProfile(int64) (int64, string, string, string, string) {
|
||||||
|
return 0, "", "", "", ""
|
||||||
|
}
|
||||||
func (s *recordingSettler) ChargeTicket(int64) error { return nil }
|
func (s *recordingSettler) ChargeTicket(int64) error { return nil }
|
||||||
func (s *recordingSettler) ChargeCoins(int64, int64, string) error { return nil }
|
func (s *recordingSettler) ChargeCoins(int64, int64, string) error { return nil }
|
||||||
func (s *recordingSettler) AwardCoins(int64, int64, string) {}
|
func (s *recordingSettler) AwardCoins(int64, int64, string) {}
|
||||||
|
|||||||
Reference in New Issue
Block a user