Improve live and random live handling

- Add random live handlers, schemas and persistence for lot/play/continue/gameover/reward flow
- Update live status and schedule generation, including special rotation handling and CN timezone alignment
- Add config support for unlocking all daily special rotation songs and document the option
- Refresh live play, reward, precise score and session logic plus bundled main.db updates

Signed-off-by: Sean Du <do4suki@gmail.com>
This commit is contained in:
2026-06-06 20:59:45 +08:00
parent 4e642cc7c1
commit 0aacf8a4e9
28 changed files with 1085 additions and 228 deletions
+39
View File
@@ -0,0 +1,39 @@
package rlive
import (
"errors"
usermodel "honoka-chan/internal/model/user"
"honoka-chan/internal/session"
)
func getRandomLiveByToken(ss *session.Session, token string) (usermodel.UserLiveRandom, error) {
if token == "" {
return usermodel.UserLiveRandom{}, errors.New("random live token is required")
}
row := usermodel.UserLiveRandom{}
has, err := ss.UserEng.Table(new(usermodel.UserLiveRandom)).
Where("user_id = ? AND token = ?", ss.UserID, token).
Get(&row)
if err != nil {
return usermodel.UserLiveRandom{}, err
}
if !has {
return usermodel.UserLiveRandom{}, errors.New("random live session not found")
}
return row, nil
}
func deleteRandomLiveByToken(ss *session.Session, token string) {
if token == "" || ss.UserEng == nil {
return
}
_, err := ss.UserEng.Table(new(usermodel.UserLiveRandom)).
Where("user_id = ? AND token = ?", ss.UserID, token).
Delete()
if ss.CheckErr(err) {
return
}
}
+41
View File
@@ -0,0 +1,41 @@
package rlive
import (
"errors"
livehandler "honoka-chan/internal/handler/live"
"honoka-chan/internal/middleware"
"honoka-chan/internal/router"
rliveschema "honoka-chan/internal/schema/rlive"
"honoka-chan/internal/session"
honokautils "honoka-chan/internal/utils"
"github.com/gin-gonic/gin"
)
func continuee(ctx *gin.Context) {
ss := session.Get(ctx)
defer ss.Finalize()
req := rliveschema.TokenReq{}
err := honokautils.ParseRequestData(ctx, &req)
if ss.CheckErr(err) {
return
}
_, err = getRandomLiveByToken(ss, req.Token)
if ss.CheckErr(err) {
return
}
hasProgress, _ := ss.GetLiveInProgress()
if !hasProgress {
ss.CheckErr(errors.New("live progress not found"))
return
}
ss.Respond(livehandler.BuildContinueResp(ss))
}
func init() {
router.AddHandler("main.php", "POST", "/rlive/continue", middleware.Common, continuee)
}
+42
View File
@@ -0,0 +1,42 @@
package rlive
import (
livehandler "honoka-chan/internal/handler/live"
"honoka-chan/internal/middleware"
"honoka-chan/internal/router"
rliveschema "honoka-chan/internal/schema/rlive"
"honoka-chan/internal/session"
honokautils "honoka-chan/internal/utils"
"github.com/gin-gonic/gin"
)
func gameOver(ctx *gin.Context) {
ss := session.Get(ctx)
token := ""
defer func() {
if ss.UserEng != nil {
deleteRandomLiveByToken(ss, token)
ss.ClearLiveInProgress()
}
ss.Finalize()
}()
req := rliveschema.TokenReq{}
err := honokautils.ParseRequestData(ctx, &req)
if ss.CheckErr(err) {
return
}
token = req.Token
_, err = getRandomLiveByToken(ss, req.Token)
if ss.CheckErr(err) {
return
}
ss.Respond(livehandler.BuildGameOverResp())
}
func init() {
router.AddHandler("main.php", "POST", "/rlive/gameover", middleware.Common, gameOver)
}
+251
View File
@@ -0,0 +1,251 @@
package rlive
import (
"crypto/rand"
"errors"
livehandler "honoka-chan/internal/handler/live"
"honoka-chan/internal/middleware"
usermodel "honoka-chan/internal/model/user"
"honoka-chan/internal/router"
rliveschema "honoka-chan/internal/schema/rlive"
"honoka-chan/internal/session"
honokautils "honoka-chan/internal/utils"
pkgutils "honoka-chan/pkg/utils"
"math/big"
"os"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
)
var rliveCST = time.FixedZone("CST", 8*3600)
type randomLiveCandidateRow struct {
LiveDifficultyID int `xorm:"live_difficulty_id"`
NotesSettingAsset string `xorm:"notes_setting_asset"`
AcFlag int `xorm:"ac_flag"`
SwingFlag int `xorm:"swing_flag"`
}
func lot(ctx *gin.Context) {
ss := session.Get(ctx)
defer ss.Finalize()
req := rliveschema.LotReq{}
err := honokautils.ParseRequestData(ctx, &req)
if ss.CheckErr(err) {
return
}
if err := validateLotReq(req, time.Now()); ss.CheckErr(err) {
return
}
randomLive, err := loadOrCreateRandomLive(ss, req)
if ss.CheckErr(err) {
return
}
partyListData, err := livehandler.BuildPartyListData(ss)
if ss.CheckErr(err) {
return
}
ss.Respond(rliveschema.LotResp{
ResponseData: rliveschema.LotData{
LiveInfo: rliveschema.LiveInfo{
LiveDifficultyID: randomLive.LiveDifficultyID,
IsRandom: true,
AcFlag: randomLive.AcFlag,
SwingFlag: randomLive.SwingFlag,
},
HasSlideNotes: randomLive.SwingFlag,
PartyList: partyListData.PartyList,
TrainingEnergy: partyListData.TrainingEnergy,
TrainingEnergyMax: partyListData.TrainingEnergyMax,
Token: randomLive.Token,
},
ReleaseInfo: []any{},
StatusCode: 200,
})
}
type randomLiveResult struct {
Token string
LiveDifficultyID int
AcFlag int
SwingFlag int
}
func loadOrCreateRandomLive(ss *session.Session, req rliveschema.LotReq) (randomLiveResult, error) {
sessionRow := usermodel.UserLiveRandom{}
has, err := ss.UserEng.Table(new(usermodel.UserLiveRandom)).
Where("user_id = ?", ss.UserID).
Where("attribute = ?", req.Attribute).
Where("difficulty = ?", req.Difficulty).
Where("member_category = ?", req.MemberCategory).
Get(&sessionRow)
if err != nil {
return randomLiveResult{}, err
}
if has {
liveInfo, err := loadRandomLiveInfo(ss, sessionRow.LiveDifficultyID)
if err != nil {
return randomLiveResult{}, err
}
return randomLiveResult{
Token: sessionRow.Token,
LiveDifficultyID: sessionRow.LiveDifficultyID,
AcFlag: liveInfo.AcFlag,
SwingFlag: liveInfo.SwingFlag,
}, nil
}
candidates, err := listRandomLiveCandidates(ss, req.Difficulty, req.Attribute, req.MemberCategory)
if err != nil {
return randomLiveResult{}, err
}
if len(candidates) == 0 {
return randomLiveResult{}, errors.New("no random live candidates available")
}
index, err := cryptoRandIndex(len(candidates))
if err != nil {
return randomLiveResult{}, err
}
picked := candidates[index]
token := pkgutils.RandomStr(64)
if token == "" {
return randomLiveResult{}, errors.New("failed to generate random live token")
}
_, err = ss.UserEng.Insert(&usermodel.UserLiveRandom{
UserID: ss.UserID,
Attribute: req.Attribute,
Difficulty: req.Difficulty,
MemberCategory: req.MemberCategory,
Token: token,
LiveDifficultyID: picked.LiveDifficultyID,
InProgress: false,
})
if err != nil {
return randomLiveResult{}, err
}
return randomLiveResult{
Token: token,
LiveDifficultyID: picked.LiveDifficultyID,
AcFlag: picked.AcFlag,
SwingFlag: picked.SwingFlag,
}, nil
}
func validateLotReq(req rliveschema.LotReq, now time.Time) error {
if req.Difficulty < 1 || req.Difficulty > 4 {
return errors.New("invalid difficulty")
}
if req.Attribute < 1 || req.Attribute > 3 {
return errors.New("invalid attribute")
}
if req.MemberCategory < 0 {
return errors.New("invalid member category")
}
if int(now.In(rliveCST).Weekday())%3+1 != req.Attribute {
return errors.New("attribute does not match current random live rotation")
}
return nil
}
func listRandomLiveCandidates(ss *session.Session, difficulty, attribute, memberCategory int) ([]randomLiveCandidateRow, error) {
rows := []randomLiveCandidateRow{}
sql := `
SELECT
difficulty.live_difficulty_id,
setting.notes_setting_asset,
setting.ac_flag,
setting.swing_flag
FROM live_setting_m AS setting
INNER JOIN (
SELECT live_setting_id, live_difficulty_id FROM special_live_m
UNION
SELECT live_setting_id, live_difficulty_id FROM normal_live_m
) AS difficulty ON setting.live_setting_id = difficulty.live_setting_id
INNER JOIN live_track_m AS track ON track.live_track_id = setting.live_track_id
WHERE setting.difficulty = ? AND setting.attribute_icon_id = ?
`
args := []any{difficulty, attribute}
if memberCategory > 0 {
sql += ` AND track.member_category = ?`
args = append(args, memberCategory)
}
sql += `
ORDER BY difficulty.live_difficulty_id ASC
`
err := ss.MainEng.SQL(sql, args...).Find(&rows)
if err != nil {
return nil, err
}
result := make([]randomLiveCandidateRow, 0, len(rows))
for _, row := range rows {
if !hasRandomLiveBeatmap(row.NotesSettingAsset) {
continue
}
result = append(result, row)
}
return result, nil
}
func loadRandomLiveInfo(ss *session.Session, liveDifficultyID int) (randomLiveCandidateRow, error) {
rows := []randomLiveCandidateRow{}
err := ss.MainEng.SQL(`
SELECT
difficulty.live_difficulty_id,
setting.notes_setting_asset,
setting.ac_flag,
setting.swing_flag
FROM live_setting_m AS setting
INNER JOIN (
SELECT live_setting_id, live_difficulty_id FROM special_live_m
UNION
SELECT live_setting_id, live_difficulty_id FROM normal_live_m
) AS difficulty ON setting.live_setting_id = difficulty.live_setting_id
WHERE difficulty.live_difficulty_id = ?
LIMIT 1
`, liveDifficultyID).Find(&rows)
if err != nil {
return randomLiveCandidateRow{}, err
}
if len(rows) == 0 {
return randomLiveCandidateRow{}, errors.New("random live difficulty not found")
}
return rows[0], nil
}
func hasRandomLiveBeatmap(notesSettingAsset string) bool {
if notesSettingAsset == "" {
return false
}
path := filepath.Join("assets/serverdata/beatmaps", notesSettingAsset)
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func cryptoRandIndex(length int) (int, error) {
if length <= 0 {
return 0, errors.New("invalid random candidate length")
}
n, err := rand.Int(rand.Reader, big.NewInt(int64(length)))
if err != nil {
return 0, err
}
return int(n.Int64()), nil
}
func init() {
router.AddHandler("main.php", "POST", "/rlive/lot", middleware.Common, lot)
}
+55
View File
@@ -0,0 +1,55 @@
package rlive
import (
livehandler "honoka-chan/internal/handler/live"
"honoka-chan/internal/middleware"
usermodel "honoka-chan/internal/model/user"
"honoka-chan/internal/router"
rliveschema "honoka-chan/internal/schema/rlive"
"honoka-chan/internal/session"
honokautils "honoka-chan/internal/utils"
"github.com/gin-gonic/gin"
)
func play(ctx *gin.Context) {
ss := session.Get(ctx)
defer ss.Finalize()
req := rliveschema.PlayReq{}
err := honokautils.ParseRequestData(ctx, &req)
if ss.CheckErr(err) {
return
}
randomLive, err := getRandomLiveByToken(ss, req.Token)
if ss.CheckErr(err) {
return
}
if err := ss.ResetRandomLiveInProgress(); ss.CheckErr(err) {
return
}
ss.ClearLiveInProgress()
ss.RegisterLiveInProgress(req.UnitDeckID)
_, err = ss.UserEng.Table(new(usermodel.UserLiveRandom)).
Where("user_id = ? AND token = ?", ss.UserID, req.Token).
Cols("in_progress").
Update(&usermodel.UserLiveRandom{InProgress: true})
if ss.CheckErr(err) {
return
}
playResp, err := livehandler.BuildPlayResp(ss, req.ToLivePlayReq(randomLive.LiveDifficultyID), true)
if ss.CheckErr(err) {
return
}
ss.Respond(playResp)
}
func init() {
router.AddHandler("main.php", "POST", "/rlive/play", middleware.Common, play)
}
+59
View File
@@ -0,0 +1,59 @@
package rlive
import (
"errors"
livehandler "honoka-chan/internal/handler/live"
"honoka-chan/internal/middleware"
"honoka-chan/internal/router"
rliveschema "honoka-chan/internal/schema/rlive"
"honoka-chan/internal/session"
honokautils "honoka-chan/internal/utils"
"github.com/gin-gonic/gin"
)
func reward(ctx *gin.Context) {
ss := session.Get(ctx)
token := ""
defer func() {
if ss.UserEng != nil {
deleteRandomLiveByToken(ss, token)
ss.ClearLiveInProgress()
}
ss.Finalize()
}()
req := rliveschema.RewardReq{}
err := honokautils.ParseRequestData(ctx, &req)
if ss.CheckErr(err) {
return
}
token = req.Token
randomLive, err := getRandomLiveByToken(ss, req.Token)
if ss.CheckErr(err) {
return
}
hasProgress, _ := ss.GetLiveInProgress()
if !hasProgress {
ss.CheckErr(errors.New("live progress not found"))
return
}
if req.LiveDifficultyID != 0 && req.LiveDifficultyID != randomLive.LiveDifficultyID {
ss.CheckErr(errors.New("random live difficulty mismatch"))
return
}
rewardResp, err := livehandler.BuildRewardResp(ss, req.ToLiveRewardReq(randomLive.LiveDifficultyID), true)
if ss.CheckErr(err) {
return
}
ss.Respond(rewardResp)
}
func init() {
router.AddHandler("main.php", "POST", "/rlive/reward", middleware.Common, reward)
}