Forfeit = 2x coin loss + 0 XP (no kot); end-of-game roster + add friend
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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -318,6 +318,7 @@ export interface MatchSummary {
|
||||
shutout: boolean; // won with the opponent on 0 rounds (e.g. 7–0)
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user