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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user