Compare commits
25 Commits
48e707cd64
...
Bang3
| Author | SHA1 | Date | |
|---|---|---|---|
| e52baf2555 | |||
| 45d6b1d9de | |||
| bb3ad834b2 | |||
| addd9d90f0 | |||
| 69b92b34d8 | |||
| 9935d70e31 | |||
| 3f7ff13ef7 | |||
| 0706f99427 | |||
| 9bd2b21d44 | |||
| b15752e051 | |||
| 84d15b70c6 | |||
| 271fc52e82 | |||
| 1038fc85b9 | |||
| 76a3d52098 | |||
| d6a1b6bd41 | |||
|
|
6d7be5c45c | ||
| 92c1261f6f | |||
| 9a2a7dbee6 | |||
| f91e3c9089 | |||
| 84a0c2b7e0 | |||
| 9f2d753500 | |||
| a77534c72b | |||
| 1ca7a3f610 | |||
| 4da81d16dc | |||
| 3d611a9c46 |
@@ -0,0 +1,9 @@
|
|||||||
|
I will implement the requested changes to remove the delete functionality and redirect the upload interface while keeping the upload API intact.
|
||||||
|
|
||||||
|
### 1. Frontend: Song Selection Menu (`public/src/js/songselect.js`)
|
||||||
|
* **Remove Delete Button**: I will remove the "Delete" (削除) button configuration from the `difficultyMenu` buttons array (around lines 313-319). This removes the option from the UI.
|
||||||
|
* **Redirect Upload Action**: I will modify the handler for the "upload" action (around lines 954-958). Instead of redirecting to the local `/upload/` page, it will redirect to `https://zizhipu.taiko.asia`.
|
||||||
|
|
||||||
|
### 2. Backend: API Security (`app.py`)
|
||||||
|
* **Disable Delete API**: I will modify the `/api/delete` route to return a 403 Forbidden error (or simply pass), ensuring that songs cannot be deleted even if someone calls the API directly.
|
||||||
|
* **Keep Upload API**: The `/api/upload` route will remain unchanged, preserving the ability to upload songs via API as requested.
|
||||||
76
README.md
76
README.md
@@ -33,6 +33,55 @@ http://<服务器IP>/
|
|||||||
- 创建 `systemd` 服务,使用 `gunicorn` 直接监听 `0.0.0.0:80`
|
- 创建 `systemd` 服务,使用 `gunicorn` 直接监听 `0.0.0.0:80`
|
||||||
- 开放防火墙 `80/tcp`(如系统启用了 `ufw`)
|
- 开放防火墙 `80/tcp`(如系统启用了 `ufw`)
|
||||||
|
|
||||||
|
## 全命令部署(使用 Docker 部署 MongoDB)
|
||||||
|
|
||||||
|
适用系统:Ubuntu 20.04+/22.04+/24.04(需 root),MongoDB 通过 Docker 启动,其余步骤照常。
|
||||||
|
|
||||||
|
1. 安装 Docker 并启动:
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y docker.io
|
||||||
|
sudo systemctl enable --now docker
|
||||||
|
```
|
||||||
|
2. 启动 MongoDB 容器(持久化到 `/srv/taiko-web-mongo`,监听 `27017`):
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /srv/taiko-web-mongo
|
||||||
|
sudo docker run -d \
|
||||||
|
--name taiko-web-mongo \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-v /srv/taiko-web-mongo:/data/db \
|
||||||
|
-p 27017:27017 \
|
||||||
|
mongo:6
|
||||||
|
```
|
||||||
|
如需开启认证,可加上:
|
||||||
|
```bash
|
||||||
|
-e MONGO_INITDB_ROOT_USERNAME=<用户名> -e MONGO_INITDB_ROOT_PASSWORD=<强密码>
|
||||||
|
```
|
||||||
|
并在应用侧通过环境变量指定 Host:
|
||||||
|
```bash
|
||||||
|
export TAIKO_WEB_MONGO_HOST=127.0.0.1:27017
|
||||||
|
```
|
||||||
|
3. 安装并启动 Redis(照常):
|
||||||
|
```bash
|
||||||
|
sudo apt install -y redis-server
|
||||||
|
sudo systemctl enable --now redis-server
|
||||||
|
```
|
||||||
|
4. 准备项目与虚拟环境(照常):
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /srv/taiko-web
|
||||||
|
sudo rsync -a --delete --exclude '.git' --exclude '.venv' . /srv/taiko-web/
|
||||||
|
sudo python3 -m venv /srv/taiko-web/.venv
|
||||||
|
sudo /srv/taiko-web/.venv/bin/pip install -U pip
|
||||||
|
sudo /srv/taiko-web/.venv/bin/pip install -r /srv/taiko-web/requirements.txt
|
||||||
|
sudo cp /srv/taiko-web/config.example.py /srv/taiko-web/config.py
|
||||||
|
```
|
||||||
|
5. 赋予 80 端口绑定权限并启动:
|
||||||
|
```bash
|
||||||
|
sudo setcap 'cap_net_bind_service=+ep' /srv/taiko-web/.venv/bin/python3
|
||||||
|
export TAIKO_WEB_MONGO_HOST=${TAIKO_WEB_MONGO_HOST:-127.0.0.1:27017}
|
||||||
|
sudo /srv/taiko-web/.venv/bin/gunicorn -b 0.0.0.0:80 app:app
|
||||||
|
```
|
||||||
|
|
||||||
## 手动部署(可选)
|
## 手动部署(可选)
|
||||||
|
|
||||||
1. 安装依赖:
|
1. 安装依赖:
|
||||||
@@ -94,3 +143,30 @@ docker run --detach \
|
|||||||
---
|
---
|
||||||
|
|
||||||
如需将监听接口改为仅内网或增加并发工作数(例如 `--workers 4`),可在 `setup.sh` 或 `systemd` 服务中调整。
|
如需将监听接口改为仅内网或增加并发工作数(例如 `--workers 4`),可在 `setup.sh` 或 `systemd` 服务中调整。
|
||||||
|
## 歌曲类型(Type)
|
||||||
|
|
||||||
|
- 可选枚举:
|
||||||
|
- 01 Pop
|
||||||
|
- 02 Anime
|
||||||
|
- 03 Vocaloid
|
||||||
|
- 04 Children and Folk
|
||||||
|
- 05 Variety
|
||||||
|
- 06 Classical
|
||||||
|
- 07 Game Music
|
||||||
|
- 08 Live Festival Mode
|
||||||
|
- 09 Namco Original
|
||||||
|
- 10 Taiko Towers
|
||||||
|
- 11 Dan Dojo
|
||||||
|
|
||||||
|
### 上传要求
|
||||||
|
- 上传表单新增必填字段 `song_type`,取值为上述枚举之一
|
||||||
|
- 成功后将写入 MongoDB `songs.song_type`
|
||||||
|
|
||||||
|
### API 扩展
|
||||||
|
- `GET /api/songs?type=<歌曲类型>` 按类型过滤返回启用歌曲
|
||||||
|
- 示例:`/api/songs?type=02%20Anime`
|
||||||
|
- 返回项包含 `song_type` 字段
|
||||||
|
|
||||||
|
### 前端切换
|
||||||
|
- 在歌曲选择页顶部显示当前歌曲类型标签
|
||||||
|
- 使用左右跳转(Shift+左右或肩键)自动切换类型并刷新列表
|
||||||
|
|||||||
223
app.py
223
app.py
@@ -33,6 +33,7 @@ from flask_session import Session
|
|||||||
from flask_wtf.csrf import CSRFProtect, generate_csrf, CSRFError
|
from flask_wtf.csrf import CSRFProtect, generate_csrf, CSRFError
|
||||||
from ffmpy import FFmpeg
|
from ffmpy import FFmpeg
|
||||||
from pymongo import MongoClient
|
from pymongo import MongoClient
|
||||||
|
from pymongo.errors import DuplicateKeyError
|
||||||
from redis import Redis
|
from redis import Redis
|
||||||
|
|
||||||
def take_config(name, required=False):
|
def take_config(name, required=False):
|
||||||
@@ -44,6 +45,19 @@ def take_config(name, required=False):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
SONG_TYPES = [
|
||||||
|
"01 Pop",
|
||||||
|
"02 Anime",
|
||||||
|
"03 Vocaloid",
|
||||||
|
"04 Children and Folk",
|
||||||
|
"05 Variety",
|
||||||
|
"06 Classical",
|
||||||
|
"07 Game Music",
|
||||||
|
"08 Live Festival Mode",
|
||||||
|
"09 Namco Original",
|
||||||
|
"10 Taiko Towers",
|
||||||
|
"11 Dan Dojo",
|
||||||
|
]
|
||||||
|
|
||||||
def get_remote_address() -> str:
|
def get_remote_address() -> str:
|
||||||
return flask.request.headers.get("CF-Connecting-IP") or flask.request.headers.get("X-Forwarded-For") or flask.request.remote_addr or "127.0.0.1"
|
return flask.request.headers.get("CF-Connecting-IP") or flask.request.headers.get("X-Forwarded-For") or flask.request.remote_addr or "127.0.0.1"
|
||||||
@@ -89,7 +103,11 @@ sess.init_app(app)
|
|||||||
db = client[take_config('MONGO', required=True)['database']]
|
db = client[take_config('MONGO', required=True)['database']]
|
||||||
db.users.create_index('username', unique=True)
|
db.users.create_index('username', unique=True)
|
||||||
db.songs.create_index('id', unique=True)
|
db.songs.create_index('id', unique=True)
|
||||||
|
db.songs.create_index('song_type')
|
||||||
db.scores.create_index('username')
|
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):
|
class HashException(Exception):
|
||||||
@@ -479,7 +497,13 @@ def route_api_preview():
|
|||||||
@app.route(basedir + 'api/songs')
|
@app.route(basedir + 'api/songs')
|
||||||
@app.cache.cached(timeout=15)
|
@app.cache.cached(timeout=15)
|
||||||
def route_api_songs():
|
def route_api_songs():
|
||||||
songs = list(db.songs.find({'enabled': True}, {'_id': False, 'enabled': False}))
|
type_q = flask.request.args.get('type')
|
||||||
|
query = {'enabled': True}
|
||||||
|
if type_q:
|
||||||
|
if type_q not in SONG_TYPES:
|
||||||
|
return abort(400)
|
||||||
|
query['song_type'] = type_q
|
||||||
|
songs = list(db.songs.find(query, {'_id': False, 'enabled': False}))
|
||||||
for song in songs:
|
for song in songs:
|
||||||
if song['maker_id']:
|
if song['maker_id']:
|
||||||
if song['maker_id'] == 0:
|
if song['maker_id'] == 0:
|
||||||
@@ -725,6 +749,167 @@ def route_api_scores_get():
|
|||||||
return jsonify({'status': 'ok', 'scores': scores, 'username': user['username'], 'display_name': user['display_name'], 'don': don})
|
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')
|
@app.route(basedir + 'privacy')
|
||||||
def route_api_privacy():
|
def route_api_privacy():
|
||||||
last_modified = time.strftime('%d %B %Y', time.gmtime(os.path.getmtime('templates/privacy.txt')))
|
last_modified = time.strftime('%d %B %Y', time.gmtime(os.path.getmtime('templates/privacy.txt')))
|
||||||
@@ -852,16 +1037,31 @@ def upload_file():
|
|||||||
db_entry['enabled'] = True
|
db_entry['enabled'] = True
|
||||||
pprint.pprint(db_entry)
|
pprint.pprint(db_entry)
|
||||||
|
|
||||||
# mongoDBにデータをぶち込む
|
# 必要な歌曲类型
|
||||||
client['taiko']["songs"].insert_one(db_entry)
|
song_type = flask.request.form.get('song_type')
|
||||||
|
if not song_type or song_type not in SONG_TYPES:
|
||||||
|
return flask.jsonify({'error': 'invalid_song_type'})
|
||||||
|
db_entry['song_type'] = song_type
|
||||||
|
|
||||||
|
# mongoDBにデータをぶち込む(重複IDは部分更新で上書きし、_id を不変に保つ)
|
||||||
|
coll = client['taiko']["songs"]
|
||||||
|
try:
|
||||||
|
coll.insert_one(db_entry)
|
||||||
|
except DuplicateKeyError:
|
||||||
|
coll.update_one({"id": db_entry["id"]}, {"$set": db_entry}, upsert=True)
|
||||||
# キャッシュ削除(/api/songs)
|
# キャッシュ削除(/api/songs)
|
||||||
try:
|
try:
|
||||||
app.cache.delete_memoized(route_api_songs)
|
app.cache.delete_memoized(route_api_songs)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# ディレクトリを作成
|
base_env = os.getenv("TAIKO_WEB_SONGS_DIR")
|
||||||
target_dir = pathlib.Path(os.getenv("TAIKO_WEB_SONGS_DIR", "public/songs")) / generated_id
|
if base_env:
|
||||||
|
base_dir = pathlib.Path(base_env)
|
||||||
|
else:
|
||||||
|
base_dir = pathlib.Path(__file__).resolve().parent / "public" / "songs"
|
||||||
|
base_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
target_dir = base_dir / generated_id
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# TJAを保存
|
# TJAを保存
|
||||||
@@ -875,19 +1075,8 @@ def upload_file():
|
|||||||
return flask.jsonify({'success': True})
|
return flask.jsonify({'success': True})
|
||||||
|
|
||||||
@app.route("/api/delete", methods=["POST"])
|
@app.route("/api/delete", methods=["POST"])
|
||||||
@limiter.limit("1 per day")
|
|
||||||
def delete():
|
def delete():
|
||||||
id = flask.request.get_json().get('id')
|
return flask.jsonify({ "success": False, "reason": "Deletion is disabled" }), 403
|
||||||
client["taiko"]["songs"].delete_one({ "id": id })
|
|
||||||
|
|
||||||
parent_dir = pathlib.Path(os.getenv("TAIKO_WEB_SONGS_DIR", "public/songs"))
|
|
||||||
target_dir = parent_dir / id
|
|
||||||
if not (target_dir.resolve().parents and parent_dir.resolve() in target_dir.resolve().parents):
|
|
||||||
return flask.jsonify({ "success": False, "reason": "PARENT IS NOT ALLOWED" })
|
|
||||||
|
|
||||||
shutil.rmtree(target_dir)
|
|
||||||
|
|
||||||
return "成功しました。"
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
import argparse
|
import argparse
|
||||||
|
|||||||
BIN
flask_session/2029240f6d1128be89ddc32729463129
Normal file
BIN
flask_session/2029240f6d1128be89ddc32729463129
Normal file
Binary file not shown.
201
local_debug.py
Normal file
201
local_debug.py
Normal 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("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
|
||||||
13
public/src/css/leaderboard.css
Normal file
13
public/src/css/leaderboard.css
Normal 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%;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ var assets = {
|
|||||||
"titlescreen.js",
|
"titlescreen.js",
|
||||||
"scoresheet.js",
|
"scoresheet.js",
|
||||||
"songselect.js",
|
"songselect.js",
|
||||||
|
"leaderboard.js",
|
||||||
"keyboard.js",
|
"keyboard.js",
|
||||||
"gameinput.js",
|
"gameinput.js",
|
||||||
"game.js",
|
"game.js",
|
||||||
@@ -140,6 +141,7 @@ var assets = {
|
|||||||
},
|
},
|
||||||
"views": [
|
"views": [
|
||||||
"game.html",
|
"game.html",
|
||||||
|
"leaderboard.html",
|
||||||
"loadsong.html",
|
"loadsong.html",
|
||||||
"songselect.html",
|
"songselect.html",
|
||||||
"titlescreen.html",
|
"titlescreen.html",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
class ImportSongs{
|
class ImportSongs{
|
||||||
constructor(...args){
|
constructor(...args){
|
||||||
this.init(...args)
|
this.init(...args)
|
||||||
}
|
}
|
||||||
@@ -322,6 +322,10 @@
|
|||||||
songTitle = songTitle.slice(0, uraPos)
|
songTitle = songTitle.slice(0, uraPos)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if(id === "cn" && !meta["titlecn"] && meta.titlezh){
|
||||||
|
titleLang.cn = meta.titlezh
|
||||||
|
titleLangAdded = true
|
||||||
|
}
|
||||||
if(meta["title" + id]){
|
if(meta["title" + id]){
|
||||||
titleLang[id] = meta["title" + id]
|
titleLang[id] = meta["title" + id]
|
||||||
titleLangAdded = true
|
titleLangAdded = true
|
||||||
@@ -329,6 +333,10 @@
|
|||||||
titleLang[id] = this.songTitle[songTitle][id] + ura
|
titleLang[id] = this.songTitle[songTitle][id] + ura
|
||||||
titleLangAdded = true
|
titleLangAdded = true
|
||||||
}
|
}
|
||||||
|
if(id === "cn" && !meta["subtitlecn"] && meta.subtitlezh){
|
||||||
|
subtitleLang.cn = meta.subtitlezh
|
||||||
|
subtitleLangAdded = true
|
||||||
|
}
|
||||||
if(meta["subtitle" + id]){
|
if(meta["subtitle" + id]){
|
||||||
subtitleLang[id] = meta["subtitle" + id]
|
subtitleLang[id] = meta["subtitle" + id]
|
||||||
subtitleLangAdded = true
|
subtitleLangAdded = true
|
||||||
|
|||||||
410
public/src/js/leaderboard.js
Normal file
410
public/src/js/leaderboard.js
Normal 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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -178,7 +178,93 @@ class Scoresheet{
|
|||||||
}
|
}
|
||||||
this.game.appendChild(this.tetsuoHana)
|
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() {
|
redraw() {
|
||||||
if (!this.redrawRunning) {
|
if (!this.redrawRunning) {
|
||||||
@@ -926,12 +1012,24 @@ class Scoresheet{
|
|||||||
var title = this.controller.selectedSong.originalTitle
|
var title = this.controller.selectedSong.originalTitle
|
||||||
var hash = this.controller.selectedSong.hash
|
var hash = this.controller.selectedSong.hash
|
||||||
var difficulty = this.resultsObj.difficulty
|
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 oldScore = scoreStorage.get(hash, difficulty, true)
|
||||||
var clearReached = this.controller.game.rules.clearReached(this.resultsObj.gauge)
|
var clearReached = this.controller.game.rules.clearReached(this.resultsObj.gauge)
|
||||||
var crown = ""
|
var crown = ""
|
||||||
if (clearReached) {
|
if (clearReached) {
|
||||||
crown = this.resultsObj.bad === 0 ? "gold" : "silver"
|
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.points <= this.resultsObj.points) {
|
||||||
if (oldScore && (oldScore.crown === "gold" || oldScore.crown === "silver" && !crown)) {
|
if (oldScore && (oldScore.crown === "gold" || oldScore.crown === "silver" && !crown)) {
|
||||||
crown = oldScore.crown
|
crown = oldScore.crown
|
||||||
@@ -953,6 +1051,130 @@ class Scoresheet{
|
|||||||
this.scoreSaved = true
|
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() {
|
clean() {
|
||||||
this.keyboard.clean()
|
this.keyboard.clean()
|
||||||
this.gamepad.clean()
|
this.gamepad.clean()
|
||||||
@@ -971,10 +1193,17 @@ class Scoresheet{
|
|||||||
if (!this.multiplayer) {
|
if (!this.multiplayer) {
|
||||||
delete this.tetsuoHana
|
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.ctx
|
||||||
delete this.canvas
|
delete this.canvas
|
||||||
delete this.fadeScreen
|
delete this.fadeScreen
|
||||||
delete this.results
|
delete this.results
|
||||||
delete this.rules
|
delete this.rules
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -359,6 +359,7 @@ class Search{
|
|||||||
}
|
}
|
||||||
|
|
||||||
highlightResult(text, result) {
|
highlightResult(text, result) {
|
||||||
|
if (text === null || text === undefined) return document.createDocumentFragment();
|
||||||
var fragment = document.createDocumentFragment()
|
var fragment = document.createDocumentFragment()
|
||||||
var ranges = (result ? result.ranges : null) || []
|
var ranges = (result ? result.ranges : null) || []
|
||||||
var lastIdx = 0
|
var lastIdx = 0
|
||||||
|
|||||||
@@ -311,10 +311,10 @@ class SongSelect{
|
|||||||
iconFill: "#e7cbe1",
|
iconFill: "#e7cbe1",
|
||||||
letterSpacing: 4
|
letterSpacing: 4
|
||||||
}, {
|
}, {
|
||||||
text: "削除",
|
text: "排行榜",
|
||||||
fill: "silver",
|
fill: "#ffd700",
|
||||||
iconName: "trash",
|
iconName: "ranking",
|
||||||
iconFill: "#111111",
|
iconFill: "#fff4b5",
|
||||||
letterSpacing: 4
|
letterSpacing: 4
|
||||||
}]
|
}]
|
||||||
this.optionsList = [strings.none, strings.auto, strings.netplay]
|
this.optionsList = [strings.none, strings.auto, strings.netplay]
|
||||||
@@ -386,6 +386,32 @@ class SongSelect{
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.songSelect = document.getElementById("song-select")
|
this.songSelect = document.getElementById("song-select")
|
||||||
|
this.songTypes = [
|
||||||
|
"01 Pop",
|
||||||
|
"02 Anime",
|
||||||
|
"03 Vocaloid",
|
||||||
|
"04 Children and Folk",
|
||||||
|
"05 Variety",
|
||||||
|
"06 Classical",
|
||||||
|
"07 Game Music",
|
||||||
|
"08 Live Festival Mode",
|
||||||
|
"09 Namco Original",
|
||||||
|
"10 Taiko Towers",
|
||||||
|
"11 Dan Dojo",
|
||||||
|
]
|
||||||
|
this.songTypeIndex = Math.max(0, Math.min(this.songTypes.length - 1, +(localStorage.getItem("songTypeIndex") || 0)))
|
||||||
|
this.typeLabel = document.createElement("div")
|
||||||
|
this.typeLabel.style.position = "absolute"
|
||||||
|
this.typeLabel.style.top = "8px"
|
||||||
|
this.typeLabel.style.left = "12px"
|
||||||
|
this.typeLabel.style.padding = "4px 8px"
|
||||||
|
this.typeLabel.style.background = "rgba(0,0,0,0.5)"
|
||||||
|
this.typeLabel.style.color = "#fff"
|
||||||
|
this.typeLabel.style.borderRadius = "6px"
|
||||||
|
this.typeLabel.style.fontSize = "14px"
|
||||||
|
this.typeLabel.style.zIndex = "10"
|
||||||
|
this.songSelect.appendChild(this.typeLabel)
|
||||||
|
this.updateTypeLabel()
|
||||||
var cat = this.songs[this.selectedSong].originalCategory
|
var cat = this.songs[this.selectedSong].originalCategory
|
||||||
this.drawBackground(cat)
|
this.drawBackground(cat)
|
||||||
|
|
||||||
@@ -536,24 +562,20 @@ class SongSelect{
|
|||||||
this.toSession()
|
this.toSession()
|
||||||
} else if (name === "left") {
|
} else if (name === "left") {
|
||||||
if (shift) {
|
if (shift) {
|
||||||
if(!repeat){
|
if (!repeat) { this.changeType(-1) }
|
||||||
this.categoryJump(-1)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
this.moveToSong(-1)
|
this.moveToSong(-1)
|
||||||
}
|
}
|
||||||
} else if (name === "right") {
|
} else if (name === "right") {
|
||||||
if (shift) {
|
if (shift) {
|
||||||
if(!repeat){
|
if (!repeat) { this.changeType(1) }
|
||||||
this.categoryJump(1)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
this.moveToSong(1)
|
this.moveToSong(1)
|
||||||
}
|
}
|
||||||
} else if (name === "jump_left" && !repeat) {
|
} else if (name === "jump_left" && !repeat) {
|
||||||
this.categoryJump(-1)
|
this.changeType(-1)
|
||||||
} else if (name === "jump_right" && !repeat) {
|
} else if (name === "jump_right" && !repeat) {
|
||||||
this.categoryJump(1)
|
this.changeType(1)
|
||||||
} else if (name === "mute" || name === "ctrlGamepad") {
|
} else if (name === "mute" || name === "ctrlGamepad") {
|
||||||
this.endPreview(true)
|
this.endPreview(true)
|
||||||
this.playBgm(false)
|
this.playBgm(false)
|
||||||
@@ -567,12 +589,16 @@ class SongSelect{
|
|||||||
} else if (name === "confirm") {
|
} else if (name === "confirm") {
|
||||||
if (this.selectedDiff === 0) {
|
if (this.selectedDiff === 0) {
|
||||||
this.toSongSelect()
|
this.toSongSelect()
|
||||||
|
} else if (this.selectedDiff === 1) {
|
||||||
|
this.toOptions(1)
|
||||||
} else if (this.selectedDiff === 2) {
|
} else if (this.selectedDiff === 2) {
|
||||||
this.toDownload()
|
this.toDownload()
|
||||||
} else if (this.selectedDiff === 3) {
|
} else if (this.selectedDiff === 3) {
|
||||||
|
this.toLeaderboard()
|
||||||
|
} else if (this.selectedDiff === 4) {
|
||||||
this.toDelete()
|
this.toDelete()
|
||||||
}else if(this.selectedDiff === 1){
|
} else if (false) {
|
||||||
this.toOptions(1)
|
// Moved above
|
||||||
} else {
|
} else {
|
||||||
this.toLoadSong(this.selectedDiff - this.diffOptions.length, shift, ctrl)
|
this.toLoadSong(this.selectedDiff - this.diffOptions.length, shift, ctrl)
|
||||||
}
|
}
|
||||||
@@ -598,6 +624,23 @@ class SongSelect{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateTypeLabel() {
|
||||||
|
this.setAltText(this.typeLabel, this.songTypes[this.songTypeIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
changeType(delta) {
|
||||||
|
this.songTypeIndex = (this.songTypeIndex + delta + this.songTypes.length) % this.songTypes.length
|
||||||
|
localStorage.setItem("songTypeIndex", this.songTypeIndex)
|
||||||
|
this.updateTypeLabel()
|
||||||
|
var type = encodeURIComponent(this.songTypes[this.songTypeIndex])
|
||||||
|
loader.ajax("api/songs?type=" + type).then(resp => {
|
||||||
|
var songs = JSON.parse(resp)
|
||||||
|
assets.songsDefault = songs
|
||||||
|
assets.songs = assets.songsDefault
|
||||||
|
new SongSelect(false, false, this.touchEnabled)
|
||||||
|
}).catch(() => { })
|
||||||
|
}
|
||||||
|
|
||||||
mouseDown(event) {
|
mouseDown(event) {
|
||||||
if (event.target === this.selectable || event.target.parentNode === this.selectable) {
|
if (event.target === this.selectable || event.target.parentNode === this.selectable) {
|
||||||
this.selectable.focus()
|
this.selectable.focus()
|
||||||
@@ -655,12 +698,14 @@ class SongSelect{
|
|||||||
} else if (moveBy === 0) {
|
} else if (moveBy === 0) {
|
||||||
this.selectedDiff = 0
|
this.selectedDiff = 0
|
||||||
this.toSongSelect()
|
this.toSongSelect()
|
||||||
|
} else if (moveBy === 1) {
|
||||||
|
this.toOptions(1)
|
||||||
} else if (moveBy === 2) {
|
} else if (moveBy === 2) {
|
||||||
this.toDownload()
|
this.toDownload()
|
||||||
} else if (moveBy === 3) {
|
} else if (moveBy === 3) {
|
||||||
|
this.toLeaderboard()
|
||||||
|
} else if (moveBy === 4) {
|
||||||
this.toDelete()
|
this.toDelete()
|
||||||
}else if(moveBy === 1){
|
|
||||||
this.toOptions(1)
|
|
||||||
} else if (moveBy === "maker") {
|
} else if (moveBy === "maker") {
|
||||||
window.open(this.songs[this.selectedSong].maker.url)
|
window.open(this.songs[this.selectedSong].maker.url)
|
||||||
} else if (moveBy === this.diffOptions.length + 4) {
|
} else if (moveBy === this.diffOptions.length + 4) {
|
||||||
@@ -915,7 +960,7 @@ class SongSelect{
|
|||||||
} else if (currentSong.action === "upload") {
|
} else if (currentSong.action === "upload") {
|
||||||
this.playSound("se_don");
|
this.playSound("se_don");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = "/upload/";
|
window.location.href = "https://zizhipu.taiko.asia";
|
||||||
}, 100);
|
}, 100);
|
||||||
} else if (currentSong.action === "keijiban") {
|
} else if (currentSong.action === "keijiban") {
|
||||||
this.playSound("se_don");
|
this.playSound("se_don");
|
||||||
@@ -2926,6 +2971,12 @@ class SongSelect{
|
|||||||
var categoryName = song.category
|
var categoryName = song.category
|
||||||
var originalCategory = song.category
|
var originalCategory = song.category
|
||||||
}
|
}
|
||||||
|
if (!categoryName) {
|
||||||
|
if (song.song_type) {
|
||||||
|
categoryName = song.song_type
|
||||||
|
originalCategory = song.song_type
|
||||||
|
}
|
||||||
|
}
|
||||||
var addedSong = {
|
var addedSong = {
|
||||||
title: title,
|
title: title,
|
||||||
originalTitle: song.title,
|
originalTitle: song.title,
|
||||||
@@ -3068,6 +3119,15 @@ class SongSelect{
|
|||||||
|
|
||||||
getLocalTitle(title, titleLang) {
|
getLocalTitle(title, titleLang) {
|
||||||
if (titleLang) {
|
if (titleLang) {
|
||||||
|
if (strings.id === "cn") {
|
||||||
|
if (titleLang.cn) {
|
||||||
|
return titleLang.cn
|
||||||
|
}
|
||||||
|
if (titleLang.ja) {
|
||||||
|
return titleLang.ja
|
||||||
|
}
|
||||||
|
return title
|
||||||
|
}
|
||||||
for (var id in titleLang) {
|
for (var id in titleLang) {
|
||||||
if (id === "en" && strings.preferEn && !(strings.id in titleLang) && titleLang.en || id === strings.id && titleLang[id]) {
|
if (id === "en" && strings.preferEn && !(strings.id in titleLang) && titleLang.en || id === strings.id && titleLang[id]) {
|
||||||
return titleLang[id]
|
return titleLang[id]
|
||||||
@@ -3274,4 +3334,25 @@ class SongSelect{
|
|||||||
alert(text);
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
3
public/src/views/leaderboard.html
Normal file
3
public/src/views/leaderboard.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<div id="leaderboard">
|
||||||
|
<canvas id="leaderboard-canvas"></canvas>
|
||||||
|
</div>
|
||||||
@@ -16,6 +16,21 @@
|
|||||||
|
|
||||||
<label for="file_music">音楽ファイル:</label>
|
<label for="file_music">音楽ファイル:</label>
|
||||||
<input type="file" name="file_music" accept=".ogg,.mp3,.wav" required>
|
<input type="file" name="file_music" accept=".ogg,.mp3,.wav" required>
|
||||||
|
|
||||||
|
<label for="song_type">曲のタイプ:</label>
|
||||||
|
<select name="song_type" required>
|
||||||
|
<option value="01 Pop">01 Pop</option>
|
||||||
|
<option value="02 Anime">02 Anime</option>
|
||||||
|
<option value="03 Vocaloid">03 Vocaloid</option>
|
||||||
|
<option value="04 Children and Folk">04 Children and Folk</option>
|
||||||
|
<option value="05 Variety">05 Variety</option>
|
||||||
|
<option value="06 Classical">06 Classical</option>
|
||||||
|
<option value="07 Game Music">07 Game Music</option>
|
||||||
|
<option value="08 Live Festival Mode">08 Live Festival Mode</option>
|
||||||
|
<option value="09 Namco Original">09 Namco Original</option>
|
||||||
|
<option value="10 Taiko Towers">10 Taiko Towers</option>
|
||||||
|
<option value="11 Dan Dojo">11 Dan Dojo</option>
|
||||||
|
</select>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<button type="button" onclick="uploadFiles()">今すぐ投稿! (1分ほどかかる場合があります)</button>
|
<button type="button" onclick="uploadFiles()">今すぐ投稿! (1分ほどかかる場合があります)</button>
|
||||||
|
|||||||
133
reproduce_issue.py
Normal file
133
reproduce_issue.py
Normal 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()
|
||||||
12
schema.py
12
schema.py
@@ -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']
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
2
setup.sh
2
setup.sh
@@ -8,7 +8,7 @@ CODENAME=${VERSION_CODENAME:-}
|
|||||||
VERSION=${VERSION_ID:-}
|
VERSION=${VERSION_ID:-}
|
||||||
|
|
||||||
echo "更新系统软件源..."
|
echo "更新系统软件源..."
|
||||||
apt-get update -y
|
apt-get update -y || true
|
||||||
echo "安装基础依赖..."
|
echo "安装基础依赖..."
|
||||||
apt-get install -y python3 python3-venv python3-pip git ffmpeg rsync curl gnupg libcap2-bin
|
apt-get install -y python3 python3-venv python3-pip git ffmpeg rsync curl gnupg libcap2-bin
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>太鼓ウェブ - Taiko Web | (゚∀゚)</title>
|
<title>太鼓ウェブ - Taiko Web | (゚∀゚)</title>
|
||||||
<link rel="icon" href="{{config.assets_baseurl}}img/favicon.png" type="image/png">
|
<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="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="description"
|
||||||
<meta name="keywords" content="taiko no tatsujin, taiko, don chan, online, rhythm, browser, html5, game, for browsers, pc, arcade, emulator, free, download, website, 太鼓の達人, 太鼓ウェブ, 太鼓之達人, 太鼓達人, 太鼓网页, 网页版, 太鼓網頁, 網頁版, 태고의 달인, 태고 웹">
|
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="notranslate">
|
||||||
<meta name="robots" content="noimageindex">
|
<meta name="robots" content="noimageindex">
|
||||||
<meta name="color-scheme" content="only light">
|
<meta name="color-scheme" content="only light">
|
||||||
@@ -24,14 +27,17 @@
|
|||||||
<script src="src/js/lib/jszip.js"></script>
|
<script src="src/js/lib/jszip.js"></script>
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="assets"></div>
|
<div id="assets"></div>
|
||||||
<div id="screen" class="pattern-bg"></div>
|
<div id="screen" class="pattern-bg"></div>
|
||||||
<div data-nosnippet id="version">
|
<div data-nosnippet id="version">
|
||||||
{% if version.version and version.commit_short and version.commit %}
|
{% 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="taiko-web ver.{{version.version}} ({{version.commit_short}})">taiko-web ver.{{version.version}} ({{version.commit_short}})</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 %}
|
{% else %}
|
||||||
<a target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="taiko-web vRAINBOW-BETA4">taiko-web vRAINBOW-BETA4</a>
|
<a target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="vLightNova 1.1.0">vLightNova
|
||||||
|
1.1.0</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<script src="src/js/browsersupport.js?{{version.commit_short}}"></script>
|
<script src="src/js/browsersupport.js?{{version.commit_short}}"></script>
|
||||||
@@ -43,4 +49,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</noscript>
|
</noscript>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
32
test_frontend_logic.js
Normal file
32
test_frontend_logic.js
Normal 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.");
|
||||||
22
tjaf.py
22
tjaf.py
@@ -7,6 +7,8 @@ class Tja:
|
|||||||
self.text = text
|
self.text = text
|
||||||
self.title: Optional[str] = None
|
self.title: Optional[str] = None
|
||||||
self.subtitle: Optional[str] = None
|
self.subtitle: Optional[str] = None
|
||||||
|
self.title_ja: Optional[str] = None
|
||||||
|
self.subtitle_ja: Optional[str] = None
|
||||||
self.wave: Optional[str] = None
|
self.wave: Optional[str] = None
|
||||||
self.offset: Optional[float] = None
|
self.offset: Optional[float] = None
|
||||||
self.courses: Dict[str, Dict[str, Optional[int]]] = {}
|
self.courses: Dict[str, Dict[str, Optional[int]]] = {}
|
||||||
@@ -25,8 +27,12 @@ class Tja:
|
|||||||
val = v.strip()
|
val = v.strip()
|
||||||
if key == "TITLE":
|
if key == "TITLE":
|
||||||
self.title = val or None
|
self.title = val or None
|
||||||
|
elif key == "TITLEJA":
|
||||||
|
self.title_ja = val or None
|
||||||
elif key == "SUBTITLE":
|
elif key == "SUBTITLE":
|
||||||
self.subtitle = val or None
|
self.subtitle = val or None
|
||||||
|
elif key == "SUBTITLEJA":
|
||||||
|
self.subtitle_ja = val or None
|
||||||
elif key == "WAVE":
|
elif key == "WAVE":
|
||||||
self.wave = val or None
|
self.wave = val or None
|
||||||
elif key == "OFFSET":
|
elif key == "OFFSET":
|
||||||
@@ -73,8 +79,20 @@ class Tja:
|
|||||||
"type": "tja",
|
"type": "tja",
|
||||||
"title": self.title,
|
"title": self.title,
|
||||||
"subtitle": self.subtitle,
|
"subtitle": self.subtitle,
|
||||||
"title_lang": {"ja": self.title, "en": None, "cn": None, "tw": None, "ko": None},
|
"title_lang": {
|
||||||
"subtitle_lang": {"ja": self.subtitle, "en": None, "cn": None, "tw": None, "ko": None},
|
"ja": self.title_ja or self.title,
|
||||||
|
"en": None,
|
||||||
|
"cn": self.title_ja or None,
|
||||||
|
"tw": None,
|
||||||
|
"ko": None,
|
||||||
|
},
|
||||||
|
"subtitle_lang": {
|
||||||
|
"ja": self.subtitle_ja or self.subtitle,
|
||||||
|
"en": None,
|
||||||
|
"cn": self.subtitle_ja or None,
|
||||||
|
"tw": None,
|
||||||
|
"ko": None,
|
||||||
|
},
|
||||||
"courses": courses_out,
|
"courses": courses_out,
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
"category_id": None,
|
"category_id": None,
|
||||||
|
|||||||
40
update.sh
Normal file
40
update.sh
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
if [ "${EUID}" -ne 0 ]; then echo "需要 root 权限"; exit 1; fi
|
||||||
|
|
||||||
|
SRC_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
DEST_DIR=/srv/taiko-web
|
||||||
|
SONGS_DIR="$DEST_DIR/public/songs"
|
||||||
|
BACKUP_DIR="$DEST_DIR/.backup_songs_$(date +%Y%m%d_%H%M%S)"
|
||||||
|
|
||||||
|
systemctl stop taiko-web || true
|
||||||
|
|
||||||
|
if [ -d "$SONGS_DIR" ]; then
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
rsync -a "$SONGS_DIR/" "$BACKUP_DIR/" || cp -a "$SONGS_DIR/." "$BACKUP_DIR/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$DEST_DIR"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude '.git' \
|
||||||
|
--exclude '.venv' \
|
||||||
|
--exclude 'public/songs' \
|
||||||
|
"$SRC_DIR/" "$DEST_DIR/"
|
||||||
|
|
||||||
|
if [ -x "$DEST_DIR/.venv/bin/pip" ]; then
|
||||||
|
"$DEST_DIR/.venv/bin/pip" install -U pip
|
||||||
|
"$DEST_DIR/.venv/bin/pip" install -r "$DEST_DIR/requirements.txt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
chown -R www-data:www-data "$DEST_DIR"
|
||||||
|
|
||||||
|
if [ -d "$BACKUP_DIR" ]; then
|
||||||
|
mkdir -p "$SONGS_DIR"
|
||||||
|
rsync -a "$BACKUP_DIR/" "$SONGS_DIR/" || cp -a "$BACKUP_DIR/." "$SONGS_DIR/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
systemctl restart taiko-web || systemctl start taiko-web || true
|
||||||
|
|
||||||
|
systemctl is-active --quiet taiko-web
|
||||||
Reference in New Issue
Block a user