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:
@@ -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],
|
||||
})}
|
||||
</p>
|
||||
<MatchPlayersList />
|
||||
|
||||
<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")}
|
||||
</button>
|
||||
</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 { 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({
|
||||
</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")}
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user