Ajoute les jeux "6 qui prend !" et "Moustache"
Build and Publish Docker Image / build-and-push-image (push) Failing after 42s

- 6 qui prend ! : 2-10 joueurs, score négatif, fin dès qu'un joueur
  atteint 66 points, simple addition
- Moustache : 3-6 joueurs, le plus de points gagne

Moustache introduit une nouvelle mécanique dans le moteur : les équipes
étant retirées au sort à chaque manche, on note seulement l'équipe
gagnante des 4 premières manches (GameConfig.teams + teamRounds,
Round.winningTeamId), puis la dernière manche sert au décompte final des
points de chaque joueur.

- PlayGame : sélecteur d'équipe (pas de saisie de score), validation
  bloquée sans choix, compteur de manches gagnées par équipe
- GameOver : les manches d'équipe s'affichent avec un badge coloré au
  lieu d'une rangée de zéros

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Zed
2026-08-14 16:24:14 +02:00
parent 0a963d991b
commit 8ea0c5f676
12 changed files with 224 additions and 15 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+4
View File
@@ -8,7 +8,9 @@ import { azulConfig } from "./azul/config";
import { caboConfig } from "./cabo/config"; import { caboConfig } from "./cabo/config";
import { chouineursConfig } from "./chouineurs/config"; import { chouineursConfig } from "./chouineurs/config";
import { harmonieConfig } from "./harmonie/config"; import { harmonieConfig } from "./harmonie/config";
import { moustacheConfig } from "./moustache/config";
import { odinCardsConfig } from "./odin_cards/config"; import { odinCardsConfig } from "./odin_cards/config";
import { sixQuiPrendConfig } from "./six_qui_prend/config";
import { skyjoConfig } from "./skyjo/config"; import { skyjoConfig } from "./skyjo/config";
// Built-in games (compiled into the app). // Built-in games (compiled into the app).
@@ -19,6 +21,8 @@ export const games: GameConfig[] = [
harmonieConfig, harmonieConfig,
odinCardsConfig, odinCardsConfig,
chouineursConfig, chouineursConfig,
sixQuiPrendConfig,
moustacheConfig,
]; ];
// Imported games for the active profile, kept in sync in-memory so the // Imported games for the active profile, kept in sync in-memory so the
+28
View File
@@ -0,0 +1,28 @@
import { GameConfig } from "../../types";
// Les équipes sont retirées au sort à chaque manche : on ne note donc que
// l'équipe gagnante des 4 manches, puis chacun révèle ses points au décompte
// final (5e et dernière manche). Le plus de points l'emporte.
export const moustacheConfig: GameConfig = {
imagePath: "/games-assets/moustache-logo.webp",
bannerPath: "/games-assets/moustache-banner.webp",
id: "moustache",
name: "Moustache",
minPlayers: 3,
maxPlayers: 6,
scoreType: "positive",
teamRounds: 4,
fixedRounds: 5, // 4 manches en équipes + le décompte final
teams: [
{ id: "bleu", label: "Équipe bleue", color: "#2563eb" },
{ id: "rouge", label: "Équipe rouge", color: "#dc2626" },
],
description:
"Les équipes changent à chaque manche : notez l'équipe gagnante des 4 manches, puis chacun révèle ses points. Le plus de points gagne.",
icon: "Drama",
colors: {
// Le rose de la moustache du morse : distinctif, et sans confusion
// possible avec le bleu / rouge des équipes.
primary: "#e5709b",
},
};
+19
View File
@@ -0,0 +1,19 @@
import { GameConfig } from "../../types";
export const sixQuiPrendConfig: GameConfig = {
imagePath: "/games-assets/six-qui-prend-logo.webp",
bannerPath: "/games-assets/six-qui-prend-banner.webp",
id: "six_qui_prend",
name: "6 qui prend !",
minPlayers: 2,
maxPlayers: 10,
scoreType: "negative",
targetScore: 66,
targetScoreCondition: "reaches", // la partie s'arrête dès qu'un joueur atteint 66
description:
"Évitez de ramasser les têtes de bœuf ! La partie s'arrête quand un joueur atteint 66 points. Le plus petit score gagne.",
icon: "Beef",
colors: {
primary: "#d81f26", // Rouge vif de la boite
},
};
+34 -2
View File
@@ -313,7 +313,38 @@ export default function GameOver() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{session.rounds.map((round) => ( {session.rounds.map((round) => {
// Manche jouée en équipes : une seule ligne avec l'équipe
// gagnante, plutôt qu'une rangée de zéros.
const team = round.winningTeamId
? gameConfig?.teams?.find(
(t) => t.id === round.winningTeamId,
)
: undefined;
if (team) {
return (
<tr
key={round.id}
className="border-b border-border/30 last:border-0"
>
<td className="px-3 py-2 font-bold text-muted-foreground sticky left-0 bg-card z-10">
#{round.roundNumber}
</td>
<td
colSpan={session.players.length}
className="px-3 py-2 text-center"
>
<span
className="px-2.5 py-1 rounded-full text-white text-xs font-black"
style={{ backgroundColor: team.color }}
>
{team.label}
</span>
</td>
</tr>
);
}
return (
<tr <tr
key={round.id} key={round.id}
className="border-b border-border/30 last:border-0" className="border-b border-border/30 last:border-0"
@@ -350,7 +381,8 @@ export default function GameOver() {
); );
})} })}
</tr> </tr>
))} );
})}
<tr className="bg-secondary/50 font-black"> <tr className="bg-secondary/50 font-black">
<td className="px-3 py-2.5 sticky left-0 bg-secondary/50 z-10"> <td className="px-3 py-2.5 sticky left-0 bg-secondary/50 z-10">
Total Total
+117 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { Check, Undo2, Award, Trash2, BookOpen } from "lucide-react"; import { Check, Undo2, Award, Trash2, BookOpen, Users } from "lucide-react";
import * as Icons from "lucide-react"; import * as Icons from "lucide-react";
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../database/db"; import { db } from "../database/db";
@@ -32,6 +32,7 @@ export default function PlayGame() {
>({}); >({});
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0); const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
// Keep active session players updated if a global player rename happens // Keep active session players updated if a global player rename happens
const playersInDb = useLiveQuery(() => db.players.toArray()); const playersInDb = useLiveQuery(() => db.players.toArray());
@@ -103,6 +104,17 @@ export default function PlayGame() {
return dbPlayer ? { ...p, name: dbPlayer.name } : p; return dbPlayer ? { ...p, name: dbPlayer.name } : p;
}); });
// Jeux en équipes (ex: Moustache) : les premières manches ne notent que
// l'équipe gagnante, la dernière sert au décompte final des points.
const teams = gameConfig.teams;
const isTeamGame = Boolean(teams?.length && gameConfig.teamRounds);
const isTeamRound =
isTeamGame && activeSession.rounds.length < (gameConfig.teamRounds ?? 0);
const isFinalTally = isTeamGame && !isTeamRound;
const teamWins = (teamId: string) =>
activeSession.rounds.filter((r) => r.winningTeamId === teamId).length;
const calculateTotalScore = (playerId: string) => { const calculateTotalScore = (playerId: string) => {
return calculatePlayerTotalScore( return calculatePlayerTotalScore(
playerId, playerId,
@@ -146,6 +158,17 @@ export default function PlayGame() {
}; };
const handleValidateRound = async () => { const handleValidateRound = async () => {
// Manche jouée en équipes : on n'enregistre que l'équipe gagnante.
if (isTeamRound) {
if (!selectedTeamId) return;
await addRound(
activeSession.players.map((p) => ({ playerId: p.id, score: 0 })),
selectedTeamId,
);
setSelectedTeamId(null);
return;
}
const scores: RoundScore[] = activeSession.players.map((p) => { const scores: RoundScore[] = activeSession.players.map((p) => {
// For games with categories (like Harmonies), we store the sum as the main score // For games with categories (like Harmonies), we store the sum as the main score
// and keep the details in the `details` object // and keep the details in the `details` object
@@ -305,8 +328,30 @@ export default function PlayGame() {
</div> </div>
<main className="flex-1 p-4 space-y-8 mt-2"> <main className="flex-1 p-4 space-y-8 mt-2">
{/* Manches gagnées par équipe (les points restent secrets jusqu'au bout) */}
{isTeamGame && teams && (
<div className="flex items-center justify-center gap-3">
{teams.map((team) => (
<div
key={team.id}
className="flex-1 rounded-[1.5rem] p-4 flex flex-col items-center shadow-sm text-white"
style={{ backgroundColor: team.color }}
>
<span className="text-3xl font-black tabular-nums leading-none">
{teamWins(team.id)}
</span>
<span className="text-[11px] font-bold uppercase tracking-wide mt-1.5 opacity-90">
{team.label}
</span>
</div>
))}
</div>
)}
{/* Leaderboard Summary (Floating Chips) */} {/* Leaderboard Summary (Floating Chips) */}
<div className="flex overflow-x-auto pb-4 -mx-4 px-4 space-x-3 hide-scrollbar"> <div
className={`flex overflow-x-auto pb-4 -mx-4 px-4 space-x-3 hide-scrollbar ${isTeamRound ? "hidden" : ""}`}
>
{displayPlayers.map((player) => { {displayPlayers.map((player) => {
const total = getLiveTotalScore(player.id); const total = getLiveTotalScore(player.id);
return ( return (
@@ -350,6 +395,10 @@ export default function PlayGame() {
<h2 className="text-2xl font-black tracking-tighter drop-shadow-sm truncate"> <h2 className="text-2xl font-black tracking-tighter drop-shadow-sm truncate">
{categories {categories
? categories[currentCategoryIndex].label ? categories[currentCategoryIndex].label
: isTeamRound
? "Qui a gagné ?"
: isFinalTally
? "Décompte final"
: isSingleRound : isSingleRound
? "Saisie des scores" ? "Saisie des scores"
: "À vos marques !"} : "À vos marques !"}
@@ -389,7 +438,51 @@ export default function PlayGame() {
</div> </div>
</div> </div>
{categories ? ( {isTeamRound ? (
<Card className="bg-background/80 backdrop-blur-xl border-0 shadow-xl rounded-[2.5rem] overflow-hidden">
<CardContent className="p-4 space-y-4">
<p className="text-sm font-medium text-muted-foreground text-center px-2">
Les équipes changent à chaque manche : notez simplement celle
qui l'emporte. Les points seront révélés à la fin.
</p>
<div className="grid grid-cols-2 gap-3">
{teams?.map((team) => {
const selected = selectedTeamId === team.id;
return (
<button
key={team.id}
onClick={() => setSelectedTeamId(team.id)}
className={`rounded-[1.5rem] p-5 flex flex-col items-center gap-2.5 border-4 transition-all active:scale-95 ${
selected ? "shadow-lg" : "opacity-70"
}`}
style={{
backgroundColor: `${team.color}1f`,
borderColor: selected ? team.color : "transparent",
}}
>
<span
className="w-14 h-14 rounded-full grid place-items-center text-white shadow-md"
style={{ backgroundColor: team.color }}
>
{selected ? (
<Check className="w-7 h-7" strokeWidth={3} />
) : (
<Users className="w-7 h-7" />
)}
</span>
<span
className="font-black text-sm"
style={{ color: team.color }}
>
{team.label}
</span>
</button>
);
})}
</div>
</CardContent>
</Card>
) : categories ? (
<Card className="bg-background/80 backdrop-blur-xl border-0 shadow-xl rounded-[2.5rem] overflow-hidden"> <Card className="bg-background/80 backdrop-blur-xl border-0 shadow-xl rounded-[2.5rem] overflow-hidden">
<CardContent className="p-4 space-y-1"> <CardContent className="p-4 space-y-1">
{displayPlayers.map((player, idx) => { {displayPlayers.map((player, idx) => {
@@ -544,7 +637,22 @@ export default function PlayGame() {
#{round.roundNumber} #{round.roundNumber}
</div> </div>
<div className="flex-1 flex justify-around items-center"> <div className="flex-1 flex justify-around items-center">
{round.scores.map((s) => ( {round.winningTeamId &&
(() => {
const t = teams?.find(
(x) => x.id === round.winningTeamId,
);
return t ? (
<span
className="px-3 py-1 rounded-full text-white text-sm font-black shadow-sm"
style={{ backgroundColor: t.color }}
>
{t.label}
</span>
) : null;
})()}
{!round.winningTeamId &&
round.scores.map((s) => (
<div <div
key={s.playerId} key={s.playerId}
className="flex flex-col items-center" className="flex flex-col items-center"
@@ -583,7 +691,8 @@ export default function PlayGame() {
<div className="max-w-md mx-auto pointer-events-auto"> <div className="max-w-md mx-auto pointer-events-auto">
<Button <Button
size="lg" size="lg"
className="w-full text-xl font-black shadow-xl h-16 rounded-[2rem] transition-transform active:scale-95" disabled={isTeamRound && !selectedTeamId}
className="w-full text-xl font-black shadow-xl h-16 rounded-[2rem] transition-transform active:scale-95 disabled:opacity-50"
onClick={() => { onClick={() => {
if ( if (
categories && categories &&
@@ -601,7 +710,9 @@ export default function PlayGame() {
) : ( ) : (
<> <>
<Check className="w-7 h-7 mr-2" strokeWidth={3} /> <Check className="w-7 h-7 mr-2" strokeWidth={3} />
{categories || isSingleRound {isTeamRound
? "Valider la manche"
: isFinalTally || categories || isSingleRound
? "Valider le score" ? "Valider le score"
: "Valider la manche"} : "Valider la manche"}
</> </>
+3 -2
View File
@@ -17,7 +17,7 @@ interface GameState {
profileId: string, profileId: string,
locationId?: string, locationId?: string,
) => Promise<string>; ) => Promise<string>;
addRound: (scores: RoundScore[]) => Promise<void>; addRound: (scores: RoundScore[], winningTeamId?: string) => Promise<void>;
updateRound: (roundId: string, scores: RoundScore[]) => Promise<void>; updateRound: (roundId: string, scores: RoundScore[]) => Promise<void>;
removeRound: (roundId: string) => Promise<void>; removeRound: (roundId: string) => Promise<void>;
undoLastRound: () => Promise<void>; undoLastRound: () => Promise<void>;
@@ -63,7 +63,7 @@ export const useGameStore = create<GameState>((set, get) => ({
return newSession.id; return newSession.id;
}, },
addRound: async (scores) => { addRound: async (scores, winningTeamId) => {
const { activeSession, saveSession } = get(); const { activeSession, saveSession } = get();
if (!activeSession) return; if (!activeSession) return;
@@ -71,6 +71,7 @@ export const useGameStore = create<GameState>((set, get) => ({
id: generateId(), id: generateId(),
roundNumber: activeSession.rounds.length + 1, roundNumber: activeSession.rounds.length + 1,
scores, scores,
winningTeamId,
}; };
set({ set({
+2
View File
@@ -57,4 +57,6 @@ export const RESERVED_GAME_IDS = [
"harmonie", "harmonie",
"odin_cards", "odin_cards",
"chouineurs", "chouineurs",
"six_qui_prend",
"moustache",
]; ];
+12
View File
@@ -24,6 +24,11 @@ export interface GameConfig {
isEndGameCalculation?: boolean, isEndGameCalculation?: boolean,
) => number; ) => number;
fixedRounds?: number; // Used if scoreType is 'fixed_rounds' fixedRounds?: number; // Used if scoreType is 'fixed_rounds'
// Jeux en équipes tirées au sort à chaque manche (ex: Moustache) : on note
// seulement l'équipe gagnante des `teamRounds` premières manches, puis la
// dernière manche sert au décompte final des points de chaque joueur.
teams?: GameTeam[];
teamRounds?: number;
scoringCategories?: ScoringCategory[]; scoringCategories?: ScoringCategory[];
// Optional: categories that depend on the chosen options (e.g. an extra // Optional: categories that depend on the chosen options (e.g. an extra
// scoring category enabled by a rule toggle). Falls back to scoringCategories. // scoring category enabled by a rule toggle). Falls back to scoringCategories.
@@ -68,6 +73,13 @@ export interface Round {
id: string; id: string;
roundNumber: number; roundNumber: number;
scores: RoundScore[]; scores: RoundScore[];
winningTeamId?: string; // manche jouée en équipes : équipe gagnante
}
export interface GameTeam {
id: string;
label: string;
color: string;
} }
export interface GameSession { export interface GameSession {