15 Commits
main ... Bang3

Author SHA1 Message Date
e52baf2555 Fix leaderboard submission display issue: update frontend to correctly handle score vs points 2026-01-17 21:22:34 +08:00
45d6b1d9de Fix: Backend score parsing (points vs score) and Frontend songselect ID fallback 2026-01-17 21:03:09 +08:00
bb3ad834b2 Fix: Add robust leaderboard submission fallback and fix search.js crash 2026-01-17 20:45:13 +08:00
addd9d90f0 Fix: Use hash as fallback when song ID is not available 2026-01-17 20:30:03 +08:00
69b92b34d8 Feature: Manual leaderboard submission button on result screen with notification 2026-01-17 20:14:11 +08:00
9935d70e31 Fix: Support both numeric and hash song IDs for leaderboard 2026-01-17 19:56:41 +08:00
3f7ff13ef7 Fix: Only allow leaderboard for server songs with numeric IDs 2026-01-17 19:46:24 +08:00
0706f99427 Fix: Validate songId as number and add user notification for leaderboard submission 2026-01-17 19:29:36 +08:00
9bd2b21d44 Fix: Ignore apt-get update failure in setup script 2026-01-17 19:17:13 +08:00
b15752e051 Fix: Initialize leaderboard canvas after page is loaded 2026-01-17 19:07:52 +08:00
84d15b70c6 Fix: Add leaderboard.js and leaderboard.html to assets loading list 2026-01-17 18:57:02 +08:00
271fc52e82 Enhanced leaderboard UI with gradients, glows, medals and modern design 2026-01-15 23:54:29 +08:00
1038fc85b9 Add auto-submit to leaderboard and argparse support for app.py 2026-01-15 23:51:01 +08:00
76a3d52098 Fix critical bugs: touchEnabled undefined, MongoDB score sorting with score_value field 2026-01-15 23:39:12 +08:00
d6a1b6bd41 Add leaderboard feature with monthly reset and top 50 limit, update version to 1.1.0 2026-01-15 23:32:43 +08:00
14 changed files with 2517 additions and 1277 deletions

164
app.py
View File

@@ -105,6 +105,9 @@ db.users.create_index('username', unique=True)
db.songs.create_index('id', unique=True)
db.songs.create_index('song_type')
db.scores.create_index('username')
db.leaderboards.create_index([('song_id', 1), ('difficulty', 1), ('month', 1), ('score_value', -1)])
db.leaderboards.create_index([('song_id', 1), ('difficulty', 1), ('username', 1), ('month', 1)], unique=True)
db.leaderboards.create_index('month')
class HashException(Exception):
@@ -746,6 +749,167 @@ def route_api_scores_get():
return jsonify({'status': 'ok', 'scores': scores, 'username': user['username'], 'display_name': user['display_name'], 'don': don})
@app.route(basedir + 'api/leaderboard/submit', methods=['POST'])
@login_required
def route_api_leaderboard_submit():
data = request.get_json()
if not schema.validate(data, schema.leaderboard_submit):
return abort(400)
username = session.get('username')
user = db.users.find_one({'username': username})
if not user:
return api_error('user_not_found')
song_id = data.get('song_id')
difficulty = data.get('difficulty')
score_data = data.get('score')
# Validate difficulty
valid_difficulties = ['easy', 'normal', 'hard', 'oni', 'ura']
if difficulty not in valid_difficulties:
return api_error('invalid_difficulty')
# Get current month (YYYY-MM format)
current_month = time.strftime('%Y-%m', time.gmtime())
# Check if user already has a record for this song/difficulty/month
existing = db.leaderboards.find_one({
'song_id': song_id,
'difficulty': difficulty,
'username': username,
'month': current_month
})
# Parse score (assuming it's in the same format as the scores collection)
try:
if isinstance(score_data, str):
import json as json_module
score_obj = json_module.loads(score_data)
else:
score_obj = score_data
# Check for 'score' first, then 'points'
# Frontend usually sends 'points'
score_val = score_obj.get('score')
if score_val is None:
score_val = score_obj.get('points')
score_value = int(score_val or 0)
except:
return api_error('invalid_score_format')
if existing:
# Update only if new score is higher
existing_score = existing.get('score_value', 0)
if score_value > existing_score:
db.leaderboards.update_one(
{'_id': existing['_id']},
{'$set': {
'score': score_data,
'score_value': score_value,
'display_name': user['display_name'],
'submitted_at': time.time()
}}
)
return jsonify({'status': 'ok', 'message': 'score_updated'})
else:
return jsonify({'status': 'ok', 'message': 'score_not_higher'})
else:
# Check if this score would be in top 50
count = db.leaderboards.count_documents({
'song_id': song_id,
'difficulty': difficulty,
'month': current_month
})
if count >= 50:
# Find the 50th score
leaderboard = list(db.leaderboards.find({
'song_id': song_id,
'difficulty': difficulty,
'month': current_month
}).sort('score_value', -1).limit(50))
if len(leaderboard) >= 50:
last_score = leaderboard[49].get('score_value', 0)
if score_value <= last_score:
return jsonify({'status': 'ok', 'message': 'score_too_low'})
# Insert new record
db.leaderboards.insert_one({
'song_id': song_id,
'difficulty': difficulty,
'username': username,
'display_name': user['display_name'],
'score': score_data,
'score_value': score_value,
'submitted_at': time.time(),
'month': current_month
})
# Remove entries beyond 50th place
if count >= 50:
# Get all entries sorted by score
all_entries = list(db.leaderboards.find({
'song_id': song_id,
'difficulty': difficulty,
'month': current_month
}).sort('score_value', -1))
# Delete entries beyond 50
if len(all_entries) > 50:
for entry in all_entries[50:]:
db.leaderboards.delete_one({'_id': entry['_id']})
return jsonify({'status': 'ok', 'message': 'score_submitted'})
@app.route(basedir + 'api/leaderboard/get')
def route_api_leaderboard_get():
song_id = request.args.get('song_id', None)
difficulty = request.args.get('difficulty', None)
if not song_id or not difficulty:
return abort(400)
# Accept both numeric IDs and hash strings
# Try to convert to int if possible, otherwise use as string
try:
song_id = int(song_id)
except (ValueError, TypeError):
# Keep as string (hash ID)
pass
# Validate difficulty
valid_difficulties = ['easy', 'normal', 'hard', 'oni', 'ura']
if difficulty not in valid_difficulties:
return abort(400)
# Get current month
current_month = time.strftime('%Y-%m', time.gmtime())
# Get top 50 scores
leaderboard = list(db.leaderboards.find({
'song_id': song_id,
'difficulty': difficulty,
'month': current_month
}, {
'_id': False,
'username': True,
'display_name': True,
'score': True,
'score_value': True,
'submitted_at': True
}).sort('score_value', -1).limit(50))
# Add rank to each entry
for i, entry in enumerate(leaderboard):
entry['rank'] = i + 1
return jsonify({'status': 'ok', 'leaderboard': leaderboard, 'month': current_month})
@app.route(basedir + 'privacy')
def route_api_privacy():
last_modified = time.strftime('%d %B %Y', time.gmtime(os.path.getmtime('templates/privacy.txt')))

201
local_debug.py Normal file
View File

@@ -0,0 +1,201 @@
import time
import json
import base64
# Mock DB and objects
class MockCollection:
def __init__(self, name):
self.name = name
self.data = []
def find_one(self, query):
for item in self.data:
match = True
for k, v in query.items():
if item.get(k) != v:
match = False
break
if match:
return item
return None
def find(self, query, projection=None):
results = []
for item in self.data:
match = True
for k, v in query.items():
if item.get(k) != v:
match = False
break
if match:
if projection:
filtered = {k: v for k, v in item.items() if projection.get(k, False)}
if projection.get('_id') is None or projection.get('_id') is True:
filtered['_id'] = item.get('_id')
results.append(filtered)
else:
results.append(item)
return MockCursor(results)
def insert_one(self, doc):
doc['_id'] = len(self.data) + 1
print(f"[{self.name}] Inserted: {doc}")
self.data.append(doc)
def count_documents(self, query):
return len(self.data)
class MockCursor:
def __init__(self, data):
self.data = data
def sort(self, key, direction):
self.data.sort(key=lambda x: x.get(key, 0), reverse=(direction < 0))
return self
def limit(self, n):
self.data = self.data[:n]
return self.data
class MockDB:
def __init__(self):
self.users = MockCollection('users')
self.leaderboards = MockCollection('leaderboards')
db = MockDB()
db.users.insert_one({'username': 'testuser', 'display_name': 'Test User'})
# --- Logic from app.py rewritten for standalone test ---
def logic_submit(data, username):
print(f"\n--- Submitting {data.get('song_id')} ---")
user = db.users.find_one({'username': username})
if not user:
return {'error': 'user_not_found'}
song_id = data.get('song_id')
difficulty = data.get('difficulty')
score_data = data.get('score')
current_month = time.strftime('%Y-%m', time.gmtime())
existing = db.leaderboards.find_one({
'song_id': song_id,
'difficulty': difficulty,
'username': username,
'month': current_month
})
# Patched Logic from app.py
try:
if isinstance(score_data, str):
score_obj = json.loads(score_data)
else:
score_obj = score_data
# Check for 'score' first, then 'points'
score_val = score_obj.get('score')
if score_val is None:
score_val = score_obj.get('points')
score_value = int(score_val or 0)
print(f" Parsed Score Value: {score_value}")
except Exception as e:
print(f"Error parsing score: {e}")
return {'error': 'invalid_score_format'}
if existing:
print(" Existing record found (simulated update).")
else:
print(" Inserting new record.")
db.leaderboards.insert_one({
'song_id': song_id,
'difficulty': difficulty,
'username': username,
'display_name': user['display_name'],
'score': score_data,
'score_value': score_value,
'submitted_at': time.time(),
'month': current_month
})
return {'status': 'ok'}
def logic_get(args):
print(f"\n--- Getting {args.get('song_id')} ---")
song_id = args.get('song_id', None)
difficulty = args.get('difficulty', None)
if not song_id or not difficulty:
return {'error': 'missing args'}
try:
song_id = int(song_id)
except (ValueError, TypeError):
pass
current_month = time.strftime('%Y-%m', time.gmtime())
leaderboard = list(db.leaderboards.find({
'song_id': song_id,
'difficulty': difficulty,
'month': current_month
}, {
'_id': False,
'username': True,
'score_value': True
}).sort('score_value', -1).limit(50))
return {'status': 'ok', 'leaderboard': leaderboard}
# --- Test Cases ---
# Case 1: Hash ID
print("=== TEST CASE 1: Hash ID ===")
logic_submit({
'song_id': "hash123",
'difficulty': 'oni',
'score': {'score': 1000000}
}, 'testuser')
res1 = logic_get({'song_id': "hash123", 'difficulty': 'oni'})
if len(res1['leaderboard']) > 0: print("✅ Success")
else: print("❌ Failed")
# Case 2: Numeric ID
print("\n=== TEST CASE 2: Numeric ID ===")
logic_submit({
'song_id': 123,
'difficulty': 'oni',
'score': {'score': 2000000}
}, 'testuser')
res2 = logic_get({'song_id': "123", 'difficulty': 'oni'})
if len(res2['leaderboard']) > 0: print("✅ Success")
else: print("❌ Failed")
# Case 3: Points Only (Frontend Payload Simulation)
print("\n=== TEST CASE 3: Points Only (Frontend Payload) ===")
# This simulates what the JS 'this.resultsObj' looks like (it has 'points', not 'score')
logic_submit({
'song_id': "points_song",
'difficulty': 'hard',
'score': {'points': 345678, 'bad': 0, 'good': 100} # No 'score' key
}, 'testuser')
res3 = logic_get({'song_id': "points_song", 'difficulty': 'hard'})
passed = False
if len(res3.get('leaderboard', [])) > 0:
val = res3['leaderboard'][0].get('score_value')
if val == 345678:
print(f"✅ SUCCESS: Correctly parsed 'points' -> {val}")
passed = True
else:
print(f"❌ FAILED: Score value mismatch. Expected 345678, got {val}")
else:
print("❌ FAILED: No record found")
if passed:
print("\n---------------------------------------------------")
print("🎉 ALL LOCAL TESTS PASSED. BACKEND LOGIC VERIFIED.")
print("---------------------------------------------------")
else:
print("\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
print("🛑 TESTS FAILED. CHECK LOGS.")
print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")

View File

@@ -0,0 +1,13 @@
#leaderboard {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 1000;
}
#leaderboard-canvas {
width: 100%;
height: 100%;
}

View File

@@ -7,6 +7,7 @@ var assets = {
"titlescreen.js",
"scoresheet.js",
"songselect.js",
"leaderboard.js",
"keyboard.js",
"gameinput.js",
"game.js",
@@ -140,6 +141,7 @@ var assets = {
},
"views": [
"game.html",
"leaderboard.html",
"loadsong.html",
"songselect.html",
"titlescreen.html",

View File

@@ -0,0 +1,410 @@
class Leaderboard {
constructor() {
this.canvas = null
this.ctx = null
this.songId = null
this.difficulty = null
this.leaderboardData = []
this.currentMonth = ""
this.visible = false
this.draw = null
this.keyboard = null
}
init() {
this.canvas = document.getElementById("leaderboard-canvas")
if (!this.canvas) {
console.error("Leaderboard canvas not found!")
return false
}
this.ctx = this.canvas.getContext("2d")
var resolution = settings.getItem("resolution")
var noSmoothing = resolution === "low" || resolution === "lowest"
if (noSmoothing) {
this.ctx.imageSmoothingEnabled = false
}
this.draw = new CanvasDraw(noSmoothing)
// Keyboard controls
this.keyboard = new Keyboard({
confirm: ["enter", "escape", "don_l", "don_r"],
back: ["escape"],
left: ["left", "ka_l"],
right: ["right", "ka_r"]
}, this.keyPress.bind(this))
pageEvents.add(this.canvas, ["mousedown", "touchstart"], this.onClose.bind(this))
return true
}
keyPress(pressed, name) {
if (!pressed || !this.visible) {
return
}
if (name === "confirm" || name === "back") {
this.close()
} else if (name === "left") {
this.changeDifficulty(-1)
} else if (name === "right") {
this.changeDifficulty(1)
}
}
async display(songId, difficulty) {
this.songId = songId
this.difficulty = difficulty
this.visible = true
loader.changePage("leaderboard", false)
// Initialize after page is loaded
if (!this.init()) {
console.error("Failed to initialize leaderboard")
return
}
// Fetch leaderboard data
await this.fetchLeaderboard()
// Start rendering
this.redrawRunning = true
this.redrawBind = this.redraw.bind(this)
this.redraw()
assets.sounds["se_don"].play()
}
async fetchLeaderboard() {
// Validate songId exists
if (!this.songId) {
console.error("Missing song ID for leaderboard")
this.leaderboardData = []
return
}
try {
var response = await loader.ajax(
`${gameConfig.basedir || "/"}api/leaderboard/get?song_id=${encodeURIComponent(this.songId)}&difficulty=${this.difficulty}`
)
var data = JSON.parse(response)
if (data.status === "ok") {
this.leaderboardData = data.leaderboard || []
this.currentMonth = data.month || ""
} else {
this.leaderboardData = []
}
} catch (e) {
console.error("Failed to fetch leaderboard:", e)
this.leaderboardData = []
}
}
changeDifficulty(direction) {
var difficulties = ["easy", "normal", "hard", "oni", "ura"]
var currentIndex = difficulties.indexOf(this.difficulty)
if (currentIndex === -1) {
return
}
var newIndex = (currentIndex + direction + difficulties.length) % difficulties.length
this.difficulty = difficulties[newIndex]
this.fetchLeaderboard().then(() => {
assets.sounds["se_ka"].play()
})
}
onClose(event) {
if (!this.visible) {
return
}
var rect = this.canvas.getBoundingClientRect()
var x = (event.offsetX || event.touches[0].pageX - rect.left)
var y = (event.offsetY || event.touches[0].pageY - rect.top)
// Check if clicked outside modal - updated dimensions
var centerX = this.canvas.width / 2
var centerY = this.canvas.height / 2
var modalWidth = 880 // Updated from 800
var modalHeight = 640 // Updated from 600
if (x < centerX - modalWidth / 2 || x > centerX + modalWidth / 2 ||
y < centerY - modalHeight / 2 || y > centerY + modalHeight / 2) {
this.close()
}
}
close() {
this.visible = false
this.redrawRunning = false
if (this.keyboard) {
this.keyboard.clean()
}
assets.sounds["se_cancel"].play()
// Return to song select - get touchEnabled from global or default to false
setTimeout(() => {
var touchEnabled = typeof window.touchEnabled !== 'undefined' ? window.touchEnabled : false
new SongSelect(false, false, touchEnabled)
}, 100)
}
redraw() {
if (!this.redrawRunning) {
return
}
requestAnimationFrame(this.redrawBind)
var winW = innerWidth
var winH = innerHeight
var ratio = winH / 720
this.canvas.width = winW
this.canvas.height = winH
this.ctx.save()
// Draw semi-transparent background
this.ctx.fillStyle = "rgba(0, 0, 0, 0.8)"
this.ctx.fillRect(0, 0, winW, winH)
this.ctx.scale(ratio, ratio)
// Draw modal background with gradient
var modalX = 200
var modalY = 40
var modalW = 880
var modalH = 640
// Gradient background
var bgGradient = this.ctx.createLinearGradient(modalX, modalY, modalX, modalY + modalH)
bgGradient.addColorStop(0, "#ffffff")
bgGradient.addColorStop(1, "#f0f4f8")
this.ctx.fillStyle = bgGradient
this.ctx.shadowColor = "rgba(0, 0, 0, 0.3)"
this.ctx.shadowBlur = 30
this.ctx.shadowOffsetX = 0
this.ctx.shadowOffsetY = 10
this.ctx.fillRect(modalX, modalY, modalW, modalH)
this.ctx.shadowBlur = 0
// Modal border with gradient
var borderGradient = this.ctx.createLinearGradient(modalX, modalY, modalX + modalW, modalY + modalH)
borderGradient.addColorStop(0, "#4a90e2")
borderGradient.addColorStop(0.5, "#7b68ee")
borderGradient.addColorStop(1, "#ff6b9d")
this.ctx.strokeStyle = borderGradient
this.ctx.lineWidth = 6
this.ctx.strokeRect(modalX, modalY, modalW, modalH)
// Draw title with gradient
var titleGradient = this.ctx.createLinearGradient(0, 90, 0, 130)
titleGradient.addColorStop(0, "#4a90e2")
titleGradient.addColorStop(1, "#7b68ee")
this.ctx.fillStyle = titleGradient
this.ctx.font = "bold 48px " + (strings.font || "sans-serif")
this.ctx.textAlign = "center"
this.ctx.shadowColor = "rgba(0, 0, 0, 0.2)"
this.ctx.shadowBlur = 5
this.ctx.shadowOffsetX = 2
this.ctx.shadowOffsetY = 2
this.ctx.fillText("🏆 排行榜 Leaderboard", 640, 100)
this.ctx.shadowBlur = 0
// Draw difficulty selector with improved styling
var diffX = 640
var diffY = 155
var difficulties = [
{ id: "easy", name: "簡単", color: "#00a0e9", glow: "#00d4ff" },
{ id: "normal", name: "普通", color: "#00a040", glow: "#00ff66" },
{ id: "hard", name: "難しい", color: "#ff8c00", glow: "#ffb347" },
{ id: "oni", name: "鬼", color: "#dc143c", glow: "#ff6b9d" },
{ id: "ura", name: "裏", color: "#9400d3", glow: "#da70d6" }
]
this.ctx.font = "bold 22px " + (strings.font || "sans-serif")
for (var i = 0; i < difficulties.length; i++) {
var diff = difficulties[i]
var x = diffX - 220 + i * 110
if (diff.id === this.difficulty) {
// Selected difficulty with glow effect
this.ctx.shadowColor = diff.glow
this.ctx.shadowBlur = 15
var gradient = this.ctx.createLinearGradient(x - 50, diffY - 30, x + 50, diffY + 20)
gradient.addColorStop(0, diff.color)
gradient.addColorStop(1, diff.glow)
this.ctx.fillStyle = gradient
this.ctx.fillRect(x - 50, diffY - 30, 100, 50)
this.ctx.shadowBlur = 0
this.ctx.fillStyle = "#ffffff"
this.ctx.shadowColor = "rgba(0, 0, 0, 0.5)"
this.ctx.shadowBlur = 3
} else {
// Unselected difficulty
this.ctx.strokeStyle = diff.color
this.ctx.lineWidth = 3
this.ctx.strokeRect(x - 50, diffY - 30, 100, 50)
this.ctx.fillStyle = diff.color
}
this.ctx.textAlign = "center"
this.ctx.fillText(diff.name, x, diffY)
this.ctx.shadowBlur = 0
}
// Draw month info with style
this.ctx.fillStyle = "#666666"
this.ctx.font = "20px " + (strings.font || "sans-serif")
this.ctx.textAlign = "center"
this.ctx.fillText("📅 当月排行 " + this.currentMonth, 640, 215)
// Header bar
var headerY = 250
var headerGradient = this.ctx.createLinearGradient(modalX, headerY, modalX, headerY + 35)
headerGradient.addColorStop(0, "#e8eef5")
headerGradient.addColorStop(1, "#d0dae8")
this.ctx.fillStyle = headerGradient
this.ctx.fillRect(modalX + 20, headerY, modalW - 40, 35)
this.ctx.fillStyle = "#333333"
this.ctx.font = "bold 18px " + (strings.font || "sans-serif")
this.ctx.textAlign = "center"
this.ctx.fillText("排名", modalX + 80, headerY + 23)
this.ctx.textAlign = "left"
this.ctx.fillText("玩家", modalX + 140, headerY + 23)
this.ctx.textAlign = "right"
this.ctx.fillText("分数", modalX + modalW - 60, headerY + 23)
// Draw leaderboard entries
var startY = 295
var rowHeight = 38
this.ctx.font = "22px " + (strings.font || "sans-serif")
this.ctx.textAlign = "left"
if (this.leaderboardData.length === 0) {
this.ctx.fillStyle = "#999999"
this.ctx.font = "24px " + (strings.font || "sans-serif")
this.ctx.textAlign = "center"
this.ctx.fillText("暂无排行数据", 640, startY + 120)
} else {
for (var i = 0; i < Math.min(this.leaderboardData.length, 15); i++) {
var entry = this.leaderboardData[i]
var y = startY + i * rowHeight
var rank = entry.rank
// Rank background color with gradient
var gradient
if (rank === 1) {
// Gold gradient for 1st place
gradient = this.ctx.createLinearGradient(modalX + 20, y - 25, modalX + 20, y + 10)
gradient.addColorStop(0, "#ffd700")
gradient.addColorStop(1, "#ffed4e")
this.ctx.fillStyle = gradient
this.ctx.shadowColor = "#ffd700"
this.ctx.shadowBlur = 20
} else if (rank === 2) {
// Silver gradient for 2nd place
gradient = this.ctx.createLinearGradient(modalX + 20, y - 25, modalX + 20, y + 10)
gradient.addColorStop(0, "#c0c0c0")
gradient.addColorStop(1, "#e8e8e8")
this.ctx.fillStyle = gradient
this.ctx.shadowColor = "#c0c0c0"
this.ctx.shadowBlur = 15
} else if (rank === 3) {
// Bronze gradient for 3rd place
gradient = this.ctx.createLinearGradient(modalX + 20, y - 25, modalX + 20, y + 10)
gradient.addColorStop(0, "#cd7f32")
gradient.addColorStop(1, "#e9a86a")
this.ctx.fillStyle = gradient
this.ctx.shadowColor = "#cd7f32"
this.ctx.shadowBlur = 12
} else {
// Regular entries with subtle gradient
gradient = this.ctx.createLinearGradient(modalX + 20, y - 25, modalX + 20, y + 10)
gradient.addColorStop(0, "#ffffff")
gradient.addColorStop(1, "#f8f9fa")
this.ctx.fillStyle = gradient
}
// Rounded rectangle effect
this.ctx.fillRect(modalX + 20, y - 25, modalW - 40, 35)
this.ctx.shadowBlur = 0
// Border for entries
if (rank <= 3) {
this.ctx.strokeStyle = rank === 1 ? "#ffd700" : rank === 2 ? "#c0c0c0" : "#cd7f32"
this.ctx.lineWidth = 2
this.ctx.strokeRect(modalX + 20, y - 25, modalW - 40, 35)
}
// Rank with medal emoji for top 3
if (rank === 1) {
this.ctx.fillStyle = "#8b6914"
this.ctx.font = "bold 24px " + (strings.font || "sans-serif")
this.ctx.textAlign = "center"
this.ctx.fillText("🥇", modalX + 80, y + 2)
} else if (rank === 2) {
this.ctx.fillStyle = "#5c5c5c"
this.ctx.font = "bold 24px " + (strings.font || "sans-serif")
this.ctx.textAlign = "center"
this.ctx.fillText("🥈", modalX + 80, y + 2)
} else if (rank === 3) {
this.ctx.fillStyle = "#6d4423"
this.ctx.font = "bold 24px " + (strings.font || "sans-serif")
this.ctx.textAlign = "center"
this.ctx.fillText("🥉", modalX + 80, y + 2)
} else {
this.ctx.fillStyle = rank <= 3 ? "#ffffff" : "#555555"
this.ctx.font = "bold 22px " + (strings.font || "sans-serif")
this.ctx.textAlign = "center"
this.ctx.fillText("#" + rank, modalX + 80, y + 2)
}
// Display name
this.ctx.fillStyle = rank <= 3 ? "#1a1a1a" : "#333333"
this.ctx.font = (rank <= 3 ? "bold " : "") + "20px " + (strings.font || "sans-serif")
this.ctx.textAlign = "left"
var displayName = entry.display_name || entry.username
if (displayName.length > 18) {
displayName = displayName.substring(0, 18) + "..."
}
this.ctx.fillText(displayName, modalX + 140, y + 2)
// Score with formatting
// Score with formatting
var score = entry.score_value !== undefined ? entry.score_value : (entry.score && entry.score.score ? entry.score.score : 0)
this.ctx.fillStyle = rank <= 3 ? "#1a1a1a" : "#555555"
this.ctx.font = (rank <= 3 ? "bold " : "") + "20px " + (strings.font || "sans-serif")
this.ctx.textAlign = "right"
this.ctx.fillText(score.toLocaleString(), modalX + modalW - 60, y + 2)
}
}
// Draw close hint with icon
this.ctx.fillStyle = "#888888"
this.ctx.font = "18px " + (strings.font || "sans-serif")
this.ctx.textAlign = "center"
this.ctx.fillText("⌨️ 按ESC或点击外部关闭 Press ESC or click outside to close", 640, 665)
this.ctx.restore()
}
clean() {
if (this.keyboard) {
this.keyboard.clean()
}
if (this.redrawRunning) {
this.redrawRunning = false
}
pageEvents.remove(this.canvas, ["mousedown", "touchstart"])
}
}

View File

@@ -178,7 +178,93 @@ class Scoresheet{
}
this.game.appendChild(this.tetsuoHana)
}
// Add leaderboard submit button if user is logged in
if (account.loggedIn && !this.multiplayer) {
this.leaderboardBtn = document.createElement("div")
this.leaderboardBtn.id = "leaderboard-submit-btn"
this.leaderboardBtn.innerHTML = "🏆 提交排行榜<br><small>Submit to Leaderboard</small>"
this.leaderboardBtn.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
padding: 15px 25px;
background: linear-gradient(135deg, #ff6b9d, #c44db3);
color: white;
border-radius: 15px;
font-size: 18px;
font-family: ${strings.font || "sans-serif"};
text-align: center;
cursor: pointer;
z-index: 1000;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
border: 3px solid #fff;
transition: all 0.2s ease;
user-select: none;
`
this.leaderboardBtn.onmouseover = () => {
if (!this.leaderboardSubmitted) {
this.leaderboardBtn.style.transform = "scale(1.05)"
this.leaderboardBtn.style.boxShadow = "0 6px 20px rgba(0,0,0,0.4)"
}
}
this.leaderboardBtn.onmouseout = () => {
this.leaderboardBtn.style.transform = "scale(1)"
this.leaderboardBtn.style.boxShadow = "0 4px 15px rgba(0,0,0,0.3)"
}
this.leaderboardBtn.onclick = () => this.onLeaderboardBtnClick()
this.game.appendChild(this.leaderboardBtn)
}
}
onLeaderboardBtnClick() {
if (this.leaderboardSubmitted) {
return
}
// Fallback: This is CRITICAL for cases where saveScore() failed or was skipped
if (!this.leaderboardData) {
console.warn("Leaderboard data missing, attempting to reconstruct...")
var song = this.controller.selectedSong
var results = this.resultsObj
if (song && results) {
var songId = song.id || song.hash
if (songId) {
this.leaderboardData = {
songId: songId,
difficulty: results.difficulty,
scoreObj: Object.assign({ score: results.points }, results)
}
// Clean up scoreObj
if (this.leaderboardData.scoreObj) {
delete this.leaderboardData.scoreObj.title
delete this.leaderboardData.scoreObj.difficulty
delete this.leaderboardData.scoreObj.gauge
}
console.log("Leaderboard data reconstructed:", this.leaderboardData)
}
}
}
if (!this.leaderboardData || !this.leaderboardData.songId) {
this.showLeaderboardNotification("no_song_id")
return
}
// Disable button and show submitting state
this.leaderboardBtn.innerHTML = "⏳ 提交中..."
this.leaderboardBtn.style.background = "linear-gradient(135deg, #888, #666)"
this.leaderboardBtn.style.cursor = "default"
this.submitToLeaderboard(
this.leaderboardData.songId,
this.leaderboardData.difficulty,
this.leaderboardData.scoreObj
)
}
redraw() {
if (!this.redrawRunning) {
@@ -926,12 +1012,24 @@ class Scoresheet{
var title = this.controller.selectedSong.originalTitle
var hash = this.controller.selectedSong.hash
var difficulty = this.resultsObj.difficulty
// Use id if available, otherwise use hash
var songId = this.controller.selectedSong.id || hash
var oldScore = scoreStorage.get(hash, difficulty, true)
var clearReached = this.controller.game.rules.clearReached(this.resultsObj.gauge)
var crown = ""
if (clearReached) {
crown = this.resultsObj.bad === 0 ? "gold" : "silver"
}
// Store data for manual leaderboard submission
this.leaderboardData = {
songId: songId,
difficulty: difficulty,
scoreObj: Object.assign({ score: this.resultsObj.points }, this.resultsObj)
}
this.leaderboardSubmitted = false
if (!oldScore || oldScore.points <= this.resultsObj.points) {
if (oldScore && (oldScore.crown === "gold" || oldScore.crown === "silver" && !crown)) {
crown = oldScore.crown
@@ -953,6 +1051,130 @@ class Scoresheet{
this.scoreSaved = true
}
submitToLeaderboard(songId, difficulty, scoreObj) {
// Only submit if user is logged in and song has an ID
if (!account.loggedIn || !songId) {
return
}
var self = this
loader.getCsrfToken().then(token => {
var request = new XMLHttpRequest()
request.open("POST", "api/leaderboard/submit")
request.setRequestHeader("Content-Type", "application/json;charset=UTF-8")
request.setRequestHeader("X-CSRFToken", token)
request.onload = function () {
if (request.status === 200) {
try {
var response = JSON.parse(request.responseText)
if (response.status === "ok") {
self.leaderboardSubmitted = true
self.showLeaderboardNotification(response.message)
// Update button to show success
if (self.leaderboardBtn) {
self.leaderboardBtn.innerHTML = "✅ 已提交<br><small>Submitted!</small>"
self.leaderboardBtn.style.background = "linear-gradient(135deg, #4CAF50, #45a049)"
}
} else {
// Show error
self.showLeaderboardNotification("error")
if (self.leaderboardBtn) {
self.leaderboardBtn.innerHTML = "❌ 失败<br><small>Failed</small>"
self.leaderboardBtn.style.background = "linear-gradient(135deg, #f44336, #d32f2f)"
}
}
} catch (e) {
console.error("Failed to parse leaderboard response:", e)
self.showLeaderboardNotification("error")
}
} else {
self.showLeaderboardNotification("error")
if (self.leaderboardBtn) {
self.leaderboardBtn.innerHTML = "❌ 失败<br><small>Failed</small>"
self.leaderboardBtn.style.background = "linear-gradient(135deg, #f44336, #d32f2f)"
}
}
}
request.onerror = function () {
self.showLeaderboardNotification("error")
if (self.leaderboardBtn) {
self.leaderboardBtn.innerHTML = "❌ 失败<br><small>Failed</small>"
self.leaderboardBtn.style.background = "linear-gradient(135deg, #f44336, #d32f2f)"
}
}
request.send(JSON.stringify({
song_id: songId,
difficulty: difficulty,
score: scoreObj
}))
}).catch(() => {
console.log("Leaderboard submission failed")
this.showLeaderboardNotification("error")
if (this.leaderboardBtn) {
this.leaderboardBtn.innerHTML = "❌ 失败<br><small>Failed</small>"
this.leaderboardBtn.style.background = "linear-gradient(135deg, #f44336, #d32f2f)"
}
})
}
showLeaderboardNotification(message) {
var notification = document.createElement("div")
notification.className = "leaderboard-notification"
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 15px 25px;
background: linear-gradient(135deg, #4a90e2, #7b68ee);
color: white;
border-radius: 10px;
font-size: 16px;
font-family: ${strings.font || "sans-serif"};
z-index: 10000;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
opacity: 0;
transform: translateX(100px);
transition: all 0.3s ease-out;
`
var text = ""
switch (message) {
case "score_submitted": text = "🏆 成绩已提交到排行榜!"; break
case "score_updated": text = "🎉 排行榜成绩已更新!"; break
case "score_not_higher": text = "📊 已有更高成绩"; break
case "score_too_low": text = "未进入排行榜前50"; break
case "error": text = "❌ 提交失败,请重试"; break
case "no_song_id": text = "❌ 无法提交歌曲ID缺失"; break
default: text = "排行榜已更新"
}
notification.innerText = text
document.body.appendChild(notification)
// Trigger animation
setTimeout(() => {
notification.style.opacity = "1"
notification.style.transform = "translateX(0)"
}, 10)
// Remove after 3 seconds
setTimeout(() => {
notification.style.opacity = "0"
notification.style.transform = "translateX(100px)"
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification)
}
}, 300)
}, 3000)
}
clean() {
this.keyboard.clean()
this.gamepad.clean()
@@ -971,10 +1193,17 @@ class Scoresheet{
if (!this.multiplayer) {
delete this.tetsuoHana
}
// Clean up leaderboard button
if (this.leaderboardBtn && this.leaderboardBtn.parentNode) {
this.leaderboardBtn.parentNode.removeChild(this.leaderboardBtn)
}
delete this.leaderboardBtn
delete this.leaderboardData
delete this.ctx
delete this.canvas
delete this.fadeScreen
delete this.results
delete this.rules
}
}

View File

@@ -359,6 +359,7 @@ class Search{
}
highlightResult(text, result) {
if (text === null || text === undefined) return document.createDocumentFragment();
var fragment = document.createDocumentFragment()
var ranges = (result ? result.ranges : null) || []
var lastIdx = 0

View File

@@ -310,6 +310,12 @@ class SongSelect{
iconName: "download",
iconFill: "#e7cbe1",
letterSpacing: 4
}, {
text: "排行榜",
fill: "#ffd700",
iconName: "ranking",
iconFill: "#fff4b5",
letterSpacing: 4
}]
this.optionsList = [strings.none, strings.auto, strings.netplay]
@@ -583,12 +589,16 @@ class SongSelect{
} else if (name === "confirm") {
if (this.selectedDiff === 0) {
this.toSongSelect()
} else if (this.selectedDiff === 1) {
this.toOptions(1)
} else if (this.selectedDiff === 2) {
this.toDownload()
} else if (this.selectedDiff === 3) {
this.toLeaderboard()
} else if (this.selectedDiff === 4) {
this.toDelete()
}else if(this.selectedDiff === 1){
this.toOptions(1)
} else if (false) {
// Moved above
} else {
this.toLoadSong(this.selectedDiff - this.diffOptions.length, shift, ctrl)
}
@@ -688,12 +698,14 @@ class SongSelect{
} else if (moveBy === 0) {
this.selectedDiff = 0
this.toSongSelect()
} else if (moveBy === 1) {
this.toOptions(1)
} else if (moveBy === 2) {
this.toDownload()
} else if (moveBy === 3) {
this.toLeaderboard()
} else if (moveBy === 4) {
this.toDelete()
}else if(moveBy === 1){
this.toOptions(1)
} else if (moveBy === "maker") {
window.open(this.songs[this.selectedSong].maker.url)
} else if (moveBy === this.diffOptions.length + 4) {
@@ -3322,4 +3334,25 @@ class SongSelect{
alert(text);
});
}
toLeaderboard() {
var song = this.songs[this.selectedSong]
var songId = song.id || song.hash
// Allow leaderboard for any song with an ID (numeric or hash)
if (!songId) {
return
}
// Default to first available difficulty if not in a valid difficulty selection
var selectedDiff = this.selectedDiff - this.diffOptions.length
if (selectedDiff < 0) {
selectedDiff = 3 // Default to oni
}
var diffId = this.difficultyId[selectedDiff]
this.clean()
this.playSound("se_don")
new Leaderboard().display(songId, diffId)
}
}

View File

@@ -0,0 +1,3 @@
<div id="leaderboard">
<canvas id="leaderboard-canvas"></canvas>
</div>

133
reproduce_issue.py Normal file
View File

@@ -0,0 +1,133 @@
import requests
import json
import base64
BASE_URL = "http://localhost:34801"
def register_user(username, password):
url = f"{BASE_URL}/api/register"
data = {"username": username, "password": password}
try:
response = requests.post(url, json=data)
if response.status_code == 200:
return response.json()
print(f"Register failed: {response.text}")
except Exception as e:
print(f"Register error: {e}")
return None
def login_user(username, password):
url = f"{BASE_URL}/api/login"
data = {"username": username, "password": password}
session = requests.Session()
try:
response = session.post(url, json=data)
if response.status_code == 200:
return session
print(f"Login failed: {response.text}")
except Exception as e:
print(f"Login error: {e}")
return None
def get_csrf_token(session):
url = f"{BASE_URL}/api/csrftoken"
response = session.get(url)
if response.status_code == 200:
return response.json()['token']
return None
def submit_score(session, song_id, difficulty, score_obj, csrf_token):
url = f"{BASE_URL}/api/leaderboard/submit"
headers = {"X-CSRFToken": csrf_token}
data = {
"song_id": song_id,
"difficulty": difficulty,
"score": score_obj
}
response = session.post(url, json=data, headers=headers)
return response.json()
def get_leaderboard(session, song_id, difficulty):
url = f"{BASE_URL}/api/leaderboard/get"
params = {"song_id": song_id, "difficulty": difficulty}
response = session.get(url, params=params)
return response.json()
def main():
username = "debug_user_1"
password = "password123"
print("1. Registering/Logging in...")
# Try login first
session = login_user(username, password)
if not session:
# Register
reg = register_user(username, password)
if reg:
session = login_user(username, password)
else:
print("Could not register or login")
return
print("2. Getting CSRF token...")
csrf_token = get_csrf_token(session)
if not csrf_token:
print("Could not get CSRF token")
return
song_id = 1 # Assuming song 1 exists
difficulty = "oni"
# Simulate scoresheet.js submission (points only)
score_obj = {
"points": 123456,
"good": 100,
"ok": 10,
"bad": 0,
"maxCombo": 110,
"drumroll": 5,
"crown": "gold"
}
print(f"3. Submitting score: {score_obj['points']}")
submit_res = submit_score(session, song_id, difficulty, score_obj, csrf_token)
print(f"Submit response: {submit_res}")
print("4. Fetching leaderboard...")
leaderboard_res = get_leaderboard(session, song_id, difficulty)
if leaderboard_res['status'] == 'ok':
leaderboard = leaderboard_res['leaderboard']
found = False
for entry in leaderboard:
if entry['username'] == username:
found = True
print(f"Found entry: {entry}")
print(f"entry.score_value: {entry.get('score_value')}")
print(f"entry.score: {entry.get('score')}")
score_value = entry.get('score_value')
score_data = entry.get('score')
# Check what leaderboard.js would see
if score_data and 'score' in score_data:
print(f"JS would assume score: {score_data['score']}")
else:
print("JS would assume score: 0 (undefined)")
if score_value == 123456:
print("SUCCESS: score_value is correct.")
else:
print(f"FAILURE: score_value is incorrect (expected 123456, got {score_value})")
if score_data and score_data.get('points') == 123456:
print("SUCCESS: points preserved in score object.")
if not found:
print("Entry not found in leaderboard (maybe not top 50?)")
else:
print(f"Failed to get leaderboard: {leaderboard_res}")
if __name__ == "__main__":
main()

View File

@@ -80,3 +80,15 @@ scores_save = {
}
}
}
leaderboard_submit = {
'$schema': 'http://json-schema.org/schema#',
'type': 'object',
'properties': {
'song_id': {'type': ['number', 'string']},
'difficulty': {'type': 'string'},
'score': {'type': 'object'}
},
'required': ['song_id', 'difficulty', 'score']
}

View File

@@ -8,7 +8,7 @@ CODENAME=${VERSION_CODENAME:-}
VERSION=${VERSION_ID:-}
echo "更新系统软件源..."
apt-get update -y
apt-get update -y || true
echo "安装基础依赖..."
apt-get install -y python3 python3-venv python3-pip git ffmpeg rsync curl gnupg libcap2-bin

View File

@@ -1,12 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>太鼓ウェブ - Taiko Web | (゚∀゚)</title>
<link rel="icon" href="{{config.assets_baseurl}}img/favicon.png" type="image/png">
<meta name="viewport" content="width=device-width, user-scalable=no">
<meta name="description" content="2025年最新の無料オンラインゲームの パソコンとスマホのブラウザ向けの太鼓の達人シミュレータ 🥁 Taiko no Tatsujin rhythm game simulator for desktop and mobile browsers">
<meta name="keywords" content="taiko no tatsujin, taiko, don chan, online, rhythm, browser, html5, game, for browsers, pc, arcade, emulator, free, download, website, 太鼓の達人, 太鼓ウェブ, 太鼓之達人, 太鼓達人, 太鼓网页, 网页版, 太鼓網頁, 網頁版, 태고의 달인, 태고 웹">
<meta name="description"
content="2026年最新の無料オンラインゲームの パソコンとスマホのブラウザ向けの太鼓の達人シミュレータ 🥁 Taiko no Tatsujin rhythm game simulator for desktop and mobile browsers">
<meta name="keywords"
content="taiko no tatsujin, taiko, don chan, online, rhythm, browser, html5, game, for browsers, pc, arcade, emulator, free, download, website, 太鼓の達人, 太鼓ウェブ, 太鼓之達人, 太鼓達人, 太鼓网页, 网页版, 太鼓網頁, 網頁版, 태고의 달인, 태고 웹">
<meta name="robots" content="notranslate">
<meta name="robots" content="noimageindex">
<meta name="color-scheme" content="only light">
@@ -24,14 +27,17 @@
<script src="src/js/lib/jszip.js"></script>
</head>
<body>
<div id="assets"></div>
<div id="screen" class="pattern-bg"></div>
<div data-nosnippet id="version">
{% if version.version and version.commit_short and version.commit %}
<a href="{{version.url}}commit/{{version.commit}}" target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="vLightNova 1.0.0">vLightNova 1.0.0</a>
<a href="{{version.url}}commit/{{version.commit}}" target="_blank" rel="noopener" id="version-link"
class="stroke-sub" alt="vLightNova 1.1.0">vLightNova 1.1.0</a>
{% else %}
<a target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="vLightNova 1.0.0">vLightNova 1.0.0</a>
<a target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="vLightNova 1.1.0">vLightNova
1.1.0</a>
{% endif %}
</div>
<script src="src/js/browsersupport.js?{{version.commit_short}}"></script>
@@ -43,4 +49,5 @@
</div>
</noscript>
</body>
</html>

32
test_frontend_logic.js Normal file
View File

@@ -0,0 +1,32 @@
// Simulation of song objects
const songs = [
{ name: "Server Song", id: 123, hash: "hash123" },
{ name: "Custom Song (No ID)", id: undefined, hash: "hashCustom" },
{ name: "Custom Song (Null ID)", id: null, hash: "hashNull" },
{ name: "Edge Case (ID 0)", id: 0, hash: "hashZero" } // Checking if behavior is acceptable
];
console.log("=== Frontend Logic Test: Song ID Fallback ===");
songs.forEach(song => {
// Logic from scoresheet.js / songselect.js
const songId = song.id || song.hash;
console.log(`Song: ${song.name}`);
console.log(` Raw ID: ${song.id}, Hash: ${song.hash}`);
console.log(` Resolved songId: ${songId}`);
if (song.id && songId === song.id) {
console.log(" ✅ Correctly used ID");
} else if (!song.id && songId === song.hash) {
console.log(" ✅ Correctly fell back to Hash");
} else if (song.id === 0 && songId === song.hash) {
console.log(" ⚠️ ID was 0, used Hash. (Acceptable if ID > 0 always)");
} else {
console.log(" ❌ FAILED logic mismatch");
}
console.log("---");
});
console.log("Tests Completed.");