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")} + + ) : ( + + ))} +
+ ); + })} +
+
+ ); +} 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({ )} - 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;