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:
+2
-1
@@ -126,6 +126,7 @@ for (let i = 0; i < M; i++) {
|
|||||||
shutout: won && i % 8 === 0,
|
shutout: won && i % 8 === 0,
|
||||||
hakemRounds: i % 3,
|
hakemRounds: i % 3,
|
||||||
roundsWon: won ? 7 : i % 7,
|
roundsWon: won ? 7 : i % 7,
|
||||||
|
forfeit: false,
|
||||||
};
|
};
|
||||||
const before = profile;
|
const before = profile;
|
||||||
const { profile: after, reward } = applyMatchResult(before, summary, 1000);
|
const { profile: after, reward } = applyMatchResult(before, summary, 1000);
|
||||||
@@ -152,7 +153,7 @@ for (let i = 0; i < M; i++) {
|
|||||||
{
|
{
|
||||||
const r = applyMatchResult(baseProfile(), {
|
const r = applyMatchResult(baseProfile(), {
|
||||||
ranked: false, stake: 0, won: true, kotFor: false, kotAgainst: false,
|
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);
|
}, 1000);
|
||||||
assert(r.reward.ratingDelta === 0, "casual match must not change rating");
|
assert(r.reward.ratingDelta === 0, "casual match must not change rating");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace Hokm.Server.Game;
|
|||||||
public record CardDto(string Suit, int Rank, string Id);
|
public record CardDto(string Suit, int Rank, string Id);
|
||||||
public record PlayedCardDto(int Seat, CardDto Card);
|
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 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 RoundResultDto(int WinningTeam, int[] Tricks, bool Kot, int Points);
|
||||||
|
|
||||||
public record GameStateDto(
|
public record GameStateDto(
|
||||||
|
|||||||
@@ -102,11 +102,12 @@ public sealed class GameRoom : IDisposable
|
|||||||
if (rr.Kot) _tallyKot[rr.WinningTeam] = true;
|
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;
|
if (_rewardsApplied) return;
|
||||||
_rewardsApplied = true;
|
_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];
|
int rounds = State.MatchScore[0] + State.MatchScore[1];
|
||||||
foreach (var slot in Seats.Where(s => !s.IsBot && s.UserId != null))
|
foreach (var slot in Seats.Where(s => !s.IsBot && s.UserId != null))
|
||||||
{
|
{
|
||||||
@@ -117,12 +118,14 @@ public sealed class GameRoom : IDisposable
|
|||||||
Ranked = Ranked,
|
Ranked = Ranked,
|
||||||
Stake = Stake,
|
Stake = Stake,
|
||||||
Won = won,
|
Won = won,
|
||||||
// On a forfeit, the kot penalty is the surrendering team scoring 0 rounds.
|
// Forfeit penalty (2× coin loss, 0 XP) is handled in Gamification;
|
||||||
KotFor = forfeitTeam.HasValue ? (won && forfeitKot) : _tallyKot[team],
|
// no kot is applied on a forfeit.
|
||||||
KotAgainst = forfeitTeam.HasValue ? (!won && forfeitKot) : _tallyKot[1 - team],
|
Forfeit = forfeit,
|
||||||
|
KotFor = forfeit ? false : _tallyKot[team],
|
||||||
|
KotAgainst = forfeit ? false : _tallyKot[1 - team],
|
||||||
TricksWon = _tallyTricks[team],
|
TricksWon = _tallyTricks[team],
|
||||||
Rounds = rounds,
|
Rounds = rounds,
|
||||||
Shutout = won && State.MatchScore[1 - winner] == 0,
|
Shutout = !forfeit && won && State.MatchScore[1 - winner] == 0,
|
||||||
HakemRounds = _hakemRounds[slot.Seat],
|
HakemRounds = _hakemRounds[slot.Seat],
|
||||||
RoundsWon = State.MatchScore[team],
|
RoundsWon = State.MatchScore[team],
|
||||||
};
|
};
|
||||||
@@ -250,11 +253,10 @@ public sealed class GameRoom : IDisposable
|
|||||||
_forfeitPendingTeam = null;
|
_forfeitPendingTeam = null;
|
||||||
_forfeitRequester = null;
|
_forfeitRequester = null;
|
||||||
_forfeitTimer?.Dispose();
|
_forfeitTimer?.Dispose();
|
||||||
bool kot = State.MatchScore[team] == 0;
|
|
||||||
State.MatchWinner = 1 - team;
|
State.MatchWinner = 1 - team;
|
||||||
State.Phase = Phase.MatchOver;
|
State.Phase = Phase.MatchOver;
|
||||||
_timer?.Dispose();
|
_timer?.Dispose();
|
||||||
_ = ApplyRewardsAsync(team, kot);
|
_ = ApplyRewardsAsync(team); // forfeit = 2× coin loss + 0 XP (no kot)
|
||||||
BroadcastState();
|
BroadcastState();
|
||||||
if (!_finished) { _finished = true; OnFinished?.Invoke(this); }
|
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();
|
p.Seat == viewerSeat ? p.Hand.Select(Map.Card).ToList() : null)).ToList();
|
||||||
|
|
||||||
var seatPlayers = Seats.OrderBy(s => s.Seat)
|
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
|
RoundResultDto? rr = State.LastRoundResult is null ? null
|
||||||
: new RoundResultDto(State.LastRoundResult.WinningTeam, State.LastRoundResult.Tricks,
|
: new RoundResultDto(State.LastRoundResult.WinningTeam, State.LastRoundResult.Tricks,
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ public static class Gamification
|
|||||||
public static int CoinDelta(MatchSummaryDto s)
|
public static int CoinDelta(MatchSummaryDto s)
|
||||||
{
|
{
|
||||||
if (!s.Ranked) return 0;
|
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).
|
// Kot bonus scales with the league stake (mirrors gamification.ts).
|
||||||
int kot = s.Won && s.KotFor ? (int)Math.Round(s.Stake * 0.4) : 0;
|
int kot = s.Won && s.KotFor ? (int)Math.Round(s.Stake * 0.4) : 0;
|
||||||
return (s.Won ? s.Stake : -s.Stake) + kot;
|
return (s.Won ? s.Stake : -s.Stake) + kot;
|
||||||
@@ -44,6 +46,8 @@ public static class Gamification
|
|||||||
public const double PremiumXpMult = 1.5;
|
public const double PremiumXpMult = 1.5;
|
||||||
public static int MatchXp(MatchSummaryDto s)
|
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.
|
// Every game grants XP; the winner earns double.
|
||||||
int b = 40 + s.TricksWon * 5 + (s.KotFor ? 30 : 0);
|
int b = 40 + s.TricksWon * 5 + (s.KotFor ? 30 : 0);
|
||||||
return (int)Math.Round(b * (s.Won ? 2 : 1) * LeagueXpFactor(s.Stake));
|
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 bool Shutout { get; set; }
|
||||||
public int HakemRounds { get; set; }
|
public int HakemRounds { get; set; }
|
||||||
public int RoundsWon { get; set; }
|
public int RoundsWon { get; set; }
|
||||||
|
public bool Forfeit { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AchievementUnlockDto
|
public class AchievementUnlockDto
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { getService } from "@/lib/online/service";
|
|||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { PlayingCard } from "./PlayingCard";
|
import { PlayingCard } from "./PlayingCard";
|
||||||
import { Sticker } from "./online/Sticker";
|
import { Sticker } from "./online/Sticker";
|
||||||
|
import { MatchPlayersList } from "./online/MatchPlayersList";
|
||||||
|
|
||||||
function useCountdown(deadline: number | null) {
|
function useCountdown(deadline: number | null) {
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
@@ -887,8 +888,10 @@ function MatchOverlay({ onExit }: { onExit: () => void }) {
|
|||||||
them: game.matchScore[1],
|
them: game.matchScore[1],
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
<MatchPlayersList />
|
||||||
|
|
||||||
<div className="mt-7 flex gap-3">
|
<div className="mt-7 flex gap-3">
|
||||||
<button onClick={onExit} className="btn-gold flex-1 rounded-xl py-3">
|
<button onClick={onExit} className="press-3d btn-gold flex-1 rounded-xl py-3">
|
||||||
{t("match.menu")}
|
{t("match.menu")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="mt-5 text-start">
|
||||||
|
<div className="text-[11px] font-bold text-cream/55 mb-2">{t("match.players")}</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{seatPlayers.map((p, i) => {
|
||||||
|
const isMe = p.id ? p.id === myId : !p.isBot;
|
||||||
|
const canAdd = !!p.id && !p.isBot && p.id !== myId;
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center gap-2.5 glass rounded-xl px-2.5 py-1.5">
|
||||||
|
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-navy-900 text-lg">
|
||||||
|
{p.avatar}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span className="block text-sm font-semibold text-cream truncate">
|
||||||
|
{p.name}
|
||||||
|
{isMe && <span className="text-gold-300 font-normal"> ({t("match.you")})</span>}
|
||||||
|
{p.isBot && <span className="text-cream/35 font-normal"> ({t("match.bot")})</span>}
|
||||||
|
</span>
|
||||||
|
{p.level > 0 && (
|
||||||
|
<span className="block text-[10px] text-cream/45">
|
||||||
|
{t("common.level")} {p.level}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{canAdd &&
|
||||||
|
(sent[p.id!] ? (
|
||||||
|
<span className="text-[11px] text-teal-300 flex items-center gap-1 shrink-0">
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
{t("match.sent")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => 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"
|
||||||
|
>
|
||||||
|
<UserPlus className="size-3.5" />
|
||||||
|
{t("match.addFriend")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Coins, Sparkles, Star, TrendingDown, TrendingUp } from "lucide-react";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { sound } from "@/lib/sound";
|
import { sound } from "@/lib/sound";
|
||||||
|
import { MatchPlayersList } from "./MatchPlayersList";
|
||||||
import { RewardResult } from "@/lib/online/types";
|
import { RewardResult } from "@/lib/online/types";
|
||||||
|
|
||||||
/** Animated count-up used for the coins-won hero. */
|
/** Animated count-up used for the coins-won hero. */
|
||||||
@@ -177,7 +178,9 @@ export function PostMatchRewardsModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button onClick={onClose} className="btn-gold w-full rounded-xl py-3 mt-6">
|
<MatchPlayersList />
|
||||||
|
|
||||||
|
<button onClick={onClose} className="press-3d btn-gold w-full rounded-xl py-3 mt-6">
|
||||||
{t("reward.continue")}
|
{t("reward.continue")}
|
||||||
</button>
|
</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -75,15 +75,15 @@ export function GameScreen() {
|
|||||||
stake: meta.stake,
|
stake: meta.stake,
|
||||||
won: game.matchWinner === 0,
|
won: game.matchWinner === 0,
|
||||||
kotFor: tally.kotFor,
|
kotFor: tally.kotFor,
|
||||||
// Forfeiting with 0 rounds won = a Kot loss.
|
kotAgainst: tally.kotAgainst,
|
||||||
kotAgainst: tally.kotAgainst || (forfeited && game.matchScore[0] === 0),
|
|
||||||
tricksWon: tally.tricksTeam0,
|
tricksWon: tally.tricksTeam0,
|
||||||
rounds: game.matchScore[0] + game.matchScore[1],
|
rounds: game.matchScore[0] + game.matchScore[1],
|
||||||
trump: game.trump,
|
trump: game.trump,
|
||||||
// shutout = you won and the opponent never scored a round (e.g. 7–0)
|
// 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,
|
hakemRounds: tally.hakemRounds,
|
||||||
roundsWon: game.matchScore[0],
|
roundsWon: game.matchScore[0],
|
||||||
|
forfeit: forfeited,
|
||||||
};
|
};
|
||||||
getService()
|
getService()
|
||||||
.submitMatchResult(summary)
|
.submitMatchResult(summary)
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export interface SeatPlayer {
|
|||||||
name: string;
|
name: string;
|
||||||
avatar: string; // emoji
|
avatar: string; // emoji
|
||||||
level: number;
|
level: number;
|
||||||
|
id?: string; // real player's user id (for add-friend); absent for bots/you
|
||||||
|
isBot?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameSettings {
|
export interface GameSettings {
|
||||||
@@ -343,6 +345,7 @@ export const useGameStore = create<GameStore>((set, get) => {
|
|||||||
name,
|
name,
|
||||||
avatar: AI_AVATARS[i],
|
avatar: AI_AVATARS[i],
|
||||||
level: 0,
|
level: 0,
|
||||||
|
isBot: i > 0, // seat 0 is you
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
scheduleAuto();
|
scheduleAuto();
|
||||||
@@ -408,7 +411,7 @@ export const useGameStore = create<GameStore>((set, get) => {
|
|||||||
const next = mapServerState(s);
|
const next = mapServerState(s);
|
||||||
const seatPlayers: SeatPlayer[] = [...s.seatPlayers]
|
const seatPlayers: SeatPlayer[] = [...s.seatPlayers]
|
||||||
.sort((a, b) => a.seat - b.seat)
|
.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)
|
// accumulate the reward tally when the match score grows (a round ended)
|
||||||
const prevTotal = prev.matchScore[0] + prev.matchScore[1];
|
const prevTotal = prev.matchScore[0] + prev.matchScore[1];
|
||||||
|
|||||||
+12
-2
@@ -47,9 +47,14 @@ const fa: Dict = {
|
|||||||
"forfeit.title": "تسلیم",
|
"forfeit.title": "تسلیم",
|
||||||
"forfeit.ask": "از این بازی تسلیم میشوید؟",
|
"forfeit.ask": "از این بازی تسلیم میشوید؟",
|
||||||
"forfeit.teammateAsks": "{name} میخواهد تسلیم شود. موافقید؟",
|
"forfeit.teammateAsks": "{name} میخواهد تسلیم شود. موافقید؟",
|
||||||
"forfeit.rule": "اگر حتی یک دست برده باشید باخت عادی، وگرنه کُت میشوید.",
|
"forfeit.rule": "با تسلیم، دو برابر سکهٔ ورودی را از دست میدهید و هیچ امتیاز تجربهای نمیگیرید.",
|
||||||
"forfeit.confirm": "تسلیم",
|
"forfeit.confirm": "تسلیم",
|
||||||
"forfeit.keepPlaying": "ادامه میدهم",
|
"forfeit.keepPlaying": "ادامه میدهم",
|
||||||
|
"match.players": "بازیکنان",
|
||||||
|
"match.you": "شما",
|
||||||
|
"match.bot": "ربات",
|
||||||
|
"match.addFriend": "افزودن",
|
||||||
|
"match.sent": "ارسال شد",
|
||||||
|
|
||||||
"seat.you": "شما",
|
"seat.you": "شما",
|
||||||
"team.us": "ما",
|
"team.us": "ما",
|
||||||
@@ -314,9 +319,14 @@ const en: Dict = {
|
|||||||
"forfeit.title": "Forfeit",
|
"forfeit.title": "Forfeit",
|
||||||
"forfeit.ask": "Surrender this match?",
|
"forfeit.ask": "Surrender this match?",
|
||||||
"forfeit.teammateAsks": "{name} wants to forfeit. Agree?",
|
"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.confirm": "Forfeit",
|
||||||
"forfeit.keepPlaying": "Keep playing",
|
"forfeit.keepPlaying": "Keep playing",
|
||||||
|
"match.players": "Players",
|
||||||
|
"match.you": "You",
|
||||||
|
"match.bot": "Bot",
|
||||||
|
"match.addFriend": "Add",
|
||||||
|
"match.sent": "Sent",
|
||||||
|
|
||||||
"seat.you": "You",
|
"seat.you": "You",
|
||||||
"team.us": "Us",
|
"team.us": "Us",
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ export function ratingDelta(
|
|||||||
export function coinDelta(summary: MatchSummary): number {
|
export function coinDelta(summary: MatchSummary): number {
|
||||||
// Free games (vs computer / private friend rooms) never touch coins.
|
// Free games (vs computer / private friend rooms) never touch coins.
|
||||||
if (!summary.ranked) return 0;
|
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.
|
// Ranked: win the stake (+kot bonus scaled to the league), lose the stake.
|
||||||
// Higher leagues stake more, so wins/losses swing bigger.
|
// Higher leagues stake more, so wins/losses swing bigger.
|
||||||
const kotBonus = summary.won && summary.kotFor ? Math.round(summary.stake * 0.4) : 0;
|
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 const PREMIUM_XP_MULT = 1.5;
|
||||||
|
|
||||||
export function matchXp(summary: MatchSummary): number {
|
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.
|
// Every game grants XP; the winner earns double.
|
||||||
const base = 40 + summary.tricksWon * 5 + (summary.kotFor ? 30 : 0);
|
const base = 40 + summary.tricksWon * 5 + (summary.kotFor ? 30 : 0);
|
||||||
return Math.round(base * (summary.won ? 2 : 1) * leagueXpFactor(summary.stake));
|
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)
|
shutout: boolean; // won with the opponent on 0 rounds (e.g. 7–0)
|
||||||
hakemRounds: number; // rounds you were hakem this match
|
hakemRounds: number; // rounds you were hakem this match
|
||||||
roundsWon: number; // rounds (dast) your team won 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. */
|
/** A teammate's request to forfeit (surrender) the match. */
|
||||||
@@ -453,6 +454,7 @@ export interface ServerSeatPlayer {
|
|||||||
level: number;
|
level: number;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
isBot: boolean;
|
isBot: boolean;
|
||||||
|
userId?: string; // present for real players (for add-friend after the match)
|
||||||
}
|
}
|
||||||
export interface ServerRoundResult {
|
export interface ServerRoundResult {
|
||||||
winningTeam: number;
|
winningTeam: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user