From 8be2bf9dec2da0fceb6317cfeb903da8253c3c78 Mon Sep 17 00:00:00 2001 From: yaoyanwei Date: Tue, 26 Aug 2025 14:50:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=A4=A9=E6=A2=AF=E6=8E=92?= =?UTF-8?q?=E8=A1=8C=E6=A6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobs/jobs.js | 24 +++++- ladder-config.json | 92 +++++++++++++++++++++++ ladder/ladder.controller.js | 55 ++++++++++++++ ladder/ladder.model.js | 119 +++++++++++++++++++++++++++++ ladder/ladder.routes.js | 15 ++++ ladder/ladder.service.js | 144 ++++++++++++++++++++++++++++++++++++ matches/matches.tool.js | 31 +++++++- server.js | 4 + users/users.controller.js | 7 +- users/users.model.js | 8 ++ 10 files changed, 491 insertions(+), 8 deletions(-) create mode 100644 ladder-config.json create mode 100644 ladder/ladder.controller.js create mode 100644 ladder/ladder.model.js create mode 100644 ladder/ladder.routes.js create mode 100644 ladder/ladder.service.js diff --git a/jobs/jobs.js b/jobs/jobs.js index 8a5b556..0df34c2 100644 --- a/jobs/jobs.js +++ b/jobs/jobs.js @@ -1,21 +1,39 @@ const schedule = require('node-schedule'); +const ladderModel = require('./ladder/ladder.model'); const ExecuteJobs = async() => { //console.log('Run Hourly Jobs.....'); //Add custom hourly jobs here +}; - - +// Execute leaderboard refresh at specified times: 00:00, 12:00, 18:00, 22:00 +const RefreshLeaderboard = async() => { + console.log('Refreshing leaderboard...'); + try { + await ladderModel.generateLeaderboard(); + console.log('Leaderboard refreshed successfully'); + } catch (error) { + console.error('Error refreshing leaderboard:', error); + } }; exports.InitJobs = function() { + // Hourly jobs schedule.scheduleJob('* 1 * * *', function(){ // this for one hour ExecuteJobs(); }); - //Test run when starting + // Leaderboard refresh jobs at 00:00, 12:00, 18:00, 22:00 + schedule.scheduleJob('0 0 * * *', async function() { + const hours = new Date().getHours(); + if (hours === 0 || hours === 12 || hours === 18 || hours === 22) { + await RefreshLeaderboard(); + } + }); + + // Test run when starting ExecuteJobs(); } \ No newline at end of file diff --git a/ladder-config.json b/ladder-config.json new file mode 100644 index 0000000..3c7ada1 --- /dev/null +++ b/ladder-config.json @@ -0,0 +1,92 @@ +[ + { + "Id": 1, + "Rank": 1, + "RankName": "Bronze", + "Level": 1, + "BeginStar": 0, + "RankDownStar": 0, + "MaxStar": 5, + "WinGetStar": 1, + "ExtraGetStar": 0, + "LoseLostStar": 1, + "LoseRankDown": 0, + "RankScore": 0, + "AITimes": 30, + "AIDeck": "bronze_ai", + "WaitTime": 10, + "MaxWaitTime": 20 + }, + { + "Id": 2, + "Rank": 1, + "RankName": "Bronze", + "Level": 2, + "BeginStar": 0, + "RankDownStar": 3, + "MaxStar": 5, + "WinGetStar": 1, + "ExtraGetStar": 0, + "LoseLostStar": 1, + "LoseRankDown": 1, + "RankScore": 0, + "AITimes": 30, + "AIDeck": "bronze_ai", + "WaitTime": 10, + "MaxWaitTime": 20 + }, + { + "Id": 3, + "Rank": 2, + "RankName": "Silver", + "Level": 3, + "BeginStar": 0, + "RankDownStar": 3, + "MaxStar": 6, + "WinGetStar": 1, + "ExtraGetStar": 1, + "LoseLostStar": 1, + "LoseRankDown": 1, + "RankScore": 0, + "AITimes": 30, + "AIDeck": "silver_ai", + "WaitTime": 15, + "MaxWaitTime": 25 + }, + { + "Id": 4, + "Rank": 2, + "RankName": "Silver", + "Level": 4, + "BeginStar": 0, + "RankDownStar": 3, + "MaxStar": 6, + "WinGetStar": 1, + "ExtraGetStar": 1, + "LoseLostStar": 1, + "LoseRankDown": 1, + "RankScore": 0, + "AITimes": 30, + "AIDeck": "silver_ai", + "WaitTime": 15, + "MaxWaitTime": 25 + }, + { + "Id": 5, + "Rank": 3, + "RankName": "Gold", + "Level": 5, + "BeginStar": 0, + "RankDownStar": 3, + "MaxStar": 7, + "WinGetStar": 1, + "ExtraGetStar": 1, + "LoseLostStar": 1, + "LoseRankDown": 1, + "RankScore": 1, + "AITimes": 30, + "AIDeck": "gold_ai", + "WaitTime": 20, + "MaxWaitTime": 30 + } +] \ No newline at end of file diff --git a/ladder/ladder.controller.js b/ladder/ladder.controller.js new file mode 100644 index 0000000..f8bbc4d --- /dev/null +++ b/ladder/ladder.controller.js @@ -0,0 +1,55 @@ +const ladderModel = require('./ladder.model'); +const ladderService = require('./ladder.service'); +const { UserModel } = require('../users/users.model'); + +// Get leaderboard +exports.getLeaderboard = async (req, res) => { + try { + const leaderboard = await ladderModel.getLeaderboard(); + res.status(200).send(leaderboard); + } catch (error) { + console.error('Error getting leaderboard:', error); + res.status(500).send({ error: 'Failed to get leaderboard' }); + } +}; + +// Get player's position in leaderboard +exports.getPlayerPosition = async (req, res) => { + try { + const { playerId } = req.params; + const position = await ladderModel.getPlayerPosition(playerId); + res.status(200).send({ position }); + } catch (error) { + console.error('Error getting player position:', error); + res.status(500).send({ error: 'Failed to get player position' }); + } +}; + +// Get player's rank info +exports.getPlayerRankInfo = async (req, res) => { + try { + const { playerId } = req.params; + const player = await UserModel.getById(playerId); + + if (!player) { + return res.status(404).send({ error: 'Player not found' }); + } + + const rankInfo = ladderService.getPlayerRankInfo(player); + res.status(200).send(rankInfo); + } catch (error) { + console.error('Error getting player rank info:', error); + res.status(500).send({ error: 'Failed to get player rank info' }); + } +}; + +// Get all rank configurations +exports.getRankConfigurations = async (req, res) => { + try { + const configs = ladderService.getAllRankConfigs(); + res.status(200).send(configs); + } catch (error) { + console.error('Error getting rank configurations:', error); + res.status(500).send({ error: 'Failed to get rank configurations' }); + } +}; \ No newline at end of file diff --git a/ladder/ladder.model.js b/ladder/ladder.model.js new file mode 100644 index 0000000..05724d5 --- /dev/null +++ b/ladder/ladder.model.js @@ -0,0 +1,119 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; +const ladderService = require('./ladder.service'); + +const leaderboardSchema = new Schema({ + playerId: { type: String, index: true, required: true }, + username: { type: String, required: true }, + avatar: { type: String, default: "" }, + rankId: { type: Number, default: 1 }, + rankScore: { type: Number, default: 0 }, + stars: { type: Number, default: 0 }, + totalWins: { type: Number, default: 0 }, + position: { type: Number, required: true }, + lastUpdated: { type: Date, default: Date.now } +}); + +const Leaderboard = mongoose.model('Leaderboard', leaderboardSchema); + +// Leaderboard functions +exports.generateLeaderboard = async () => { + try { + // Clear current leaderboard + await Leaderboard.deleteMany({}); + + // Get all users + const User = mongoose.model('Users'); + const users = await User.find({}); + + // Sort users based on ranking criteria: + // 1. Rank level (higher is better) + // 2. Rank score (for王者分数 mechanism) + // 3. Stars + // 4. Total wins + const sortedUsers = users.sort((a, b) => { + const configA = ladderService.getRankConfig(a.rankId); + const configB = ladderService.getRankConfig(b.rankId); + + // Compare rank levels + if (configA.Level !== configB.Level) { + return configB.Level - configA.Level; + } + + // For players with rank score mechanism + if (configA.RankScore === 1 && configB.RankScore === 1) { + if (a.rankScore !== b.rankScore) { + return b.rankScore - a.rankScore; + } + } + + // Compare stars + if (a.stars !== b.stars) { + return b.stars - a.stars; + } + + // Compare total wins + return b.totalWins - a.totalWins; + }); + + // Take top 100 players + const topPlayers = sortedUsers.slice(0, 100); + + // Create leaderboard entries + const leaderboardEntries = []; + for (let i = 0; i < topPlayers.length; i++) { + const player = topPlayers[i]; + const entry = new Leaderboard({ + playerId: player._id, + username: player.username, + avatar: player.avatar, + rankId: player.rankId, + rankScore: player.rankScore, + stars: player.stars, + totalWins: player.totalWins, + position: i + 1 + }); + leaderboardEntries.push(entry); + } + + // Save all entries + if (leaderboardEntries.length > 0) { + await Leaderboard.insertMany(leaderboardEntries); + } + + return leaderboardEntries; + } catch (error) { + console.error('Error generating leaderboard:', error); + return []; + } +}; + +exports.getLeaderboard = async () => { + try { + const leaderboard = await Leaderboard.find({}).sort({ position: 1 }); + return leaderboard; + } catch (error) { + console.error('Error getting leaderboard:', error); + return []; + } +}; + +exports.getPlayerPosition = async (playerId) => { + try { + const entry = await Leaderboard.findOne({ playerId: playerId }); + return entry ? entry.position : null; + } catch (error) { + console.error('Error getting player position:', error); + return null; + } +}; + +// Initialize the ladder for a new player +exports.initializePlayerLadder = (player) => { + player.rankId = 1; + player.stars = 0; + player.rankScore = 0; + player.winStreak = 0; + player.totalWins = 0; + return player; +}; \ No newline at end of file diff --git a/ladder/ladder.routes.js b/ladder/ladder.routes.js new file mode 100644 index 0000000..42bf418 --- /dev/null +++ b/ladder/ladder.routes.js @@ -0,0 +1,15 @@ +const ladderController = require('./ladder.controller'); + +exports.route = (app) => { + // Get leaderboard + app.get('/ladder/leaderboard', ladderController.getLeaderboard); + + // Get player position in leaderboard + app.get('/ladder/position/:playerId', ladderController.getPlayerPosition); + + // Get player rank info + app.get('/ladder/rank/:playerId', ladderController.getPlayerRankInfo); + + // Get all rank configurations + app.get('/ladder/config', ladderController.getRankConfigurations); +}; \ No newline at end of file diff --git a/ladder/ladder.service.js b/ladder/ladder.service.js new file mode 100644 index 0000000..40bc573 --- /dev/null +++ b/ladder/ladder.service.js @@ -0,0 +1,144 @@ +const fs = require('fs'); +const path = require('path'); + +// Load ladder configuration +const ladderConfig = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'ladder-config.json'))); + +class LadderService { + constructor() { + this.rankConfigs = new Map(); + this.initConfig(); + } + + initConfig() { + ladderConfig.forEach(config => { + this.rankConfigs.set(config.Id, config); + }); + } + + // Get rank configuration by ID + getRankConfig(rankId) { + return this.rankConfigs.get(rankId); + } + + // Get all rank configurations + getAllRankConfigs() { + return Array.from(this.rankConfigs.values()); + } + + // Handle player win + async handleWin(player, opponent) { + const config = this.getRankConfig(player.rankId); + + if (config.RankScore === 1) { + // 王者分数 mechanism + this.updateRankScore(player, opponent, true); + } else { + // Star-based mechanism + this.updateStars(player, config, true); + } + + player.totalWins++; + player.winStreak++; + + return player; + } + + // Handle player loss + async handleLoss(player, opponent) { + const config = this.getRankConfig(player.rankId); + + if (config.RankScore === 1) { + // 王者分数 mechanism + this.updateRankScore(player, opponent, false); + } else { + // Star-based mechanism + this.updateStars(player, config, false); + } + + player.winStreak = 0; + + return player; + } + + updateStars(player, config, isWin) { + if (isWin) { + let starsToAdd = config.WinGetStar; + // Add extra stars for win streak + if (player.winStreak >= 3) { + starsToAdd += config.ExtraGetStar; + } + player.stars += starsToAdd; + + // Level up check + if (player.stars > config.MaxStar) { + this.levelUp(player); + } + } else { + if (config.LoseLostStar === 1) { + player.stars = Math.max(0, player.stars - 1); + + // Level down check + if (player.stars === 0) { + this.levelDown(player, config); + } + } + } + } + + updateRankScore(player, opponent, isWin) { + if (isWin) { + let scoreToAdd = 20; + // If opponent has a higher rank score, calculate bonus + if (opponent.rankScore > player.rankScore) { + const ratio = Math.min(opponent.rankScore / player.rankScore, 3); + scoreToAdd = Math.round(ratio * 20); + } + player.rankScore += scoreToAdd; + } else { + // Deduct points on loss + if (player.rankScore > 0) { + player.rankScore = Math.max(0, player.rankScore - 20); + } + } + } + + levelUp(player) { + const nextConfig = this.getRankConfig(player.rankId + 1); + if (nextConfig) { + player.rankId++; + player.stars = nextConfig.BeginStar; + } + } + + levelDown(player, currentConfig) { + if (currentConfig.LoseRankDown === 1 && player.rankId > 1) { + player.rankId--; + const prevConfig = this.getRankConfig(player.rankId); + player.stars = prevConfig.RankDownStar; + } + } + + // Get player's rank display info + getPlayerRankInfo(player) { + const config = this.getRankConfig(player.rankId); + if (config.RankScore === 1) { + return { + rankName: config.RankName, + level: config.Level, + score: player.rankScore, + isRankScore: true + }; + } else { + return { + rankName: config.RankName, + level: config.Level, + stars: player.stars, + maxStars: config.MaxStar, + isRankScore: false + }; + } + } +} + +module.exports = new LadderService(); \ No newline at end of file diff --git a/matches/matches.tool.js b/matches/matches.tool.js index 1ae3953..2018a33 100644 --- a/matches/matches.tool.js +++ b/matches/matches.tool.js @@ -1,6 +1,7 @@ const UserTool = require('../users/users.tool'); const config = require('../config.js'); - +const ladderService = require('../ladder/ladder.service'); +const ladderModel = require('../ladder/ladder.model'); var MatchTool = {}; @@ -26,6 +27,10 @@ MatchTool.GetPlayerData = (player) => var data = {}; data.username = player.username; data.elo = player.elo; + // Add ladder info to player data + data.rankId = player.rankId; + data.stars = player.stars; + data.rankScore = player.rankScore; data.reward = {}; return data; } @@ -52,17 +57,37 @@ MatchTool.GainMatchReward = async(player, opponent, winner_username) => { else if (lost) player.defeats += 1; + // Handle ladder system + if (won) { + await ladderService.handleWin(player, opponent); + } else if (lost) { + await ladderService.handleLoss(player, opponent); + } + + // Save last win deck for leaderboard + if (won && player.decks && player.decks.length > 0) { + // For simplicity, we'll use the first deck as the last win deck + // In a real implementation, this would be the actual deck used in the match + player.lastWinDeck = player.decks[0]; + } + //Calculate elo var match_count = player.matches || 0; var match_progress = Math.min(Math.max(match_count / config.elo_ini_match, 0.0), 1.0); var new_elo = MatchTool.calculateELO(player_elo, opponent_elo, match_progress, won, lost); player.elo = new_elo; - player.save(); + + // Save player changes + await player.save(); var reward = { elo: player.elo, xp: xp, - coins: coins + coins: coins, + // Add ladder info to reward + rankId: player.rankId, + stars: player.stars, + rankScore: player.rankScore }; return reward; diff --git a/server.js b/server.js index bf1b71d..9ae271f 100644 --- a/server.js +++ b/server.js @@ -92,6 +92,10 @@ MarketRouter.route(app); const ActivityRouter = require("./activity/activity.routes"); ActivityRouter.route(app); +// Ladder system routes +const LadderRouter = require('./ladder/ladder.routes'); +LadderRouter.route(app); + //Read SSL cert var ReadSSL = function() { diff --git a/users/users.controller.js b/users/users.controller.js index e32d5f5..f4e85e5 100644 --- a/users/users.controller.js +++ b/users/users.controller.js @@ -7,6 +7,7 @@ const Validator = require('../tools/validator.tool'); const AuthTool = require('../authorization/auth.tool'); const Email = require('../tools/email.tool'); const config = require('../config'); +const ladderModel = require('../ladder/ladder.model'); //Register new user exports.RegisterUser = async (req, res, next) => { @@ -62,6 +63,9 @@ exports.RegisterUser = async (req, res, next) => { user.elo = config.start_elo; user.xp = 0; + // Initialize ladder data + user = ladderModel.initializePlayerLadder(user); + user.account_create_time = new Date(); user.last_login_time = new Date(); user.last_online_time = new Date(); @@ -389,8 +393,7 @@ exports.SendEmail = async (req, res) =>{ }; // reward is an object containing rewards to give -exports.GiveReward = async(req, res) => -{ +exports.GiveReward = async(req, res) =>{ var userId = req.params.userId; var reward = req.body.reward; diff --git a/users/users.model.js b/users/users.model.js index d22f4db..2089e2f 100644 --- a/users/users.model.js +++ b/users/users.model.js @@ -28,6 +28,14 @@ const userSchema = new Schema({ victories: {type: Number, default: 0}, defeats: {type: Number, default: 0}, + // Ladder system fields + rankId: {type: Number, default: 1}, // Current rank ID + stars: {type: Number, default: 0}, // Current stars + rankScore: {type: Number, default: 0}, // Rank score for王者分数 mechanism + winStreak: {type: Number, default: 0}, // Win streak counter + totalWins: {type: Number, default: 0}, // Total wins for leaderboard + lastWinDeck: {type: Object, default: null}, // Last winning deck for leaderboard + cards: [{ tid: String, variant: String, quantity: Number, _id: false }], packs: [{ tid: String, quantity: Number, _id: false }], decks: [{ type: Object, _id: false }],