diff --git a/scripts/sim.ts b/scripts/sim.ts
index 90dd8e6..cf3f3c7 100644
--- a/scripts/sim.ts
+++ b/scripts/sim.ts
@@ -126,6 +126,7 @@ for (let i = 0; i < M; i++) {
shutout: won && i % 8 === 0,
hakemRounds: i % 3,
roundsWon: won ? 7 : i % 7,
+ forfeit: false,
};
const before = profile;
const { profile: after, reward } = applyMatchResult(before, summary, 1000);
@@ -152,7 +153,7 @@ for (let i = 0; i < M; i++) {
{
const r = applyMatchResult(baseProfile(), {
ranked: false, stake: 0, won: true, kotFor: false, kotAgainst: false,
- tricksWon: 7, rounds: 7, trump: null, shutout: false, hakemRounds: 0, roundsWon: 7,
+ tricksWon: 7, rounds: 7, trump: null, shutout: false, hakemRounds: 0, roundsWon: 7, forfeit: false,
}, 1000);
assert(r.reward.ratingDelta === 0, "casual match must not change rating");
}
diff --git a/server/src/Hokm.Server/Game/Contracts.cs b/server/src/Hokm.Server/Game/Contracts.cs
index 34e323b..8623242 100644
--- a/server/src/Hokm.Server/Game/Contracts.cs
+++ b/server/src/Hokm.Server/Game/Contracts.cs
@@ -8,7 +8,7 @@ namespace Hokm.Server.Game;
public record CardDto(string Suit, int Rank, string Id);
public record PlayedCardDto(int Seat, CardDto Card);
public record PlayerDto(int Seat, string Name, int Team, bool IsHuman, int HandCount, List? Hand);
-public record SeatPlayerDto(int Seat, string Name, string Avatar, int Level, bool Connected, bool IsBot);
+public record SeatPlayerDto(int Seat, string Name, string Avatar, int Level, bool Connected, bool IsBot, string? UserId);
public record RoundResultDto(int WinningTeam, int[] Tricks, bool Kot, int Points);
public record GameStateDto(
diff --git a/server/src/Hokm.Server/Game/GameRoom.cs b/server/src/Hokm.Server/Game/GameRoom.cs
index 1c0e7b5..dab30b1 100644
--- a/server/src/Hokm.Server/Game/GameRoom.cs
+++ b/server/src/Hokm.Server/Game/GameRoom.cs
@@ -102,11 +102,12 @@ public sealed class GameRoom : IDisposable
if (rr.Kot) _tallyKot[rr.WinningTeam] = true;
}
- private async Task ApplyRewardsAsync(int? forfeitTeam = null, bool forfeitKot = false)
+ private async Task ApplyRewardsAsync(int? forfeitTeam = null)
{
if (_rewardsApplied) return;
_rewardsApplied = true;
- int winner = forfeitTeam.HasValue ? 1 - forfeitTeam.Value : (State.MatchWinner ?? 0);
+ bool forfeit = forfeitTeam.HasValue;
+ int winner = forfeit ? 1 - forfeitTeam!.Value : (State.MatchWinner ?? 0);
int rounds = State.MatchScore[0] + State.MatchScore[1];
foreach (var slot in Seats.Where(s => !s.IsBot && s.UserId != null))
{
@@ -117,12 +118,14 @@ public sealed class GameRoom : IDisposable
Ranked = Ranked,
Stake = Stake,
Won = won,
- // On a forfeit, the kot penalty is the surrendering team scoring 0 rounds.
- KotFor = forfeitTeam.HasValue ? (won && forfeitKot) : _tallyKot[team],
- KotAgainst = forfeitTeam.HasValue ? (!won && forfeitKot) : _tallyKot[1 - team],
+ // Forfeit penalty (2× coin loss, 0 XP) is handled in Gamification;
+ // no kot is applied on a forfeit.
+ Forfeit = forfeit,
+ KotFor = forfeit ? false : _tallyKot[team],
+ KotAgainst = forfeit ? false : _tallyKot[1 - team],
TricksWon = _tallyTricks[team],
Rounds = rounds,
- Shutout = won && State.MatchScore[1 - winner] == 0,
+ Shutout = !forfeit && won && State.MatchScore[1 - winner] == 0,
HakemRounds = _hakemRounds[slot.Seat],
RoundsWon = State.MatchScore[team],
};
@@ -250,11 +253,10 @@ public sealed class GameRoom : IDisposable
_forfeitPendingTeam = null;
_forfeitRequester = null;
_forfeitTimer?.Dispose();
- bool kot = State.MatchScore[team] == 0;
State.MatchWinner = 1 - team;
State.Phase = Phase.MatchOver;
_timer?.Dispose();
- _ = ApplyRewardsAsync(team, kot);
+ _ = ApplyRewardsAsync(team); // forfeit = 2× coin loss + 0 XP (no kot)
BroadcastState();
if (!_finished) { _finished = true; OnFinished?.Invoke(this); }
}
@@ -388,7 +390,7 @@ public sealed class GameRoom : IDisposable
p.Seat == viewerSeat ? p.Hand.Select(Map.Card).ToList() : null)).ToList();
var seatPlayers = Seats.OrderBy(s => s.Seat)
- .Select(s => new SeatPlayerDto(s.Seat, s.Name, s.Avatar, s.Level, s.Connected, s.IsBot)).ToList();
+ .Select(s => new SeatPlayerDto(s.Seat, s.Name, s.Avatar, s.Level, s.Connected, s.IsBot, s.IsBot ? null : s.UserId)).ToList();
RoundResultDto? rr = State.LastRoundResult is null ? null
: new RoundResultDto(State.LastRoundResult.WinningTeam, State.LastRoundResult.Tricks,
diff --git a/server/src/Hokm.Server/Profiles/Gamification.cs b/server/src/Hokm.Server/Profiles/Gamification.cs
index caac5ea..72091b7 100644
--- a/server/src/Hokm.Server/Profiles/Gamification.cs
+++ b/server/src/Hokm.Server/Profiles/Gamification.cs
@@ -33,6 +33,8 @@ public static class Gamification
public static int CoinDelta(MatchSummaryDto s)
{
if (!s.Ranked) return 0;
+ // Forfeit: the surrendering team loses double the stake; the winner takes the stake.
+ if (s.Forfeit) return s.Won ? s.Stake : -2 * s.Stake;
// Kot bonus scales with the league stake (mirrors gamification.ts).
int kot = s.Won && s.KotFor ? (int)Math.Round(s.Stake * 0.4) : 0;
return (s.Won ? s.Stake : -s.Stake) + kot;
@@ -44,6 +46,8 @@ public static class Gamification
public const double PremiumXpMult = 1.5;
public static int MatchXp(MatchSummaryDto s)
{
+ // Forfeiting (surrendering) earns no XP.
+ if (s.Forfeit && !s.Won) return 0;
// Every game grants XP; the winner earns double.
int b = 40 + s.TricksWon * 5 + (s.KotFor ? 30 : 0);
return (int)Math.Round(b * (s.Won ? 2 : 1) * LeagueXpFactor(s.Stake));
diff --git a/server/src/Hokm.Server/Profiles/ProfileModels.cs b/server/src/Hokm.Server/Profiles/ProfileModels.cs
index 789eab3..9477ccd 100644
--- a/server/src/Hokm.Server/Profiles/ProfileModels.cs
+++ b/server/src/Hokm.Server/Profiles/ProfileModels.cs
@@ -65,6 +65,7 @@ public class MatchSummaryDto
public bool Shutout { get; set; }
public int HakemRounds { get; set; }
public int RoundsWon { get; set; }
+ public bool Forfeit { get; set; }
}
public class AchievementUnlockDto
diff --git a/src/components/GameTable.tsx b/src/components/GameTable.tsx
index c324ffc..0fa49f4 100644
--- a/src/components/GameTable.tsx
+++ b/src/components/GameTable.tsx
@@ -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],
})}
+
+
-
diff --git a/src/components/online/MatchPlayersList.tsx b/src/components/online/MatchPlayersList.tsx
new file mode 100644
index 0000000..abf0fdf
--- /dev/null
+++ b/src/components/online/MatchPlayersList.tsx
@@ -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>({});
+
+ 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 (
+
+
{t("match.players")}
+
+ {seatPlayers.map((p, i) => {
+ const isMe = p.id ? p.id === myId : !p.isBot;
+ const canAdd = !!p.id && !p.isBot && p.id !== myId;
+ return (
+
+
+ {p.avatar}
+
+
+
+ {p.name}
+ {isMe && ({t("match.you")})}
+ {p.isBot && ({t("match.bot")})}
+
+ {p.level > 0 && (
+
+ {t("common.level")} {p.level}
+
+ )}
+
+ {canAdd &&
+ (sent[p.id!] ? (
+
+
+ {t("match.sent")}
+
+ ) : (
+ 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"
+ >
+
+ {t("match.addFriend")}
+
+ ))}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/online/PostMatchRewardsModal.tsx b/src/components/online/PostMatchRewardsModal.tsx
index 4608ec5..b4a583f 100644
--- a/src/components/online/PostMatchRewardsModal.tsx
+++ b/src/components/online/PostMatchRewardsModal.tsx
@@ -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({
)}
-
+
+
+
{t("reward.continue")}
diff --git a/src/components/screens/GameScreen.tsx b/src/components/screens/GameScreen.tsx
index 841d037..d619082 100644
--- a/src/components/screens/GameScreen.tsx
+++ b/src/components/screens/GameScreen.tsx
@@ -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. 7–0)
- 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)
diff --git a/src/lib/game-store.ts b/src/lib/game-store.ts
index 238922c..6d78e34 100644
--- a/src/lib/game-store.ts
+++ b/src/lib/game-store.ts
@@ -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((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((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];
diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx
index b0f6617..844d0c3 100644
--- a/src/lib/i18n.tsx
+++ b/src/lib/i18n.tsx
@@ -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",
diff --git a/src/lib/online/gamification.ts b/src/lib/online/gamification.ts
index a067222..397440f 100644
--- a/src/lib/online/gamification.ts
+++ b/src/lib/online/gamification.ts
@@ -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));
diff --git a/src/lib/online/types.ts b/src/lib/online/types.ts
index c74c42b..ec83d0c 100644
--- a/src/lib/online/types.ts
+++ b/src/lib/online/types.ts
@@ -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;