diff --git a/backend/common/test_frontend_assets.py b/backend/common/test_frontend_assets.py index 345c9a9..b419477 100644 --- a/backend/common/test_frontend_assets.py +++ b/backend/common/test_frontend_assets.py @@ -143,3 +143,18 @@ def test_realtime_match_联机码与_websocket_前端资源存在(): assert "Idempotency-Key" in realtime assert ".challenge-panel" in styles assert ".realtime-progress-panel" in styles + + +def test_contest_统一玩家池并提供三种实时玩法(): + template = (settings.BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8") + app = (STATIC_ROOT / "js" / "app.js").read_text(encoding="utf-8") + games = (STATIC_ROOT / "js" / "games.js").read_text(encoding="utf-8") + realtime = (STATIC_ROOT / "js" / "realtime.js").read_text(encoding="utf-8") + + assert 'id="track-switch"' not in template + assert 'data-match-mode="quiz"' in template + assert 'data-match-mode="sudoku"' in template + assert 'data-match-mode="twenty_four"' in template + assert "state.matchMode = button.dataset.matchMode" in app + assert "difficultySelect" not in games + assert "body: { game_kind: state.matchMode }" in realtime diff --git a/backend/contest/admin.py b/backend/contest/admin.py index 07da68a..18b0f16 100644 --- a/backend/contest/admin.py +++ b/backend/contest/admin.py @@ -34,7 +34,7 @@ class ContestAdmin(admin.ModelAdmin): class ContestAttemptAdmin(admin.ModelAdmin): list_display = ("id", "user", "contest", "status", "score", "duration_ms", "started_at") list_filter = ("status", "contest__kind", "contest__track") - readonly_fields = ("started_at", "submitted_at") + readonly_fields = ("question_order", "started_at", "submitted_at") @admin.register(CheatFlag) @@ -72,13 +72,14 @@ class RealtimeMatchAdmin(admin.ModelAdmin): "id", "contest", "match_type", + "game_kind", "challenge_code", "player_one", "player_two", "status", "created_at", ) - list_filter = ("match_type", "status", "contest__track") + list_filter = ("match_type", "game_kind", "status", "contest__track") search_fields = ( "challenge_code", "player_one__username", @@ -94,6 +95,7 @@ class MathGameAttemptAdmin(admin.ModelAdmin): "id", "user", "kind", + "match", "difficulty", "status", "score", diff --git a/backend/contest/game_services.py b/backend/contest/game_services.py index 7a8cdee..0e1c700 100644 --- a/backend/contest/game_services.py +++ b/backend/contest/game_services.py @@ -57,6 +57,7 @@ def _grid_from_text(value): def game_payload(attempt): return { "attempt_id": attempt.id, + "match_id": attempt.match_id, "kind": attempt.kind, "difficulty": attempt.difficulty, "status": attempt.status, @@ -68,7 +69,7 @@ def game_payload(attempt): } -def start_game(user, kind, difficulty): +def build_game(kind, difficulty=MathGameAttempt.Difficulty.STANDARD): if kind not in MathGameAttempt.Kind.values: raise ValidationError({"kind": "不支持的数学玩法"}) if difficulty not in MathGameAttempt.Difficulty.values: @@ -82,6 +83,11 @@ def start_game(user, kind, difficulty): secrets.SystemRandom().shuffle(numbers) puzzle = {"numbers": numbers} solution = {"target": 24} + return puzzle, solution + + +def start_game(user, kind, difficulty=MathGameAttempt.Difficulty.STANDARD): + puzzle, solution = build_game(kind, difficulty) attempt = MathGameAttempt.objects.create( user=user, kind=kind, @@ -164,10 +170,14 @@ def _validate_sudoku_grid(raw_grid, puzzle, solution): @transaction.atomic def submit_game(user, attempt_id, submission, submission_key): attempt = MathGameAttempt.objects.select_for_update().get(id=attempt_id, user=user) - if attempt.status == MathGameAttempt.Status.COMPLETED: - if submission_key and attempt.submission_key == submission_key: + if attempt.status != MathGameAttempt.Status.ACTIVE: + if ( + attempt.status == MathGameAttempt.Status.COMPLETED + and submission_key + and attempt.submission_key == submission_key + ): return game_payload(attempt) - raise ValidationError("这局游戏已经完成") + raise ValidationError("这局游戏已经结束") if not submission_key: raise ValidationError({"Idempotency-Key": "提交必须提供幂等键"}) diff --git a/backend/contest/management/commands/seed_contests.py b/backend/contest/management/commands/seed_contests.py index 6ce4898..f00476e 100644 --- a/backend/contest/management/commands/seed_contests.py +++ b/backend/contest/management/commands/seed_contests.py @@ -195,7 +195,6 @@ class Command(BaseCommand): ContestQuestion.objects.filter(contest=contest).delete() pool = list(versions[track]) random.shuffle(pool) - selected = pool[:8] ContestQuestion.objects.bulk_create( [ ContestQuestion( @@ -204,7 +203,7 @@ class Command(BaseCommand): order=index, points=100, ) - for index, version in enumerate(selected, start=1) + for index, version in enumerate(pool, start=1) ] ) total = sum(map(len, QUESTIONS.values())) diff --git a/backend/contest/migrations/0005_contestattempt_question_order_mathgameattempt_match_and_more.py b/backend/contest/migrations/0005_contestattempt_question_order_mathgameattempt_match_and_more.py new file mode 100644 index 0000000..8a1689e --- /dev/null +++ b/backend/contest/migrations/0005_contestattempt_question_order_mathgameattempt_match_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.23 on 2026-08-09 16:17 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contest', '0004_remove_realtimematch_matchmaking_lookup_idx_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='contestattempt', + name='question_order', + field=models.JSONField(blank=True, default=list), + ), + migrations.AddField( + model_name='mathgameattempt', + name='match', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='game_attempts', to='contest.realtimematch'), + ), + migrations.AddField( + model_name='realtimematch', + name='game_kind', + field=models.CharField(choices=[('quiz', '口算竞速'), ('sudoku', '数独 Timerun'), ('twenty_four', '24 点竞速')], default='quiz', max_length=20), + ), + migrations.AddConstraint( + model_name='mathgameattempt', + constraint=models.UniqueConstraint(fields=('match', 'user'), name='unique_user_realtime_math_game'), + ), + ] diff --git a/backend/contest/migrations/0006_expand_contest_question_pools.py b/backend/contest/migrations/0006_expand_contest_question_pools.py new file mode 100644 index 0000000..3fdc032 --- /dev/null +++ b/backend/contest/migrations/0006_expand_contest_question_pools.py @@ -0,0 +1,57 @@ +from django.db import migrations + + +def expand_question_pools(apps, schema_editor): + Contest = apps.get_model("contest", "Contest") + ContestQuestion = apps.get_model("contest", "ContestQuestion") + QuestionVersion = apps.get_model("contest", "QuestionVersion") + + for contest in Contest.objects.all().iterator(): + existing_version_ids = set( + ContestQuestion.objects.filter(contest=contest).values_list( + "question_version_id", + flat=True, + ) + ) + latest_versions = {} + versions = QuestionVersion.objects.filter( + question__track=contest.track, + question__is_active=True, + ).order_by("question_id", "-version") + for version in versions.iterator(): + latest_versions.setdefault(version.question_id, version.id) + + next_order = ( + ContestQuestion.objects.filter(contest=contest) + .order_by("-order") + .values_list("order", flat=True) + .first() + or 0 + ) + additions = [] + for version_id in latest_versions.values(): + if version_id in existing_version_ids: + continue + next_order += 1 + additions.append( + ContestQuestion( + contest_id=contest.id, + question_version_id=version_id, + order=next_order, + points=100, + ) + ) + ContestQuestion.objects.bulk_create(additions, batch_size=500) + + +class Migration(migrations.Migration): + dependencies = [ + ("contest", "0005_contestattempt_question_order_mathgameattempt_match_and_more"), + ] + + operations = [ + migrations.RunPython( + expand_question_pools, + migrations.RunPython.noop, + ), + ] diff --git a/backend/contest/migrations/0007_remove_realtimematch_matchmaking_lookup_idx_and_more.py b/backend/contest/migrations/0007_remove_realtimematch_matchmaking_lookup_idx_and_more.py new file mode 100644 index 0000000..4f3af4f --- /dev/null +++ b/backend/contest/migrations/0007_remove_realtimematch_matchmaking_lookup_idx_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.23 on 2026-08-09 16:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contest', '0006_expand_contest_question_pools'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='realtimematch', + name='matchmaking_lookup_idx', + ), + migrations.AddIndex( + model_name='realtimematch', + index=models.Index(fields=['contest', 'match_type', 'game_kind', 'status', 'player_one_rating', 'created_at'], name='matchmaking_mode_idx'), + ), + ] diff --git a/backend/contest/models.py b/backend/contest/models.py index 8fd20d2..bf45ba5 100644 --- a/backend/contest/models.py +++ b/backend/contest/models.py @@ -87,6 +87,11 @@ class RealtimeMatch(models.Model): COMPLETED = "completed", "已完成" CANCELLED = "cancelled", "已取消" + class GameKind(models.TextChoices): + QUIZ = "quiz", "口算竞速" + SUDOKU = "sudoku", "数独 Timerun" + TWENTY_FOUR = "twenty_four", "24 点竞速" + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) contest = models.ForeignKey(Contest, on_delete=models.PROTECT) match_type = models.CharField( @@ -95,6 +100,11 @@ class RealtimeMatch(models.Model): default=MatchType.RANDOM, ) challenge_code = models.CharField(max_length=8, unique=True, null=True, blank=True) + game_kind = models.CharField( + max_length=20, + choices=GameKind.choices, + default=GameKind.QUIZ, + ) player_one = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="matches_as_player_one" ) @@ -126,11 +136,12 @@ class RealtimeMatch(models.Model): fields=( "contest", "match_type", + "game_kind", "status", "player_one_rating", "created_at", ), - name="matchmaking_lookup_idx", + name="matchmaking_mode_idx", ) ] @@ -152,6 +163,7 @@ class ContestAttempt(models.Model): correct_count = models.PositiveIntegerField(default=0) answer_count = models.PositiveIntegerField(default=0) duration_ms = models.PositiveIntegerField(default=0) + question_order = models.JSONField(default=list, blank=True) submission_key = models.CharField(max_length=80, null=True, blank=True) started_at = models.DateTimeField(auto_now_add=True) submitted_at = models.DateTimeField(null=True, blank=True) @@ -232,6 +244,13 @@ class MathGameAttempt(models.Model): on_delete=models.CASCADE, related_name="math_game_attempts", ) + match = models.ForeignKey( + RealtimeMatch, + null=True, + blank=True, + on_delete=models.PROTECT, + related_name="game_attempts", + ) kind = models.CharField(max_length=20, choices=Kind.choices) difficulty = models.CharField( max_length=16, @@ -254,7 +273,11 @@ class MathGameAttempt(models.Model): models.UniqueConstraint( fields=("user", "submission_key"), name="unique_user_math_game_submission", - ) + ), + models.UniqueConstraint( + fields=("match", "user"), + name="unique_user_realtime_math_game", + ), ] indexes = [ models.Index( diff --git a/backend/contest/services.py b/backend/contest/services.py index 8a3cc39..48b32f2 100644 --- a/backend/contest/services.py +++ b/backend/contest/services.py @@ -16,12 +16,14 @@ from .models import ( Contest, ContestAnswer, ContestAttempt, + MathGameAttempt, RatingHistory, RealtimeMatch, ) CHALLENGE_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" WAITING_MATCH_TTL = timedelta(minutes=10) +ATTEMPT_QUESTION_COUNT = 8 def _broadcast_match(match_id, reason): @@ -54,6 +56,13 @@ def _validate_realtime_contest(contest): raise ValidationError("实时比赛不可用") +def _validate_game_kind(game_kind): + value = str(game_kind or RealtimeMatch.GameKind.QUIZ) + if value not in RealtimeMatch.GameKind.values: + raise ValidationError({"game_kind": "不支持的实时比赛玩法"}) + return value + + def _cancel_expired_waiting_matches(): RealtimeMatch.objects.filter(status=RealtimeMatch.Status.WAITING).filter( Q(expires_at__isnull=True) | Q(expires_at__lte=timezone.now()) @@ -71,13 +80,13 @@ def _active_match_for(user): ) -def _cancel_other_waiting_matches(user, match_type): +def _cancel_other_waiting_matches(user, match_type, game_kind): matches = list( RealtimeMatch.objects.filter( player_one=user, status=RealtimeMatch.Status.WAITING, ) - .exclude(match_type=match_type) + .exclude(match_type=match_type, game_kind=game_kind) .values_list("id", flat=True) ) if matches: @@ -88,7 +97,29 @@ def _cancel_other_waiting_matches(user, match_type): notify_match_on_commit(match_id, "cancelled") +def _select_question_order(contest): + question_ids = list( + contest.contest_questions.filter(question_version__question__is_active=True) + .order_by("id") + .values_list("id", flat=True) + ) + if not question_ids: + raise ValidationError("比赛题库为空") + count = min(ATTEMPT_QUESTION_COUNT, len(question_ids)) + return secrets.SystemRandom().sample(question_ids, count) + + +def _attempt_questions(attempt): + queryset = attempt.contest.contest_questions.select_related("question_version") + if attempt.question_order: + items = {item.id: item for item in queryset.filter(id__in=attempt.question_order)} + return [items[item_id] for item_id in attempt.question_order if item_id in items] + return list(queryset.all()) + + def _activate_match(match, user): + from .game_services import build_game + now = timezone.now() match.player_two = user match.player_two_rating = user.rating @@ -97,13 +128,49 @@ def _activate_match(match, user): match.save( update_fields=["player_two", "player_two_rating", "status", "started_at"] ) - ContestAttempt.objects.bulk_create( - [ - ContestAttempt(contest=match.contest, user=match.player_one, match=match), - ContestAttempt(contest=match.contest, user=user, match=match), - ] - ) - match.attempts.update(started_at=now) + if match.game_kind == RealtimeMatch.GameKind.QUIZ: + question_order = _select_question_order(match.contest) + ContestAttempt.objects.bulk_create( + [ + ContestAttempt( + contest=match.contest, + user=match.player_one, + match=match, + question_order=question_order, + ), + ContestAttempt( + contest=match.contest, + user=user, + match=match, + question_order=question_order, + ), + ] + ) + match.attempts.update(started_at=now) + else: + puzzle, solution = build_game( + match.game_kind, + MathGameAttempt.Difficulty.STANDARD, + ) + MathGameAttempt.objects.bulk_create( + [ + MathGameAttempt( + user=match.player_one, + match=match, + kind=match.game_kind, + puzzle=puzzle, + solution=solution, + ), + MathGameAttempt( + user=user, + match=match, + kind=match.game_kind, + puzzle=puzzle, + solution=solution, + ), + ] + ) + match.game_attempts.update(started_at=now) notify_match_on_commit(match.id, "matched") return match @@ -119,9 +186,9 @@ def normalize_answer(value): def attempt_payload(attempt, include_results=False): questions = [] answers = {answer.contest_question_id: answer for answer in attempt.answers.all()} - for item in attempt.contest.contest_questions.select_related("question_version").all(): + for display_order, item in enumerate(_attempt_questions(attempt), start=1): question = { - "order": item.order, + "order": display_order, "prompt": item.question_version.prompt, "metadata": item.question_version.metadata, "points": item.points, @@ -169,7 +236,11 @@ def start_attempt(user, contest): existing, include_results=existing.status != ContestAttempt.Status.ACTIVE, ) - attempt = ContestAttempt.objects.create(contest=contest, user=user) + attempt = ContestAttempt.objects.create( + contest=contest, + user=user, + question_order=_select_question_order(contest), + ) return attempt_payload(attempt) @@ -193,9 +264,7 @@ def submit_attempt(user, attempt_id, raw_answers, submission_key): now = timezone.now() duration_ms = max(0, int((now - attempt.started_at).total_seconds() * 1000)) limit_ms = attempt.contest.duration_seconds * 1000 - items = list( - attempt.contest.contest_questions.select_related("question_version").all() - ) + items = _attempt_questions(attempt) if not isinstance(raw_answers, list): raise ValidationError({"answers": "答案必须是数组"}) by_order = {} @@ -209,8 +278,8 @@ def submit_attempt(user, attempt_id, raw_answers, submission_key): raise ValidationError({"answers": "答案题号无效或重复"}) from exc score = 0 correct_count = 0 - for contest_question in items: - submitted = str(by_order.get(contest_question.order, ""))[:200] + for display_order, contest_question in enumerate(items, start=1): + submitted = str(by_order.get(display_order, ""))[:200] correct = normalize_answer(submitted) == normalize_answer( contest_question.question_version.answer ) @@ -260,20 +329,26 @@ def submit_attempt(user, attempt_id, raw_answers, submission_key): @transaction.atomic -def find_match(user, contest): +def find_match(user, contest, game_kind=RealtimeMatch.GameKind.QUIZ): _validate_realtime_contest(contest) + game_kind = _validate_game_kind(game_kind) _cancel_expired_waiting_matches() active = _active_match_for(user) if active: - if active.contest_id == contest.id: + if active.contest_id == contest.id and active.game_kind == game_kind: return active raise ValidationError("你已有一场进行中的实时比赛") - _cancel_other_waiting_matches(user, RealtimeMatch.MatchType.RANDOM) + _cancel_other_waiting_matches( + user, + RealtimeMatch.MatchType.RANDOM, + game_kind, + ) existing = ( RealtimeMatch.objects.filter( Q(player_one=user) | Q(player_two=user), contest=contest, match_type=RealtimeMatch.MatchType.RANDOM, + game_kind=game_kind, status__in=[RealtimeMatch.Status.WAITING, RealtimeMatch.Status.ACTIVE], ).filter(Q(status=RealtimeMatch.Status.ACTIVE) | Q(expires_at__gt=timezone.now())) .order_by("-created_at") @@ -287,6 +362,7 @@ def find_match(user, contest): .filter( contest=contest, match_type=RealtimeMatch.MatchType.RANDOM, + game_kind=game_kind, status=RealtimeMatch.Status.WAITING, expires_at__gt=timezone.now(), player_one_rating__gte=max(0, user.rating - 300), @@ -300,6 +376,7 @@ def find_match(user, contest): return RealtimeMatch.objects.create( contest=contest, match_type=RealtimeMatch.MatchType.RANDOM, + game_kind=game_kind, player_one=user, player_one_rating=user.rating, expires_at=timezone.now() + WAITING_MATCH_TTL, @@ -309,18 +386,24 @@ def find_match(user, contest): @transaction.atomic -def create_challenge(user, contest): +def create_challenge(user, contest, game_kind=RealtimeMatch.GameKind.QUIZ): _validate_realtime_contest(contest) + game_kind = _validate_game_kind(game_kind) _cancel_expired_waiting_matches() active = _active_match_for(user) if active: raise ValidationError("你已有一场进行中的实时比赛") - _cancel_other_waiting_matches(user, RealtimeMatch.MatchType.CHALLENGE) + _cancel_other_waiting_matches( + user, + RealtimeMatch.MatchType.CHALLENGE, + game_kind, + ) existing = ( RealtimeMatch.objects.filter( player_one=user, contest=contest, match_type=RealtimeMatch.MatchType.CHALLENGE, + game_kind=game_kind, status=RealtimeMatch.Status.WAITING, expires_at__gt=timezone.now(), ) @@ -332,6 +415,7 @@ def create_challenge(user, contest): return RealtimeMatch.objects.create( contest=contest, match_type=RealtimeMatch.MatchType.CHALLENGE, + game_kind=game_kind, challenge_code=_new_challenge_code(), player_one=user, player_one_rating=user.rating, @@ -396,11 +480,19 @@ def cancel_waiting_match(user, match_id): def match_payload(match, user): + from .game_services import game_payload + reveal_results = match.status == RealtimeMatch.Status.COMPLETED - attempts = { - attempt.user_id: attempt - for attempt in match.attempts.select_related("user", "contest").all() - } + if match.game_kind == RealtimeMatch.GameKind.QUIZ: + attempts = { + attempt.user_id: attempt + for attempt in match.attempts.select_related("user", "contest").all() + } + else: + attempts = { + attempt.user_id: attempt + for attempt in match.game_attempts.select_related("user").all() + } attempt = attempts.get(user.id) opponent = match.player_two if match.player_one_id == user.id else match.player_one opponent_attempt = attempts.get(opponent.id) if opponent else None @@ -409,9 +501,18 @@ def match_payload(match, user): if reveal_results else None ) + attempt_data = None + if attempt: + attempt_data = ( + attempt_payload(attempt, include_results=reveal_results) + if match.game_kind == RealtimeMatch.GameKind.QUIZ + else game_payload(attempt) + ) return { "match_id": match.id, "match_type": match.match_type, + "game_kind": match.game_kind, + "game_label": match.get_game_kind_display(), "is_owner": match.player_one_id == user.id, "challenge_code": ( match.challenge_code @@ -420,7 +521,11 @@ def match_payload(match, user): else None ), "status": match.status, - "contest": match.contest.title, + "contest": ( + match.contest.title + if match.game_kind == RealtimeMatch.GameKind.QUIZ + else match.get_game_kind_display() + ), "duration_seconds": match.contest.duration_seconds, "expires_at": match.expires_at, "started_at": match.started_at, @@ -432,7 +537,9 @@ def match_payload(match, user): "score": opponent_attempt.score if reveal_results and opponent_attempt else None, "correct_count": ( opponent_attempt.correct_count - if reveal_results and opponent_attempt + if reveal_results + and opponent_attempt + and match.game_kind == RealtimeMatch.GameKind.QUIZ else None ), "duration_ms": ( @@ -444,11 +551,7 @@ def match_payload(match, user): if opponent else None ), - "attempt": ( - attempt_payload(attempt, include_results=reveal_results) - if attempt - else None - ), + "attempt": attempt_data, "result": ( { "winner": ( @@ -487,13 +590,34 @@ def refresh_match_state(match_id): deadline = match.started_at + timedelta(seconds=match.contest.duration_seconds) if timezone.now() >= deadline: now = timezone.now() - for attempt in match.attempts.filter(status=ContestAttempt.Status.ACTIVE): - elapsed_ms = max(0, int((now - attempt.started_at).total_seconds() * 1000)) - attempt.status = ContestAttempt.Status.EXPIRED - attempt.duration_ms = elapsed_ms - attempt.submitted_at = now - attempt.save(update_fields=["status", "duration_ms", "submitted_at"]) - match = finalize_match(match.id) + if match.game_kind == RealtimeMatch.GameKind.QUIZ: + for attempt in match.attempts.filter( + status=ContestAttempt.Status.ACTIVE + ): + elapsed_ms = max( + 0, + int((now - attempt.started_at).total_seconds() * 1000), + ) + attempt.status = ContestAttempt.Status.EXPIRED + attempt.duration_ms = elapsed_ms + attempt.submitted_at = now + attempt.save( + update_fields=["status", "duration_ms", "submitted_at"] + ) + match = finalize_match(match.id) + else: + elapsed_ms = max( + 0, + int((now - match.started_at).total_seconds() * 1000), + ) + match.game_attempts.filter( + status=MathGameAttempt.Status.ACTIVE + ).update( + status=MathGameAttempt.Status.FAILED, + duration_ms=elapsed_ms, + submitted_at=now, + ) + match = finalize_game_match(match.id) return match @@ -502,36 +626,7 @@ def _elo_delta(rating, opponent_rating, score, k=32): return round(k * (score - expected)) -@transaction.atomic -def finalize_match(match_id): - match = RealtimeMatch.objects.select_for_update().get(id=match_id) - if match.status != RealtimeMatch.Status.ACTIVE: - return match - attempts = list(match.attempts.select_related("user").order_by("user_id")) - if len(attempts) != 2 or any( - attempt.status == ContestAttempt.Status.ACTIVE for attempt in attempts - ): - return match - - first = next(item for item in attempts if item.user_id == match.player_one_id) - second = next(item for item in attempts if item.user_id == match.player_two_id) - if first.score > second.score: - first_result, second_result = 1.0, 0.0 - match.winner_id = first.user_id - elif second.score > first.score: - first_result, second_result = 0.0, 1.0 - match.winner_id = second.user_id - else: - diff = first.duration_ms - second.duration_ms - if abs(diff) <= 100: - first_result = second_result = 0.5 - elif diff < 0: - first_result, second_result = 1.0, 0.0 - match.winner_id = first.user_id - else: - first_result, second_result = 0.0, 1.0 - match.winner_id = second.user_id - +def _settle_match(match, first_result, second_result, winner_id): users = { user.id: user for user in User.objects.select_for_update().filter( @@ -555,8 +650,86 @@ def finalize_match(match_id): rating_after=user.rating, delta=delta, ) + match.winner_id = winner_id match.status = RealtimeMatch.Status.COMPLETED match.completed_at = timezone.now() match.save(update_fields=["winner", "status", "completed_at"]) notify_match_on_commit(match.id, "completed") return match + + +@transaction.atomic +def finalize_match(match_id): + match = RealtimeMatch.objects.select_for_update().get(id=match_id) + if match.status != RealtimeMatch.Status.ACTIVE: + return match + attempts = list(match.attempts.select_related("user").order_by("user_id")) + if len(attempts) != 2 or any( + attempt.status == ContestAttempt.Status.ACTIVE for attempt in attempts + ): + return match + + first = next(item for item in attempts if item.user_id == match.player_one_id) + second = next(item for item in attempts if item.user_id == match.player_two_id) + if first.score > second.score: + first_result, second_result = 1.0, 0.0 + winner_id = first.user_id + elif second.score > first.score: + first_result, second_result = 0.0, 1.0 + winner_id = second.user_id + else: + diff = first.duration_ms - second.duration_ms + if abs(diff) <= 100: + first_result = second_result = 0.5 + winner_id = None + elif diff < 0: + first_result, second_result = 1.0, 0.0 + winner_id = first.user_id + else: + first_result, second_result = 0.0, 1.0 + winner_id = second.user_id + return _settle_match( + match, + first_result, + second_result, + winner_id, + ) + + +@transaction.atomic +def finalize_game_match(match_id): + match = RealtimeMatch.objects.select_for_update().get(id=match_id) + if match.status != RealtimeMatch.Status.ACTIVE: + return match + attempts = list(match.game_attempts.select_related("user").order_by("user_id")) + if len(attempts) != 2 or any( + attempt.status == MathGameAttempt.Status.ACTIVE for attempt in attempts + ): + return match + + first = next(item for item in attempts if item.user_id == match.player_one_id) + second = next(item for item in attempts if item.user_id == match.player_two_id) + first_completed = first.status == MathGameAttempt.Status.COMPLETED + second_completed = second.status == MathGameAttempt.Status.COMPLETED + if first_completed and not second_completed: + first_result, second_result, winner_id = 1.0, 0.0, first.user_id + elif second_completed and not first_completed: + first_result, second_result, winner_id = 0.0, 1.0, second.user_id + elif not first_completed and not second_completed: + first_result = second_result = 0.5 + winner_id = None + else: + diff = first.duration_ms - second.duration_ms + if abs(diff) <= 100: + first_result = second_result = 0.5 + winner_id = None + elif diff < 0: + first_result, second_result, winner_id = 1.0, 0.0, first.user_id + else: + first_result, second_result, winner_id = 0.0, 1.0, second.user_id + return _settle_match( + match, + first_result, + second_result, + winner_id, + ) diff --git a/backend/contest/test_game_services.py b/backend/contest/test_game_services.py index a9651df..bd7fc41 100644 --- a/backend/contest/test_game_services.py +++ b/backend/contest/test_game_services.py @@ -174,4 +174,5 @@ def test_math_game_api_目录公开但开局需要登录(client, game_user): assert {item["kind"] for item in catalog.json()} == {"sudoku", "twenty_four"} assert anonymous_start.status_code in {401, 403} assert authenticated_start.status_code == 201 + assert authenticated_start.json()["difficulty"] == "standard" assert "solution" not in authenticated_start.json() diff --git a/backend/contest/test_realtime_api.py b/backend/contest/test_realtime_api.py index 67341fc..25ad23a 100644 --- a/backend/contest/test_realtime_api.py +++ b/backend/contest/test_realtime_api.py @@ -203,3 +203,54 @@ def test_challenge_api_已使用联机码返回具体原因(realtime_api_setup): assert response.status_code == 400 assert response.json()["error"]["message"] == "联机码已失效或已被使用" + + +@pytest.mark.django_db +def test_challenge_api_24点竞速完整闭环(realtime_api_setup): + contest, first_client, second_client = realtime_api_setup + created = first_client.post( + f"/api/v1/contests/{contest.slug}/challenges/", + {"game_kind": "twenty_four"}, + content_type="application/json", + ) + joined = second_client.post( + "/api/v1/contests/challenges/join/", + {"challenge_code": created.json()["challenge_code"]}, + content_type="application/json", + ) + + assert joined.status_code == 200 + assert joined.json()["game_kind"] == "twenty_four" + assert created.json()["attempt"] is None + first_state = first_client.get( + f"/api/v1/contests/matches/{joined.json()['match_id']}/" + ).json() + assert first_state["attempt"]["puzzle"] == joined.json()["attempt"]["puzzle"] + + solutions = { + (2, 3, 4, 9): "(2/3)*4*9", + (3, 5, 7, 13): "(7+5*13)/3", + (4, 7, 8, 8): "8*(4+7-8)", + } + expression = solutions[ + tuple(sorted(joined.json()["attempt"]["puzzle"]["numbers"])) + ] + first_submit = first_client.post( + f"/api/v1/contests/games/attempts/{first_state['attempt']['attempt_id']}/submit/", + {"expression": expression}, + content_type="application/json", + HTTP_IDEMPOTENCY_KEY="api-game-first", + ) + second_submit = second_client.post( + f"/api/v1/contests/games/attempts/{joined.json()['attempt']['attempt_id']}/submit/", + {"expression": expression}, + content_type="application/json", + HTTP_IDEMPOTENCY_KEY="api-game-second", + ) + + assert first_submit.status_code == 200 + assert first_submit.json()["status"] == "active" + assert first_submit.json()["attempt"]["status"] == "completed" + assert second_submit.status_code == 200 + assert second_submit.json()["status"] == "completed" + assert second_submit.json()["result"]["winner"] in {"self", "opponent", "draw"} diff --git a/backend/contest/test_services.py b/backend/contest/test_services.py index 7cd5495..c24c03c 100644 --- a/backend/contest/test_services.py +++ b/backend/contest/test_services.py @@ -5,10 +5,12 @@ from django.utils import timezone from rest_framework.exceptions import ValidationError from accounts.models import User +from contest.game_services import submit_game from contest.models import ( Contest, ContestAttempt, ContestQuestion, + MathGameAttempt, Question, QuestionVersion, RatingHistory, @@ -17,6 +19,7 @@ from contest.models import ( from contest.services import ( cancel_waiting_match, create_challenge, + finalize_game_match, finalize_match, find_match, join_challenge, @@ -190,6 +193,21 @@ def test_find_match_同分玩家配对并由_finalize_match_唯一结算_rating( track=Question.Track.STANDARD, status=Contest.Status.PUBLISHED, ) + question = Question.objects.create( + slug="realtime-rating-question", + track=Question.Track.STANDARD, + ) + version = QuestionVersion.objects.create( + question=question, + version=1, + prompt="1 + 1", + answer="2", + ) + ContestQuestion.objects.create( + contest=contest, + question_version=version, + order=1, + ) waiting = find_match(first, contest) active = find_match(second, contest) @@ -360,3 +378,104 @@ def test_challenge_owner_可取消等待中的联机码(realtime_contest): ), waiting.challenge_code, ) + + +@pytest.mark.django_db +def test_practice_attempt_从完整题池保存随机快照(user): + contest = Contest.objects.create( + slug="random-practice", + title="随机练习", + kind=Contest.Kind.PRACTICE, + track=Question.Track.STANDARD, + status=Contest.Status.PUBLISHED, + ) + for index in range(12): + question = Question.objects.create( + slug=f"random-question-{index}", + track=Question.Track.STANDARD, + ) + version = QuestionVersion.objects.create( + question=question, + version=1, + prompt=f"{index} + 1", + answer=str(index + 1), + ) + ContestQuestion.objects.create( + contest=contest, + question_version=version, + order=index + 1, + ) + + payloads = [start_attempt(user, contest) for _ in range(8)] + snapshots = { + tuple( + ContestAttempt.objects.get(id=payload["attempt_id"]).question_order + ) + for payload in payloads + } + + assert all(len(payload["questions"]) == 8 for payload in payloads) + assert len(snapshots) > 1 + + +@pytest.mark.django_db +def test_twenty_four_realtime_共享题面按完成时间结算_rating(realtime_contest): + first = User.objects.create_user( + username="game_match_one", + password="StrongPass_2026", + nickname="竞速玩家一", + ) + second = User.objects.create_user( + username="game_match_two", + password="StrongPass_2026", + nickname="竞速玩家二", + ) + waiting = create_challenge( + first, + realtime_contest, + RealtimeMatch.GameKind.TWENTY_FOUR, + ) + match = join_challenge(second, waiting.challenge_code) + attempts = { + attempt.user_id: attempt for attempt in match.game_attempts.all() + } + first_attempt = attempts[first.id] + second_attempt = attempts[second.id] + assert first_attempt.puzzle == second_attempt.puzzle + assert first_attempt.solution == second_attempt.solution + + solutions = { + (2, 3, 4, 9): "(2/3)*4*9", + (3, 5, 7, 13): "(7+5*13)/3", + (4, 7, 8, 8): "8*(4+7-8)", + } + expression = solutions[tuple(sorted(first_attempt.puzzle["numbers"]))] + MathGameAttempt.objects.filter(id=first_attempt.id).update( + started_at=timezone.now() - timedelta(seconds=2) + ) + MathGameAttempt.objects.filter(id=second_attempt.id).update( + started_at=timezone.now() - timedelta(seconds=5) + ) + submit_game( + first, + first_attempt.id, + {"expression": expression}, + "game-race-first", + ) + assert finalize_game_match(match.id).status == RealtimeMatch.Status.ACTIVE + submit_game( + second, + second_attempt.id, + {"expression": expression}, + "game-race-second", + ) + + completed = finalize_game_match(match.id) + first.refresh_from_db() + second.refresh_from_db() + + assert completed.status == RealtimeMatch.Status.COMPLETED + assert completed.winner_id == first.id + assert first.rating == 1016 + assert second.rating == 984 + assert RatingHistory.objects.filter(match=match).count() == 2 diff --git a/backend/contest/views.py b/backend/contest/views.py index ffcded6..16f3b76 100644 --- a/backend/contest/views.py +++ b/backend/contest/views.py @@ -8,9 +8,11 @@ from .models import Contest, ContestAttempt, MathGameAttempt, RealtimeMatch from .services import ( cancel_waiting_match, create_challenge, + finalize_game_match, find_match, join_challenge, match_payload, + notify_match_on_commit, refresh_match_state, start_attempt, submit_attempt, @@ -64,7 +66,11 @@ class AttemptSubmitView(APIView): class MatchmakingView(APIView): def post(self, request, slug): contest = get_object_or_404(Contest, slug=slug) - match = find_match(request.user, contest) + match = find_match( + request.user, + contest, + request.data.get("game_kind", RealtimeMatch.GameKind.QUIZ), + ) return Response(match_payload(match, request.user), status=status.HTTP_202_ACCEPTED) @@ -83,7 +89,11 @@ class MatchStateView(APIView): class ChallengeCreateView(APIView): def post(self, request, slug): contest = get_object_or_404(Contest, slug=slug) - match = create_challenge(request.user, contest) + match = create_challenge( + request.user, + contest, + request.data.get("game_kind", RealtimeMatch.GameKind.QUIZ), + ) return Response(match_payload(match, request.user), status=status.HTTP_201_CREATED) @@ -158,19 +168,29 @@ class MathGameStartView(APIView): payload = start_game( request.user, kind, - request.data.get("difficulty", MathGameAttempt.Difficulty.STANDARD), + MathGameAttempt.Difficulty.STANDARD, ) return Response(payload, status=status.HTTP_201_CREATED) class MathGameSubmitView(APIView): def post(self, request, attempt_id): + attempt = get_object_or_404( + MathGameAttempt, + id=attempt_id, + user=request.user, + ) payload = submit_game( request.user, attempt_id, request.data, request.headers.get("Idempotency-Key"), ) + if attempt.match_id: + match = finalize_game_match(attempt.match_id) + if match.status != RealtimeMatch.Status.COMPLETED: + notify_match_on_commit(match.id, "submitted") + return Response(match_payload(match, request.user)) return Response(payload) diff --git a/backend/static/css/app.css b/backend/static/css/app.css index 9bf9f6c..4e056c9 100644 --- a/backend/static/css/app.css +++ b/backend/static/css/app.css @@ -132,6 +132,10 @@ button { color: inherit; } .track-switch { display: flex; gap: 5px; margin-bottom: 22px; } .track-switch button { border: 1px solid var(--line); padding: 9px 18px; border-radius: 99px; background: transparent; cursor: pointer; } .track-switch button.active { background: var(--ink); color: white; } +.match-mode-switch { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 22px; } +.match-mode-switch button { border: 1px solid var(--line); border-radius: 14px; padding: 15px 17px; background: rgba(255,255,252,.75); text-align: left; cursor: pointer; } +.match-mode-switch b, .match-mode-switch small { display: block; }.match-mode-switch small { margin-top: 5px; color: var(--muted); } +.match-mode-switch button.active { border-color: var(--green); background: var(--ink); color: white; }.match-mode-switch button.active small { color: #b9c2bc; } .challenge-panel { display: grid; grid-template-columns: minmax(0, 1fr) minmax(380px, .9fr); gap: 24px; align-items: center; margin-bottom: 22px; padding: 24px 26px; border: 1px solid var(--line); border-radius: 18px; background: linear-gradient(120deg, rgba(25,101,72,.07), rgba(204,232,91,.12)); }.challenge-panel h2 { margin: 7px 0 6px; font: 27px Georgia, serif; }.challenge-panel p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.7; }.challenge-actions { display: grid; gap: 9px; }.challenge-actions > .primary-button { width: 100%; }.challenge-actions form { display: grid; grid-template-columns: 1fr auto; gap: 8px; }.challenge-actions input { min-width: 0; border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; background: white; text-transform: uppercase; letter-spacing: .18em; font-weight: 700; }.challenge-actions .dark-button { width: auto; margin: 0; white-space: nowrap; } .realtime-status-line { display: flex; justify-content: space-between; gap: 15px; margin: 10px 0 18px; color: var(--muted); font-size: 11px; }.realtime-status-line strong { color: var(--green); } .realtime-waiting { display: grid; justify-items: center; gap: 16px; padding: 35px 20px; border-radius: 18px; background: #eef1eb; text-align: center; }.realtime-waiting p { max-width: 480px; margin: 0; color: var(--muted); line-height: 1.7; }.realtime-pulse { width: 18px; height: 18px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 0 rgba(25,101,72,.35); animation: realtime-pulse 1.5s infinite; }.challenge-code { border: 1px dashed var(--green); border-radius: 14px; padding: 14px 24px; background: white; color: var(--green); font: 700 31px/1 "SFMono-Regular", Consolas, monospace; letter-spacing: .22em; cursor: pointer; } @@ -150,7 +154,7 @@ button { color: inherit; } .game-card-top { display: flex; justify-content: space-between; align-items: start; }.game-card-top > span { display: grid; place-items: center; width: 58px; height: 58px; border-radius: 16px; background: var(--ink); color: var(--lime); font: 700 20px Georgia, serif; }.game-card-top small { color: var(--muted); } .game-card > b { margin-top: 24px; color: var(--green); font-size: 9px; letter-spacing: .18em; }.game-card h3 { margin: 8px 0; font: 28px Georgia, serif; }.game-card p { margin: 0; color: var(--muted); line-height: 1.7; } .game-card-controls { display: flex; gap: 10px; margin-top: auto; }.game-card-controls select { min-width: 100px; border: 1px solid var(--line); border-radius: 10px; padding: 11px; background: white; }.game-card-controls .primary-button { margin-left: auto; } -.twenty-four-numbers { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 24px 0; }.twenty-four-numbers button { aspect-ratio: 1; border: 1px solid var(--line); border-radius: 18px; background: var(--ink); color: var(--lime); font: 36px Georgia, serif; cursor: pointer; } +.twenty-four-numbers { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 24px 0; }.twenty-four-numbers button, .twenty-four-numbers > span { display: grid; place-items: center; aspect-ratio: 1; border: 1px solid var(--line); border-radius: 18px; background: var(--ink); color: var(--lime); font: 36px Georgia, serif; cursor: pointer; } .twenty-four-form { display: grid; gap: 12px; }.game-keypad { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; }.game-keypad button { border: 1px solid var(--line); border-radius: 9px; padding: 10px; background: white; cursor: pointer; } .sudoku-board { width: min(100%, 540px); margin: 22px auto; display: grid; grid-template-columns: repeat(9, 1fr); border: 3px solid var(--ink); background: var(--ink); gap: 1px; } .sudoku-board input { width: 100%; min-width: 0; aspect-ratio: 1; border: 0; border-radius: 0; background: white; color: var(--green); text-align: center; font: 600 21px Georgia, serif; outline: 2px solid transparent; outline-offset: -2px; } @@ -285,6 +289,7 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); } .section-heading { display: block; }.section-heading p { margin-top: 12px; }.spirit-grid, .content-grid, .editor-shell, .metric-grid, .tool-grid, .symbol-grid, .video-grid, .graph-shell, .game-card-grid, .calc-result-grid { grid-template-columns: 1fr; } .content-grid { gap: 11px; }.editor-shell { min-height: 700px; }.page-title { padding-top: 42px; }.page-title h1 { font-size: 45px; } .preview-pane { min-height: 320px; }.match-history-item { align-items: flex-start; flex-direction: column; }.match-history-item > div:last-child { text-align: left; } + .match-mode-switch { grid-template-columns: 1fr; } .tool-card { min-height: 125px; }.workspace-heading, .map-heading { display: block; }.workspace-heading p { margin-top: 12px; }.tool-workspace { padding: 16px; }.calculator-controls { grid-template-columns: 1fr 1fr; }.calculator-display { min-height: 120px; padding: 18px; }.calculator-display output { font-size: 34px; }.calc-examples .primary-button { width: 100%; margin-left: 0; }.drawing-toolbar { align-items: stretch; }.drawing-toolbar .primary-button { width: 100%; margin-left: 0; }.canvas-stage canvas { width: 100%; min-width: 0; } .math-games-section { margin-top: 45px; }.game-card { min-height: 260px; padding: 21px; }.game-card-controls { align-items: stretch; }.game-card-controls .primary-button { flex: 1; }.twenty-four-numbers { gap: 7px; }.twenty-four-numbers button { border-radius: 13px; font-size: 28px; }.sudoku-board input { font-size: clamp(13px, 4.5vw, 20px); }.sudoku-actions { display: grid; grid-template-columns: 1fr 1fr; }.game-keypad { gap: 5px; } .challenge-panel { grid-template-columns: 1fr; padding: 20px; }.challenge-actions form { grid-template-columns: 1fr; }.challenge-actions .dark-button { width: 100%; }.challenge-code { width: 100%; padding: 14px 10px; font-size: 27px; }.realtime-status-line { display: grid; }.realtime-progress-panel { grid-template-columns: 1fr 1fr; } diff --git a/backend/static/js/app.js b/backend/static/js/app.js index c895dbf..de8dafa 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -29,7 +29,7 @@ const state = { user: null, stories: [], contests: [], - track: "standard", + matchMode: "quiz", videoCatalog: null, videos: [], videoAbility: "", @@ -446,12 +446,21 @@ async function loadContests() { function renderContests() { const root = $("#contest-list"); - const contests = state.contests.filter((contest) => contest.track === state.track); + const contests = ["realtime", "daily", "practice"] + .map((kind) => { + const candidates = state.contests.filter((contest) => contest.kind === kind); + return ( + candidates.find((contest) => contest.track === "open") || + candidates.find((contest) => contest.track === "standard") || + candidates[0] + ); + }) + .filter(Boolean); root.classList.remove("loading"); root.replaceChildren(...contests.map((contest) => card({ meta: contest.kind === "realtime" ? "实时竞技" : contest.kind === "daily" ? "今日挑战" : "自由练习", - title: contest.title, - body: contest.kind === "realtime" ? "Rating 匹配,同题序列,服务端计时和唯一结算。" : "完成整组题目,正式答案只在提交后显示。", + title: contest.title.replace(/^(入门|标准|进阶)/, ""), + body: contest.kind === "realtime" ? "统一玩家池,支持口算、数独与 24 点,服务端计时和 Rating 结算。" : "每次随机抽取题目,正式答案只在提交后显示。", foot: `${contest.duration_seconds} 秒`, action: contest.kind === "realtime" ? "开始匹配 →" : "开始答题 →", onClick: () => beginContest(contest), @@ -827,7 +836,7 @@ async function loadProfile() { profile.recent_games.forEach((game) => { const item = document.createElement("div"); const name = document.createElement("b"); - name.textContent = `${game.label} · ${game.difficulty}`; + name.textContent = game.label; const detail = document.createElement("span"); detail.textContent = game.status === "completed" @@ -1255,10 +1264,13 @@ function bindUI() { $("#register-form").classList.toggle("hidden", button.dataset.authTab !== "register"); $("#auth-error").textContent = ""; })); - $$("#track-switch button").forEach((button) => button.addEventListener("click", () => { - state.track = button.dataset.track; - $$("#track-switch button").forEach((item) => item.classList.toggle("active", item === button)); - renderContests(); + $$("#match-mode-switch button").forEach((button) => button.addEventListener("click", () => { + state.matchMode = button.dataset.matchMode; + $$("#match-mode-switch button").forEach((item) => { + item.classList.toggle("active", item === button); + }); + const label = button.querySelector("b").textContent; + $("#challenge-create").textContent = `创建${label}约战`; })); $("#login-form").addEventListener("submit", async (event) => { event.preventDefault(); diff --git a/backend/static/js/games.js b/backend/static/js/games.js index e5d5192..08863e2 100644 --- a/backend/static/js/games.js +++ b/backend/static/js/games.js @@ -14,23 +14,6 @@ }, }; - function difficultySelect() { - const select = document.createElement("select"); - select.className = "game-difficulty"; - [ - ["easy", "入门"], - ["standard", "标准"], - ["hard", "进阶"], - ].forEach(([value, label]) => { - const option = document.createElement("option"); - option.value = value; - option.textContent = label; - if (value === "standard") option.selected = true; - select.append(option); - }); - return select; - } - function gameCard(game) { const meta = GAME_META[game.kind]; const article = document.createElement("article"); @@ -50,12 +33,11 @@ summary.textContent = game.summary; const controls = document.createElement("div"); controls.className = "game-card-controls"; - const difficulty = difficultySelect(); const start = document.createElement("button"); start.className = "primary-button"; start.textContent = meta.action; - start.addEventListener("click", () => startGame(game.kind, difficulty.value)); - controls.append(difficulty, start); + start.addEventListener("click", () => startGame(game.kind)); + controls.append(start); article.append(top, kicker, title, summary, controls); return article; } @@ -70,12 +52,12 @@ } } - async function startGame(kind, difficulty) { + async function startGame(kind) { if (!requireAuth()) return; try { const attempt = await api(`contests/games/${kind}/start/`, { method: "POST", - body: { difficulty }, + body: {}, }); renderGame(attempt); $game("#experience-dialog").showModal(); @@ -88,8 +70,7 @@ const fragment = document.createDocumentFragment(); const label = document.createElement("span"); label.className = "kicker"; - label.textContent = - `${GAME_META[attempt.kind].kicker} · ${attempt.difficulty.toUpperCase()}`; + label.textContent = GAME_META[attempt.kind].kicker; const title = document.createElement("h2"); title.textContent = GAME_META[attempt.kind].title; fragment.append(label, title); diff --git a/backend/static/js/realtime.js b/backend/static/js/realtime.js index 230dff2..0eed0db 100644 --- a/backend/static/js/realtime.js +++ b/backend/static/js/realtime.js @@ -7,6 +7,11 @@ opponentProgress: 0, reconnectTimer: null, }; + const GAME_LABELS = { + quiz: "口算竞速", + sudoku: "数独 Timerun", + twenty_four: "24 点竞速", + }; function stopTimers() { if (realtime.pollTimer) window.clearInterval(realtime.pollTimer); @@ -90,7 +95,7 @@ match.opponent?.status && match.opponent.status !== "active" ) { - realtime.opponentProgress = match.attempt?.questions.length || 0; + realtime.opponentProgress = matchProgressTotal(match); updateProgressUI(); updateConnectionStatus("对手已提交,完成后将立即结算"); } @@ -130,7 +135,7 @@ const label = document.createElement("span"); label.className = "kicker"; label.textContent = - `${match.match_type === "challenge" ? "联机码约战" : "随机匹配"} · REALTIME`; + `${match.match_type === "challenge" ? "联机码约战" : "随机匹配"} · ${GAME_LABELS[match.game_kind] || "实时竞技"}`; const title = document.createElement("h2"); title.textContent = match.contest; const status = document.createElement("div"); @@ -182,7 +187,7 @@ message.textContent = match.match_type === "challenge" ? "把联机码发给朋友。对方加入后会自动开始,无需再次点击。" - : "正在寻找同赛道、相近 Rating 的玩家。"; + : `正在寻找${GAME_LABELS[match.game_kind] || "同玩法"}对手。`; panel.append(pulse, message); if (match.challenge_code) { const code = document.createElement("button"); @@ -217,30 +222,41 @@ updateClock(); } + function matchProgressTotal(match) { + return ( + match.game_kind === "sudoku" + ? 81 + : match.game_kind === "twenty_four" + ? 1 + : match.attempt?.questions.length || 0 + ); + } + function progressPanel(root, match) { + const total = matchProgressTotal(match); const panel = document.createElement("div"); panel.className = "realtime-progress-panel"; const self = document.createElement("div"); - self.innerHTML = `0 / ${match.attempt.questions.length}`; + self.innerHTML = `0 / ${total}`; const opponent = document.createElement("div"); opponent.innerHTML = `${match.opponent?.nickname || "对手"}` + - `${realtime.opponentProgress} / ${match.attempt.questions.length}`; + `${realtime.opponentProgress} / ${total}`; panel.append(self, opponent); root.append(panel); } function updateProgressUI(selfCount) { + const total = matchProgressTotal(realtime.match); if (Number.isInteger(selfCount)) { const self = document.querySelector("#self-progress"); if (self && realtime.match?.attempt) { - self.textContent = `${selfCount} / ${realtime.match.attempt.questions.length}`; + self.textContent = `${selfCount} / ${total}`; } } const opponent = document.querySelector("#opponent-progress"); if (opponent && realtime.match?.attempt) { - opponent.textContent = - `${realtime.opponentProgress} / ${realtime.match.attempt.questions.length}`; + opponent.textContent = `${realtime.opponentProgress} / ${total}`; } } @@ -252,6 +268,109 @@ } } + async function submitRealtimeGame(attempt, payload) { + realtime.match = await api( + `contests/games/attempts/${attempt.attempt_id}/submit/`, + { + method: "POST", + headers: { "Idempotency-Key": createIdempotencyKey() }, + body: payload, + }, + ); + renderMatch(); + } + + function renderTwentyFourGame(root, match) { + const attempt = match.attempt; + const numbers = document.createElement("div"); + numbers.className = "twenty-four-numbers"; + attempt.puzzle.numbers.forEach((number) => { + const tile = document.createElement("span"); + tile.textContent = number; + numbers.append(tile); + }); + const form = document.createElement("form"); + form.className = "twenty-four-form"; + const input = document.createElement("input"); + input.className = "formula-input"; + input.placeholder = "四个数字各用一次,例如:6/(1-3/4)"; + input.autocomplete = "off"; + input.addEventListener("input", () => { + sendProgress(input.value.trim() ? 1 : 0); + updateProgressUI(input.value.trim() ? 1 : 0); + }); + const error = document.createElement("p"); + error.className = "form-error"; + const submit = document.createElement("button"); + submit.className = "primary-button"; + submit.type = "submit"; + submit.textContent = "验证并锁定答案"; + form.append(input, error, submit); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + submit.disabled = true; + error.textContent = ""; + try { + await submitRealtimeGame(attempt, { expression: input.value }); + } catch (requestError) { + error.textContent = requestError.message; + submit.disabled = false; + } + }); + root.append(numbers, form); + } + + function renderSudokuGame(root, match) { + const attempt = match.attempt; + const board = document.createElement("div"); + board.className = "sudoku-board"; + attempt.puzzle.grid.forEach((rowValues, row) => { + rowValues.forEach((givenValue, column) => { + const input = document.createElement("input"); + input.inputMode = "numeric"; + input.pattern = "[1-9]"; + input.maxLength = 1; + input.dataset.row = row; + input.dataset.column = column; + input.value = givenValue || ""; + input.readOnly = Boolean(givenValue); + input.className = givenValue ? "given" : ""; + input.setAttribute("aria-label", `第 ${row + 1} 行第 ${column + 1} 列`); + input.addEventListener("input", () => { + input.value = input.value.replace(/[^1-9]/g, "").slice(0, 1); + const count = [...board.querySelectorAll("input")].filter( + (item) => item.value + ).length; + sendProgress(count); + updateProgressUI(count); + }); + board.append(input); + }); + }); + const error = document.createElement("p"); + error.className = "form-error"; + const submit = document.createElement("button"); + submit.className = "primary-button"; + submit.type = "button"; + submit.textContent = "检查并锁定数独"; + submit.addEventListener("click", async () => { + submit.disabled = true; + error.textContent = ""; + const grid = Array.from({ length: 9 }, () => Array(9).fill(0)); + board.querySelectorAll("input").forEach((input) => { + grid[Number(input.dataset.row)][Number(input.dataset.column)] = + Number(input.value || 0); + }); + try { + await submitRealtimeGame(attempt, { grid }); + } catch (requestError) { + error.textContent = requestError.message; + submit.disabled = false; + } + }); + root.append(board, error, submit); + } + function renderActive(root, match) { progressPanel(root, match); const attempt = match.attempt; @@ -264,6 +383,16 @@ updateClock(); return; } + if (match.game_kind === "twenty_four") { + renderTwentyFourGame(root, match); + updateClock(); + return; + } + if (match.game_kind === "sudoku") { + renderSudokuGame(root, match); + updateClock(); + return; + } const form = document.createElement("form"); form.className = "choice-list realtime-answer-form"; attempt.questions.forEach((question) => { @@ -328,7 +457,15 @@ : "平局"; const score = document.createElement("p"); score.textContent = - `你 ${match.attempt.score} 分 · ${match.opponent.score} 分 ${match.opponent.nickname}`; + match.game_kind === "quiz" + ? `你 ${match.attempt.score} 分 · ${match.opponent.score} 分 ${match.opponent.nickname}` + : `${GAME_LABELS[match.game_kind]} · ${ + match.attempt.status === "completed" ? "你已完成" : "你未完成" + } · ${ + match.opponent.status === "completed" + ? `${match.opponent.nickname} 已完成` + : `${match.opponent.nickname} 未完成` + }`; const timeLine = document.createElement("p"); timeLine.className = "realtime-time-line"; const selfMs = match.attempt.duration_ms || 0; @@ -336,7 +473,11 @@ const selfSec = (selfMs / 1000).toFixed(1); const opponentSec = (opponentMs / 1000).toFixed(1); timeLine.textContent = `你 ${selfSec}s · ${match.opponent.nickname} ${opponentSec}s`; - if (match.attempt.score === match.opponent.score && match.result.winner !== "draw") { + if ( + match.result.winner !== "draw" && + (match.game_kind !== "quiz" || + match.attempt.score === match.opponent.score) + ) { const faster = selfMs < opponentMs ? "你" : match.opponent.nickname; const tiebreak = document.createElement("small"); tiebreak.className = "realtime-tiebreak"; @@ -356,22 +497,24 @@ } loadUser(); - const review = document.createElement("div"); - review.className = "realtime-review"; - match.attempt.questions.forEach((question) => { - const item = document.createElement("article"); - const title = document.createElement("b"); - title.textContent = `${question.order}. ${question.prompt}`; - const answer = document.createElement("p"); - answer.textContent = - `你的答案:${question.submitted_answer || "未作答"} · 正确答案:${question.correct_answer}`; - const explanation = document.createElement("small"); - explanation.textContent = question.explanation || ""; - item.className = question.is_correct ? "correct" : "incorrect"; - item.append(title, answer, explanation); - review.append(item); - }); - root.append(review); + if (match.game_kind === "quiz") { + const review = document.createElement("div"); + review.className = "realtime-review"; + match.attempt.questions.forEach((question) => { + const item = document.createElement("article"); + const title = document.createElement("b"); + title.textContent = `${question.order}. ${question.prompt}`; + const answer = document.createElement("p"); + answer.textContent = + `你的答案:${question.submitted_answer || "未作答"} · 正确答案:${question.correct_answer}`; + const explanation = document.createElement("small"); + explanation.textContent = question.explanation || ""; + item.className = question.is_correct ? "correct" : "incorrect"; + item.append(title, answer, explanation); + review.append(item); + }); + root.append(review); + } } function renderMatch() { @@ -391,15 +534,18 @@ } function currentRealtimeContest() { - return state.contests.find( - (contest) => contest.kind === "realtime" && contest.track === state.track + const contests = state.contests.filter((contest) => contest.kind === "realtime"); + return ( + contests.find((contest) => contest.track === "open") || + contests.find((contest) => contest.track === "standard") || + contests[0] ); } async function startRandom(contest) { const match = await api(`contests/${contest.slug}/matchmaking/`, { method: "POST", - body: {}, + body: { game_kind: state.matchMode }, }); openMatch(match); } @@ -408,13 +554,13 @@ if (!requireAuth()) return; const contest = currentRealtimeContest(); if (!contest) { - showToast("当前赛道没有可用的实时比赛"); + showToast("当前没有可用的实时比赛"); return; } try { const match = await api(`contests/${contest.slug}/challenges/`, { method: "POST", - body: {}, + body: { game_kind: state.matchMode }, }); openMatch(match); } catch (error) { diff --git a/backend/templates/index.html b/backend/templates/index.html index 0711d38..7dafd08 100644 --- a/backend/templates/index.html +++ b/backend/templates/index.html @@ -175,9 +175,11 @@
-
CONTEST ARENA

比赛

实时 1v1、今日挑战和单人闯关,按水平赛道独立计算。

-
- +
CONTEST ARENA

比赛

不再按难度拆分玩家池。选择玩法后即可随机匹配或用联机码约战。

+
+ + +
@@ -186,7 +188,7 @@

创建 6 位联机码发给朋友,或输入朋友的联机码加入同一场对局。

- +