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
+1 -1
View File
@@ -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<CardDto>? 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(
+11 -9
View File
@@ -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,
@@ -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));
@@ -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