Files
past-go/past-go/internal/handler/pet.go
T

523 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"fmt"
"math"
"time"
"past/internal/httpapi"
"past/internal/store"
)
func registerPet(r *httpapi.Router) {
r.Handle("pet_info", petInfo)
r.Handle("pet_adopt", petAdopt)
r.Handle("pet_daily_action", petDailyAction)
r.Handle("pet_daily_status", petDailyStatus)
r.Handle("pet_achievements", petAchievements)
r.Handle("pet_rename", petRename)
r.Handle("pet_equip", petEquip)
r.Handle("pet_dynamics", petDynamics)
}
type petAch struct{ Key, Name, Desc, Icon string }
// allPetAchievements 对应 getAllPetAchievements()(保持顺序)
var allPetAchievements = []petAch{
{"first_adopt", "初次相遇", "领养第一只宠物", "🐣"},
{"level_5", "成长之路", "宠物达到5级", "⭐"},
{"level_10", "小有成就", "宠物达到10级", "🌟"},
{"level_20", "资深铲屎官", "宠物达到20级", "👑"},
{"level_30", "终极陪伴", "宠物达到30级", "💎"},
{"stage_2", "少年初长成", "宠物进入少年期", "🐕"},
{"stage_3", "青春活力", "宠物进入青年期", "🦮"},
{"stage_4", "成熟稳重", "宠物进入成年期", "🐩"},
{"stage_5", "终极形态", "宠物进入终极期", "🦁"},
{"all_done_7", "一周全勤", "连续7天完成所有任务", "📅"},
{"all_done_30", "月度之星", "连续30天完成所有任务", "🏆"},
{"feed_100", "大胃王", "累计喂食100次", "🍖"},
{"walk_50", "散步达人", "累计散步50次", "🚶"},
{"bath_30", "爱干净", "累计洗澡30次", "🛁"},
{"play_50", "玩伴", "累计玩耍50次", "🎾"},
{"happiness_100", "快乐满分", "宠物心情达到100", "😊"},
{"rename_first", "取名大师", "第一次给宠物改名", "✏️"},
{"adopt_7days", "一周陪伴", "领养满7天", "📆"},
{"adopt_30days", "一月相伴", "领养满30天", "🗓️"},
{"adopt_100days", "百日纪念", "领养满100天", "💯"},
}
func petAchByKey(key string) petAch {
for _, a := range allPetAchievements {
if a.Key == key {
return a
}
}
return petAch{}
}
// consecutiveDoneDays 从按日期倒序的 all_done 记录统计连续天数
func consecutiveDoneDays(rows []store.Row) int64 {
var consecutive int64
var prev time.Time
for i, r := range rows {
cur, _ := parseDate(r.Str("date"))
if i == 0 {
consecutive = 1
} else {
if math.Round(prev.Sub(cur).Hours()/24) == 1 {
consecutive++
} else {
break
}
}
prev = cur
}
return consecutive
}
// checkPetAchievements 对应 checkPetAchievements($db, $coupleId, $level, $stage, $happiness, $extra)
// happiness < 0 表示 null(不检查心情成就)
func checkPetAchievements(c *httpapi.Ctx, coupleID, level, stage, happiness int64, rename bool) {
db := c.DB
rs, _ := db.Query("SELECT achievement_key FROM pet_achievements WHERE couple_id = ?", coupleID)
unlocked := map[string]bool{}
for _, r := range rs {
unlocked[r.Str("achievement_key")] = true
}
var toUnlock []string
add := func(cond bool, key string) {
if cond && !unlocked[key] {
toUnlock = append(toUnlock, key)
}
}
add(true, "first_adopt")
add(level >= 5, "level_5")
add(level >= 10, "level_10")
add(level >= 20, "level_20")
add(level >= 30, "level_30")
add(stage >= 2, "stage_2")
add(stage >= 3, "stage_3")
add(stage >= 4, "stage_4")
add(stage >= 5, "stage_5")
if p, _ := db.QueryOne("SELECT adopted_at FROM pets WHERE couple_id = ?", coupleID); p != nil && p.Str("adopted_at") != "" {
if t, err := parseDate(p.Str("adopted_at")); err == nil {
days := int64(math.Floor(time.Since(t).Seconds() / 86400))
add(days >= 7, "adopt_7days")
add(days >= 30, "adopt_30days")
add(days >= 100, "adopt_100days")
}
}
add(happiness >= 0 && happiness >= 100, "happiness_100")
if !unlocked["feed_100"] || !unlocked["walk_50"] || !unlocked["bath_30"] || !unlocked["play_50"] {
if cnt, _ := db.QueryOne("SELECT SUM(feed_u1 + feed_u2) as feed_cnt, SUM(walk_u1 + walk_u2) as walk_cnt, SUM(bath_u1 + bath_u2) as bath_cnt, SUM(play_u1 + play_u2) as play_cnt FROM pet_dailies WHERE couple_id = ?", coupleID); cnt != nil {
add(cnt.Int("feed_cnt") >= 100, "feed_100")
add(cnt.Int("walk_cnt") >= 50, "walk_50")
add(cnt.Int("bath_cnt") >= 30, "bath_30")
add(cnt.Int("play_cnt") >= 50, "play_50")
}
}
if !unlocked["all_done_7"] || !unlocked["all_done_30"] {
done, _ := db.Query("SELECT date, all_done FROM pet_dailies WHERE couple_id = ? AND all_done = 1 ORDER BY date DESC", coupleID)
consecutive := consecutiveDoneDays(done)
add(consecutive >= 7, "all_done_7")
add(consecutive >= 30, "all_done_30")
}
add(rename, "rename_first")
if len(toUnlock) == 0 {
return
}
// pet_dynamics.user_id 有外键指向 users,而系统动态写 user_id=0:PHP 版本在此处因外键失败而 500,
// Go 版本在独占连接上临时关闭外键检查写入(语义即"系统"动态)。
db.ExecNoFK(func(q store.Querier) error {
for _, key := range toUnlock {
q.Exec("INSERT IGNORE INTO pet_achievements (couple_id, achievement_key) VALUES (?, ?)", coupleID, key)
a := petAchByKey(key)
q.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, 0, 'achievement', ?)", coupleID, "解锁成就:"+a.Name+" "+a.Icon)
}
return nil
})
}
// coupleUserIDsOrdered 对应 SELECT id FROM users WHERE couple_id = ? ORDER BY id ASC LIMIT 2 → [user1, user2](缺省 0)
func coupleUserIDsOrdered(c *httpapi.Ctx, coupleID int64) (int64, int64) {
r1, r2 := coupleUserIDsRaw(c, coupleID)
return store.ToInt(r1), store.ToInt(r2)
}
// coupleUserIDsRaw 同上,但保留 PHP 的原始值类型($users[0] ?? 0:存在为行值,缺省为整数 0)
func coupleUserIDsRaw(c *httpapi.Ctx, coupleID int64) (any, any) {
rs, _ := c.DB.Query("SELECT id FROM users WHERE couple_id = ? ORDER BY id ASC LIMIT 2", coupleID)
var u1, u2 any = int64(0), int64(0)
if len(rs) > 0 {
u1 = rs[0]["id"]
}
if len(rs) > 1 {
u2 = rs[1]["id"]
}
return u1, u2
}
func petInfo(c *httpapi.Ctx) error {
uid := c.UID()
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
pet, err := c.DB.QueryOne("SELECT * FROM pets WHERE couple_id = ?", coupleID)
if err != nil {
return err
}
if pet == nil {
return c.OK(M{"has_pet": false, "pet": nil})
}
if fa, _ := c.DB.QueryOne("SELECT id FROM pet_achievements WHERE couple_id = ? AND achievement_key = 'first_adopt'", coupleID); fa == nil {
c.DB.ExecNoFK(func(q store.Querier) error {
q.Exec("INSERT IGNORE INTO pet_achievements (couple_id, achievement_key) VALUES (?, 'first_adopt')", coupleID)
q.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, 0, 'achievement', ?)", coupleID, "解锁成就:初次相遇 🐣")
return nil
})
}
daily, _ := c.DB.QueryOne("SELECT * FROM pet_dailies WHERE couple_id = ? AND date = ?", coupleID, today())
achs, _ := c.DB.Query("SELECT achievement_key, unlocked_at FROM pet_achievements WHERE couple_id = ?", coupleID)
achKeys := []any{}
for _, a := range achs {
achKeys = append(achKeys, a["achievement_key"])
}
equipped, _ := c.DB.Query("SELECT item_key, item_type FROM pet_items WHERE couple_id = ? AND equipped = 1", coupleID)
dynamics, _ := c.DB.Query("SELECT pd.*, u.nickname FROM pet_dynamics pd LEFT JOIN users u ON pd.user_id = u.id WHERE pd.couple_id = ? ORDER BY pd.created_at DESC LIMIT 20", coupleID)
u1raw, u2raw := coupleUserIDsRaw(c, coupleID)
u1, u2 := store.ToInt(u1raw), store.ToInt(u2raw)
expForNext := pet.Int("level") * 100
var expProgress any = 0
if expForNext > 0 {
p := math.Round(float64(pet.Int("exp")) / float64(expForNext) * 100)
if p > 100 {
p = 100
}
expProgress = p
}
counts := M{"feed": 0, "walk": 0, "bath": 0, "play": 0}
if ac, _ := c.DB.QueryOne("SELECT SUM(feed_u1 + feed_u2) as feed_cnt, SUM(walk_u1 + walk_u2) as walk_cnt, SUM(bath_u1 + bath_u2) as bath_cnt, SUM(play_u1 + play_u2) as play_cnt FROM pet_dailies WHERE couple_id = ?", coupleID); ac != nil {
counts = M{"feed": ac.Int("feed_cnt"), "walk": ac.Int("walk_cnt"), "bath": ac.Int("bath_cnt"), "play": ac.Int("play_cnt")}
}
done, _ := c.DB.Query("SELECT date, all_done FROM pet_dailies WHERE couple_id = ? AND all_done = 1 ORDER BY date DESC", coupleID)
var dailyOut any
if daily != nil {
dailyOut = daily
}
return c.OK(M{
"has_pet": true, "pet": pet, "daily": dailyOut, "achievements": achKeys, "equipped": rows(equipped), "dynamics": rows(dynamics),
"user1_id": u1raw, "user2_id": u2raw, "is_user1": uid == u1, "is_user2": uid == u2,
"exp_for_next_level": expForNext, "exp_progress": expProgress, "action_counts": counts,
"consecutive_done_days": consecutiveDoneDays(done),
})
}
func petAdopt(c *httpapi.Ctx) error {
uid := c.UID()
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
name := c.InTrim("name")
if !c.InHas("name") {
name = "小狗狗"
}
breed := c.InTrim("breed")
if !c.InHas("breed") {
breed = "golden"
}
gender := int64(1)
if c.InHas("gender") {
gender = c.InInt("gender")
}
switch breed {
case "golden", "husky", "corgi", "shiba":
default:
breed = "golden"
}
if ex, _ := c.DB.QueryOne("SELECT id FROM pets WHERE couple_id = ?", coupleID); ex != nil {
return c.Err(400, "已领养过宠物")
}
petID, err := c.DB.Insert("INSERT INTO pets (couple_id, name, breed, gender, stage, level, exp, happiness, hunger, energy, cleanliness, adopted_at, created_by) VALUES (?, ?, ?, ?, 1, 1, 0, 100, 100, 100, 100, ?, ?)",
coupleID, name, breed, gender, today(), uid)
if err != nil {
return err
}
c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, 'adopt', ?)", coupleID, uid, "领养了"+name+"!🎉")
actID := logActivity(c.DB, coupleID, uid, "pet_adopt", "领养了宠物"+name, "")
c.DB.Exec("INSERT INTO pet_items (couple_id, item_key, item_type, quantity, equipped) VALUES (?, 'collar_basic', 'collar', 1, 1)", coupleID)
checkPetAchievements(c, coupleID, 1, 1, 100, false)
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
// 使用活动ID记录发送事件,供发送日志和去重查询使用。
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "领养了新宠物 🐾", "thing2": "TA领养了「" + name + "」,快来一起照顾吧", "date3": today(), "thing4": "点击查看宠物详情 💕",
}, "pages/pet/pet", actID, uid)
}
return c.OK(M{"success": true, "pet_id": fmt.Sprint(petID)})
}
var petActionEffects = map[string]map[string]int64{
"feed": {"exp": 10, "happiness": 3, "hunger": 15},
"water": {"exp": 8, "happiness": 2, "energy": 5},
"walk": {"exp": 15, "happiness": 8, "energy": -10, "cleanliness": -5},
"bath": {"exp": 12, "happiness": 5, "cleanliness": 20, "energy": -5},
"sleep": {"exp": 5, "energy": 20, "happiness": 3},
"play": {"exp": 12, "happiness": 10, "energy": -8},
}
var petActionMessages = map[string]string{
"feed": "喂食了宠物 🍖", "water": "给宠物喝了水 💧", "walk": "带宠物去散步 🐾",
"bath": "给宠物洗了澡 🛁", "sleep": "哄宠物睡觉了 😴", "play": "和宠物玩耍了 🎾",
}
var petDailyFields = []string{"feed_u1", "feed_u2", "water_u1", "water_u2", "walk_u1", "walk_u2", "bath_u1", "bath_u2", "sleep_u1", "sleep_u2", "play_u1", "play_u2"}
func clamp100(v int64) int64 {
if v < 0 {
return 0
}
if v > 100 {
return 100
}
return v
}
func petDailyAction(c *httpapi.Ctx) error {
uid := c.UID()
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
action := c.InTrim("action")
effects, ok := petActionEffects[action]
if !ok {
return c.Err(400, "无效的动作")
}
pet, _ := c.DB.QueryOne("SELECT * FROM pets WHERE couple_id = ?", coupleID)
if pet == nil {
return c.Err(400, "请先领养宠物")
}
u1, _ := coupleUserIDsOrdered(c, coupleID)
suffix := "u2"
if uid == u1 {
suffix = "u1"
}
td := today()
daily, _ := c.DB.QueryOne("SELECT * FROM pet_dailies WHERE couple_id = ? AND date = ?", coupleID, td)
if daily == nil {
if _, err := c.DB.Exec("INSERT INTO pet_dailies (couple_id, date) VALUES (?, ?)", coupleID, td); err != nil {
return err
}
daily, _ = c.DB.QueryOne("SELECT * FROM pet_dailies WHERE couple_id = ? AND date = ?", coupleID, td)
}
field := action + "_" + suffix
if daily.Int(field) == 1 {
return c.JSON(400, M{"error": "你今天已完成此任务", "already_done": true})
}
if _, err := c.DB.Exec("UPDATE pet_dailies SET "+field+" = 1 WHERE id = ?", daily.Int("id")); err != nil {
return err
}
allDone := true
for _, f := range petDailyFields {
if daily.Int(f) == 0 && f != field {
row, _ := c.DB.QueryOne("SELECT "+f+" FROM pet_dailies WHERE id = ?", daily.Int("id"))
if row == nil || row.Int(f) == 0 {
allDone = false
break
}
}
}
newHappiness := clamp100(pet.Int("happiness") + effects["happiness"])
newHunger := clamp100(pet.Int("hunger") + effects["hunger"])
newEnergy := clamp100(pet.Int("energy") + effects["energy"])
newClean := clamp100(pet.Int("cleanliness") + effects["cleanliness"])
newExp := pet.Int("exp") + effects["exp"]
newLevel := pet.Int("level")
// PHP: $newLevel = $pet['level'] 为行值(8.0 下是字符串),只有 ++ 后才变为整数;$newStage 同理
var levelOut, stageOut any = pet["level"], pet["stage"]
expNeeded := newLevel * 100
for newExp >= expNeeded {
newExp -= expNeeded
newLevel++
levelOut = newLevel
expNeeded = newLevel * 100
}
newStage := pet.Int("stage")
switch {
case newLevel >= 30:
newStage = 5
case newLevel >= 20:
newStage = 4
case newLevel >= 12:
newStage = 3
case newLevel >= 5:
newStage = 2
}
if newStage != pet.Int("stage") || newLevel >= 5 {
stageOut = newStage
}
if _, err := c.DB.Exec("UPDATE pets SET happiness = ?, hunger = ?, energy = ?, cleanliness = ?, exp = ?, level = ?, stage = ?, updated_at = NOW() WHERE id = ?",
newHappiness, newHunger, newEnergy, newClean, newExp, newLevel, newStage, pet.Int("id")); err != nil {
return err
}
msg := petActionMessages[action]
c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, ?, ?)", coupleID, uid, action, msg)
if allDone {
c.DB.Exec("UPDATE pet_dailies SET all_done = 1 WHERE id = ?", daily.Int("id"))
c.DB.Exec("UPDATE pets SET exp = exp + 20, happiness = happiness + 10, updated_at = NOW() WHERE id = ?", pet.Int("id"))
c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, 'all_done', '今日所有任务完成!太棒了 🎉')", coupleID, uid)
logActivity(c.DB, coupleID, uid, "pet_all_done", "今日宠物所有任务完成!", "")
}
checkPetAchievements(c, coupleID, newLevel, newStage, newHappiness, false)
actID := logActivity(c.DB, coupleID, uid, "pet_daily", msg, "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
notifyMsg := msg
if allDone {
notifyMsg = msg + ",今日所有任务完成!🎉"
}
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "宠物日常 🐾", "thing2": notifyMsg, "date3": td, "thing4": "点击查看宠物详情 💕",
}, "pages/pet/pet", actID, uid)
}
return c.OK(M{
"success": true, "exp_gain": effects["exp"], "happiness": newHappiness, "hunger": newHunger, "energy": newEnergy,
"cleanliness": newClean, "level": levelOut, "stage": stageOut, "all_done": allDone,
})
}
func petDailyStatus(c *httpapi.Ctx) error {
uid := c.UID()
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
date := c.Q("date")
if !c.QHas("date") {
date = today()
}
daily, err := c.DB.QueryOne("SELECT * FROM pet_dailies WHERE couple_id = ? AND date = ?", coupleID, date)
if err != nil {
return err
}
u1, _ := coupleUserIDsOrdered(c, coupleID)
suffix, partnerSuffix := "u2", "u1"
if uid == u1 {
suffix, partnerSuffix = "u1", "u2"
}
var myDone, partnerDone any = []any{}, []any{}
var allDone any = false
if daily != nil {
md, pd := M{}, M{}
for _, a := range []string{"feed", "water", "walk", "bath", "sleep", "play"} {
md[a] = daily.Int(a+"_"+suffix) == 1
pd[a] = daily.Int(a+"_"+partnerSuffix) == 1
}
myDone, partnerDone = md, pd
allDone = daily["all_done"]
}
return c.OK(M{"date": date, "has_record": daily != nil, "my_done": myDone, "partner_done": partnerDone, "all_done": allDone})
}
func petAchievements(c *httpapi.Ctx) error {
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
unlocked, err := c.DB.Query("SELECT achievement_key, unlocked_at FROM pet_achievements WHERE couple_id = ? ORDER BY unlocked_at ASC", coupleID)
if err != nil {
return err
}
at := map[string]any{}
for _, u := range unlocked {
k := u.Str("achievement_key")
if _, seen := at[k]; !seen {
at[k] = u["unlocked_at"]
}
}
result := make([]M, 0, len(allPetAchievements))
for _, a := range allPetAchievements {
ua, ok := at[a.Key]
if !ok {
ua = nil
}
result = append(result, M{"key": a.Key, "name": a.Name, "desc": a.Desc, "icon": a.Icon, "unlocked": ok, "unlocked_at": ua})
}
return c.OK(M{"achievements": result})
}
func petRename(c *httpapi.Ctx) error {
uid := c.UID()
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
name := c.InTrim("name")
if !store.Truthy(name) || len([]rune(name)) > 20 {
return c.Err(400, "名称长度1-20个字符")
}
if _, err := c.DB.Exec("UPDATE pets SET name = ?, updated_at = NOW() WHERE couple_id = ?", name, coupleID); err != nil {
return err
}
c.DB.Exec("INSERT INTO pet_dynamics (couple_id, user_id, action, message) VALUES (?, ?, 'rename', ?)", coupleID, uid, "给宠物改名为"+name+" ✏️")
actID := logActivity(c.DB, coupleID, uid, "pet_rename", "给宠物改名为"+name, "")
info, _ := c.DB.QueryOne("SELECT level, stage, happiness FROM pets WHERE couple_id = ?", coupleID)
var level, stage, happiness int64
if info != nil {
level, stage, happiness = info.Int("level"), info.Int("stage"), info.Int("happiness")
}
checkPetAchievements(c, coupleID, level, stage, happiness, true)
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "宠物改名 ✏️", "thing2": "TA给宠物改名为「" + name + "」", "date3": today(), "thing4": "点击查看新名字 💕",
}, "pages/pet/pet", actID, uid)
}
return c.OK(M{"success": true, "name": name})
}
func petEquip(c *httpapi.Ctx) error {
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
itemKey := c.InTrim("item_key")
equip := int64(1)
if c.InHas("equip") {
equip = c.InInt("equip")
}
if !store.Truthy(itemKey) {
return c.Err(400, "缺少物品标识")
}
item, _ := c.DB.QueryOne("SELECT * FROM pet_items WHERE couple_id = ? AND item_key = ?", coupleID, itemKey)
if item == nil {
return c.Err(400, "物品不存在")
}
if equip != 0 {
c.DB.Exec("UPDATE pet_items SET equipped = 0 WHERE couple_id = ? AND item_type = ? AND item_key != ?", coupleID, item["item_type"], itemKey)
c.DB.Exec("UPDATE pet_items SET equipped = 1 WHERE couple_id = ? AND item_key = ?", coupleID, itemKey)
} else {
c.DB.Exec("UPDATE pet_items SET equipped = 0 WHERE couple_id = ? AND item_key = ?", coupleID, itemKey)
}
return c.OK(M{"success": true, "equipped": equip == 1})
}
func petDynamics(c *httpapi.Ctx) error {
coupleID, err := requireCoupleID(c)
if err != nil {
return err
}
page := int64(1)
if c.QHas("page") {
page = c.QInt("page")
}
limit := int64(20)
offset := (page - 1) * limit
dynamics, err := c.DB.Query("SELECT pd.*, u.nickname, u.avatar FROM pet_dynamics pd LEFT JOIN users u ON pd.user_id = u.id WHERE pd.couple_id = ? ORDER BY pd.created_at DESC LIMIT ? OFFSET ?", coupleID, limit, offset)
if err != nil {
return err
}
totalRaw := countRaw(c, "total", "SELECT COUNT(*) as total FROM pet_dynamics WHERE couple_id = ?", coupleID)
total := store.ToInt(totalRaw)
return c.OK(M{"dynamics": rows(dynamics), "total": totalRaw, "page": page, "has_more": (offset + limit) < total})
}