Forfeit = 2x coin loss + 0 XP (no kot); end-of-game roster + add friend
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 21s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m0s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 0s

Forfeit penalty reworked (client + server gamification, in sync):
- Surrendering team loses DOUBLE the entry coins; winner takes the stake.
- Forfeiter earns NO XP. No kot is applied or mentioned anymore.
- MatchSummary/Dto carry a `forfeit` flag; GameRoom.FinalizeForfeit →
  ApplyRewardsAsync(team) with Forfeit=true (dropped the kot path).
- Forfeit confirm dialogs now alert the real penalty (double coins, no XP).

End-of-game roster: SeatPlayerDto/ServerSeatPlayer + game-store SeatPlayer gain
userId/isBot. New <MatchPlayersList> lists everyone at the table on the final
screen (PostMatchRewardsModal + AI MatchOverlay) with a tactile "Add" button to
send a friend request to real (non-bot, non-self) players ("Sent" after).

Verified: tsc + sim + dotnet + next build clean; stack rebuilt :1500/:1505.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-05 10:40:14 +03:30
parent 6bbdbac23b
commit b739b503eb
13 changed files with 127 additions and 19 deletions
+4 -1
View File
@@ -23,6 +23,7 @@ import { getService } from "@/lib/online/service";
import { cn } from "@/lib/cn";
import { PlayingCard } from "./PlayingCard";
import { Sticker } from "./online/Sticker";
import { MatchPlayersList } from "./online/MatchPlayersList";
function useCountdown(deadline: number | null) {
const [now, setNow] = useState(() => Date.now());
@@ -887,8 +888,10 @@ function MatchOverlay({ onExit }: { onExit: () => void }) {
them: game.matchScore[1],
})}
</p>
<MatchPlayersList />
<div className="mt-7 flex gap-3">
<button onClick={onExit} className="btn-gold flex-1 rounded-xl py-3">
<button onClick={onExit} className="press-3d btn-gold flex-1 rounded-xl py-3">
{t("match.menu")}
</button>
</div>
@@ -0,0 +1,75 @@
"use client";
import { Check, UserPlus } from "lucide-react";
import { useState } from "react";
import { useGameStore } from "@/lib/game-store";
import { useSessionStore } from "@/lib/session-store";
import { getService } from "@/lib/online/service";
import { sound } from "@/lib/sound";
import { useI18n } from "@/lib/i18n";
/** Final-screen roster: lists everyone at the table and lets you friend real players. */
export function MatchPlayersList() {
const { t } = useI18n();
const seatPlayers = useGameStore((s) => s.seatPlayers);
const myId = useSessionStore((s) => s.profile?.id);
const [sent, setSent] = useState<Record<string, boolean>>({});
if (!seatPlayers.length) return null;
const add = async (id: string) => {
setSent((p) => ({ ...p, [id]: true }));
sound.play("click");
try {
await getService().addFriend(id);
} catch {
/* ignore — request is best-effort */
}
};
return (
<div className="mt-5 text-start">
<div className="text-[11px] font-bold text-cream/55 mb-2">{t("match.players")}</div>
<div className="space-y-1.5">
{seatPlayers.map((p, i) => {
const isMe = p.id ? p.id === myId : !p.isBot;
const canAdd = !!p.id && !p.isBot && p.id !== myId;
return (
<div key={i} className="flex items-center gap-2.5 glass rounded-xl px-2.5 py-1.5">
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-navy-900 text-lg">
{p.avatar}
</span>
<span className="flex-1 min-w-0">
<span className="block text-sm font-semibold text-cream truncate">
{p.name}
{isMe && <span className="text-gold-300 font-normal"> ({t("match.you")})</span>}
{p.isBot && <span className="text-cream/35 font-normal"> ({t("match.bot")})</span>}
</span>
{p.level > 0 && (
<span className="block text-[10px] text-cream/45">
{t("common.level")} {p.level}
</span>
)}
</span>
{canAdd &&
(sent[p.id!] ? (
<span className="text-[11px] text-teal-300 flex items-center gap-1 shrink-0">
<Check className="size-3.5" />
{t("match.sent")}
</span>
) : (
<button
onClick={() => add(p.id!)}
className="press-3d btn-gold rounded-lg px-2.5 py-1.5 text-[11px] font-bold flex items-center gap-1 shrink-0"
>
<UserPlus className="size-3.5" />
{t("match.addFriend")}
</button>
))}
</div>
);
})}
</div>
</div>
);
}
@@ -5,6 +5,7 @@ import { Coins, Sparkles, Star, TrendingDown, TrendingUp } from "lucide-react";
import { useEffect, useState } from "react";
import { useI18n } from "@/lib/i18n";
import { sound } from "@/lib/sound";
import { MatchPlayersList } from "./MatchPlayersList";
import { RewardResult } from "@/lib/online/types";
/** Animated count-up used for the coins-won hero. */
@@ -177,7 +178,9 @@ export function PostMatchRewardsModal({
</div>
)}
<button onClick={onClose} className="btn-gold w-full rounded-xl py-3 mt-6">
<MatchPlayersList />
<button onClick={onClose} className="press-3d btn-gold w-full rounded-xl py-3 mt-6">
{t("reward.continue")}
</button>
</motion.div>
+3 -3
View File
@@ -75,15 +75,15 @@ export function GameScreen() {
stake: meta.stake,
won: game.matchWinner === 0,
kotFor: tally.kotFor,
// Forfeiting with 0 rounds won = a Kot loss.
kotAgainst: tally.kotAgainst || (forfeited && game.matchScore[0] === 0),
kotAgainst: tally.kotAgainst,
tricksWon: tally.tricksTeam0,
rounds: game.matchScore[0] + game.matchScore[1],
trump: game.trump,
// shutout = you won and the opponent never scored a round (e.g. 70)
shutout: game.matchWinner === 0 && game.matchScore[1] === 0,
shutout: !forfeited && game.matchWinner === 0 && game.matchScore[1] === 0,
hakemRounds: tally.hakemRounds,
roundsWon: game.matchScore[0],
forfeit: forfeited,
};
getService()
.submitMatchResult(summary)
+4 -1
View File
@@ -40,6 +40,8 @@ export interface SeatPlayer {
name: string;
avatar: string; // emoji
level: number;
id?: string; // real player's user id (for add-friend); absent for bots/you
isBot?: boolean;
}
export interface GameSettings {
@@ -343,6 +345,7 @@ export const useGameStore = create<GameStore>((set, get) => {
name,
avatar: AI_AVATARS[i],
level: 0,
isBot: i > 0, // seat 0 is you
})),
});
scheduleAuto();
@@ -408,7 +411,7 @@ export const useGameStore = create<GameStore>((set, get) => {
const next = mapServerState(s);
const seatPlayers: SeatPlayer[] = [...s.seatPlayers]
.sort((a, b) => a.seat - b.seat)
.map((sp) => ({ name: sp.name, avatar: avatarEmoji(sp.avatar), level: sp.level }));
.map((sp) => ({ name: sp.name, avatar: avatarEmoji(sp.avatar), level: sp.level, id: sp.userId, isBot: sp.isBot }));
// accumulate the reward tally when the match score grows (a round ended)
const prevTotal = prev.matchScore[0] + prev.matchScore[1];
+12 -2
View File
@@ -47,9 +47,14 @@ const fa: Dict = {
"forfeit.title": "تسلیم",
"forfeit.ask": "از این بازی تسلیم می‌شوید؟",
"forfeit.teammateAsks": "{name} می‌خواهد تسلیم شود. موافقید؟",
"forfeit.rule": "اگر حتی یک دست برده باشید باخت عادی، وگرنه کُت می‌شوید.",
"forfeit.rule": "با تسلیم، دو برابر سکهٔ ورودی را از دست می‌دهید و هیچ امتیاز تجربه‌ای نمی‌گیرید.",
"forfeit.confirm": "تسلیم",
"forfeit.keepPlaying": "ادامه می‌دهم",
"match.players": "بازیکنان",
"match.you": "شما",
"match.bot": "ربات",
"match.addFriend": "افزودن",
"match.sent": "ارسال شد",
"seat.you": "شما",
"team.us": "ما",
@@ -314,9 +319,14 @@ const en: Dict = {
"forfeit.title": "Forfeit",
"forfeit.ask": "Surrender this match?",
"forfeit.teammateAsks": "{name} wants to forfeit. Agree?",
"forfeit.rule": "Win ≥1 round = normal loss, otherwise it's a Kot.",
"forfeit.rule": "Forfeiting costs double your entry coins and earns no XP.",
"forfeit.confirm": "Forfeit",
"forfeit.keepPlaying": "Keep playing",
"match.players": "Players",
"match.you": "You",
"match.bot": "Bot",
"match.addFriend": "Add",
"match.sent": "Sent",
"seat.you": "You",
"team.us": "Us",
+4
View File
@@ -111,6 +111,8 @@ export function ratingDelta(
export function coinDelta(summary: MatchSummary): number {
// Free games (vs computer / private friend rooms) never touch coins.
if (!summary.ranked) return 0;
// Forfeit: the surrendering team loses double the stake; the winner takes the stake.
if (summary.forfeit) return summary.won ? summary.stake : -2 * summary.stake;
// Ranked: win the stake (+kot bonus scaled to the league), lose the stake.
// Higher leagues stake more, so wins/losses swing bigger.
const kotBonus = summary.won && summary.kotFor ? Math.round(summary.stake * 0.4) : 0;
@@ -160,6 +162,8 @@ export function leagueXpFactor(stake: number): number {
export const PREMIUM_XP_MULT = 1.5;
export function matchXp(summary: MatchSummary): number {
// Forfeiting (surrendering) earns no XP.
if (summary.forfeit && !summary.won) return 0;
// Every game grants XP; the winner earns double.
const base = 40 + summary.tricksWon * 5 + (summary.kotFor ? 30 : 0);
return Math.round(base * (summary.won ? 2 : 1) * leagueXpFactor(summary.stake));
+2
View File
@@ -318,6 +318,7 @@ export interface MatchSummary {
shutout: boolean; // won with the opponent on 0 rounds (e.g. 70)
hakemRounds: number; // rounds you were hakem this match
roundsWon: number; // rounds (dast) your team won this match
forfeit: boolean; // the match ended by forfeit (loser: 2× coin loss, 0 XP)
}
/** A teammate's request to forfeit (surrender) the match. */
@@ -453,6 +454,7 @@ export interface ServerSeatPlayer {
level: number;
connected: boolean;
isBot: boolean;
userId?: string; // present for real players (for add-friend after the match)
}
export interface ServerRoundResult {
winningTeam: number;