Skip to content

Commit 10c3bd5

Browse files
deyvydclaude
andcommitted
feat: enforce Excalibur must go to a team member; dedupe server game constants
- server: assign-excalibur now requires targetPlayerId in the leader's in-progress teamPlayerIds; propose-team clears excaliburHolder if the final team no longer includes them (official rule: holder must be a mission team member) - GameView: excalibur assign UI now only lists selectedTeam members and sends teamPlayerIds with the emit - server.ts: drop duplicated TEAM_DISTRIBUTION/MISSION_SIZES/ROLES/needsTwoFails/assignRoles, import from src/core/avalon.ts instead (client always sends both lancelot roles together, so core's simpler assignRoles behaves identically to the server's old duplicate) - tests: update existing Excalibur tests for the new teamPlayerIds param; add coverage for the team-membership rule and for clearing the holder when the team changes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent fddb478 commit 10c3bd5

6 files changed

Lines changed: 69 additions & 89 deletions

File tree

server.ts

Lines changed: 12 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import { Server } from "socket.io";
99
import { createServer as createViteServer } from "vite";
1010
import path from "path";
1111
import { fileURLToPath } from "url";
12-
import { LANCELOT_CONFIGS, generateLoyaltyDeck, shuffle } from "./src/core/avalon.ts";
12+
import {
13+
LANCELOT_CONFIGS, generateLoyaltyDeck, shuffle,
14+
TEAM_DISTRIBUTION, MISSION_SIZES, ROLES, needsTwoFails, assignRoles,
15+
} from "./src/core/avalon.ts";
1316

1417
const __filename = fileURLToPath(import.meta.url);
1518
const __dirname = path.dirname(__filename);
@@ -486,17 +489,24 @@ io.on("connection", (socket) => {
486489
room.proposedTeam = teamPlayerIds;
487490
room.phase = 'team-voting';
488491
room.teamVotes = {};
492+
// Regra oficial: portador da Excalibur deve ser membro da equipe da missão —
493+
// se o líder mudou a equipe depois de designar, a designação cai
494+
if (room.excaliburHolder && !teamPlayerIds.includes(room.excaliburHolder)) {
495+
room.excaliburHolder = null;
496+
}
489497
broadcastRoom(room);
490498
});
491499

492-
socket.on("assign-excalibur", ({ roomCode, targetPlayerId }) => {
500+
socket.on("assign-excalibur", ({ roomCode, targetPlayerId, teamPlayerIds }) => {
493501
const room = rooms.get(roomCode);
494502
const { playerId } = socketToPlayer.get(socket.id) || {};
495503
if (!room || room.phase !== 'team-proposal' || !room.excaliburEnabled) return;
496504
if (playerId !== room.players[room.currentLeaderIndex]?.id) return;
497505
// Líder não pode manter a Excalibur consigo
498506
if (targetPlayerId === playerId) return;
499507
if (!room.players.some(p => p.id === targetPlayerId)) return;
508+
// Regra oficial: portador deve ser membro da equipe que o líder está montando
509+
if (!Array.isArray(teamPlayerIds) || !teamPlayerIds.includes(targetPlayerId)) return;
500510

501511
room.excaliburHolder = targetPlayerId;
502512
broadcastRoom(room);
@@ -787,38 +797,6 @@ io.on("connection", (socket) => {
787797
});
788798
});
789799

790-
// Avalon game logic helper (duplicate for server context)
791-
const TEAM_DISTRIBUTION: Record<number, { good: number; evil: number }> = {
792-
5: { good: 3, evil: 2 },
793-
6: { good: 4, evil: 2 },
794-
7: { good: 4, evil: 3 },
795-
8: { good: 5, evil: 3 },
796-
9: { good: 6, evil: 3 },
797-
10: { good: 6, evil: 4 },
798-
};
799-
800-
const MISSION_SIZES: Record<number, number[]> = {
801-
5: [2, 3, 2, 3, 3],
802-
6: [2, 3, 4, 3, 4],
803-
7: [2, 3, 3, 4, 4],
804-
8: [3, 4, 4, 5, 5],
805-
9: [3, 4, 4, 5, 5],
806-
10: [3, 4, 4, 5, 5],
807-
};
808-
809-
const ROLES: Record<string, { team: 'good' | 'evil' }> = {
810-
merlin: { team: 'good' },
811-
assassin: { team: 'evil' },
812-
servant: { team: 'good' },
813-
minion: { team: 'evil' },
814-
percival: { team: 'good' },
815-
morgana: { team: 'evil' },
816-
mordred: { team: 'evil' },
817-
oberon: { team: 'evil' },
818-
lancelot_good: { team: 'good' },
819-
lancelot_evil: { team: 'evil' },
820-
};
821-
822800
interface KnowledgeItem {
823801
playerId: string;
824802
hint: 'evil' | 'maybe-merlin' | 'lancelot';
@@ -1039,44 +1017,6 @@ function saveMatchHistory(room: Room) {
10391017
if (room.matchHistory.length > 10) room.matchHistory.pop();
10401018
}
10411019

1042-
function needsTwoFails(missionIndex: number, playerCount: number): boolean {
1043-
return missionIndex === 3 && playerCount >= 7;
1044-
}
1045-
1046-
function assignRoles(playerIds: string[], selectedOptionalRoles: string[]): Record<string, string> {
1047-
const playerCount = playerIds.length;
1048-
const distribution = TEAM_DISTRIBUTION[playerCount];
1049-
if (!distribution) throw new Error('Número de jogadores inválido');
1050-
1051-
const rolesToAssign: string[] = [];
1052-
rolesToAssign.push('merlin');
1053-
rolesToAssign.push('assassin');
1054-
1055-
// Handle Lancelots separately if selected
1056-
const useLancelots = selectedOptionalRoles.includes('lancelot_good') || selectedOptionalRoles.includes('lancelot_evil');
1057-
const otherOptionalRoles = selectedOptionalRoles.filter(r => r !== 'lancelot_good' && r !== 'lancelot_evil');
1058-
1059-
if (useLancelots) {
1060-
rolesToAssign.push('lancelot_good');
1061-
rolesToAssign.push('lancelot_evil');
1062-
}
1063-
1064-
otherOptionalRoles.forEach(roleId => rolesToAssign.push(roleId));
1065-
1066-
const currentGood = rolesToAssign.filter(r => ROLES[r].team === 'good').length;
1067-
const currentEvil = rolesToAssign.filter(r => ROLES[r].team === 'evil').length;
1068-
1069-
for (let i = 0; i < distribution.good - currentGood; i++) rolesToAssign.push('servant');
1070-
for (let i = 0; i < distribution.evil - currentEvil; i++) rolesToAssign.push('minion');
1071-
1072-
const shuffledRoles = shuffle(rolesToAssign);
1073-
const assignments: Record<string, string> = {};
1074-
playerIds.forEach((id, index) => {
1075-
assignments[id] = shuffledRoles[index];
1076-
});
1077-
return assignments;
1078-
}
1079-
10801020
export { httpServer, io };
10811021

10821022
async function startServer() {

src/components/game/GameView.tsx

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -277,19 +277,24 @@ export const GameView = ({ room, me, isHost, onLeave }: { room: Room; me?: Playe
277277
{room.excaliburEnabled && !room.excaliburUsed && (
278278
<div className="pt-4 border-t border-white/5 space-y-3">
279279
<p className="text-xs uppercase tracking-widest text-gray-400 font-bold">{t('app.game.excaliburAssign')}</p>
280-
<div className="grid grid-cols-2 gap-2">
281-
{room.players.filter(p => p.id !== playerId).map(p => (
282-
<button
283-
key={p.id}
284-
onClick={() => socket.emit('assign-excalibur', { roomCode: room.code, targetPlayerId: p.id })}
285-
className={`p-2 rounded-lg border transition-all text-xs font-bold ${
286-
room.excaliburHolder === p.id ? 'border-[#ffd700] bg-[#ffd700]/10 text-[#ffd700]' : 'border-white/10 bg-white/5 text-gray-400'
287-
}`}
288-
>
289-
{p.name}
290-
</button>
291-
))}
292-
</div>
280+
{selectedTeam.length === 0 ? (
281+
<p className="text-[10px] text-gray-500 italic">{t('app.game.excaliburSelectTeamFirst')}</p>
282+
) : (
283+
<div className="grid grid-cols-2 gap-2">
284+
{/* Regra oficial: portador precisa estar na equipe da missão */}
285+
{room.players.filter(p => p.id !== playerId && selectedTeam.includes(p.id)).map(p => (
286+
<button
287+
key={p.id}
288+
onClick={() => socket.emit('assign-excalibur', { roomCode: room.code, targetPlayerId: p.id, teamPlayerIds: selectedTeam })}
289+
className={`p-2 rounded-lg border transition-all text-xs font-bold ${
290+
room.excaliburHolder === p.id ? 'border-[#ffd700] bg-[#ffd700]/10 text-[#ffd700]' : 'border-white/10 bg-white/5 text-gray-400'
291+
}`}
292+
>
293+
{p.name}
294+
</button>
295+
))}
296+
</div>
297+
)}
293298
<p className="text-[10px] text-gray-400 italic">{t('app.game.excaliburHint')}</p>
294299
</div>
295300
)}

src/i18n/locales/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,8 @@
157157
"selectMissionLabel": "Select the Mission",
158158
"confirmTeam": "Confirm Team",
159159
"excaliburAssign": "Give Excalibur",
160-
"excaliburHint": "The leader must give Excalibur to another team member (or any player depending on your interpretation, but generally to a team member or another player).",
160+
"excaliburSelectTeamFirst": "Select the mission team first — Excalibur can only go to a team member.",
161+
"excaliburHint": "The leader must give Excalibur to another member of the mission team.",
161162
"waitingFormation": "Waiting for team formation...",
162163
"teamVote": "Team Vote",
163164
"youVoted": "You voted!",

src/i18n/locales/pt.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,8 @@
157157
"selectMissionLabel": "Selecione a Missão",
158158
"confirmTeam": "Confirmar Equipe",
159159
"excaliburAssign": "Entregar Excalibur",
160-
"excaliburHint": "O líder deve entregar a Excalibur a outro cavaleiro da equipe (ou qualquer jogador dependendo da sua interpretação, mas geralmente é para alguém da equipe ou apenas outro jogador).",
160+
"excaliburSelectTeamFirst": "Selecione a equipe da missão primeiro — a Excalibur só pode ir para um membro dela.",
161+
"excaliburHint": "O líder deve entregar a Excalibur a outro cavaleiro da equipe da missão.",
161162
"waitingFormation": "Aguardando a formação da equipe...",
162163
"teamVote": "Votação da Equipe",
163164
"youVoted": "Você votou!",

tests/game.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ describe('Excalibur', () => {
252252
const team = h.currentTeam(0); // 2 jogadores do bem
253253
const leader = h.leader();
254254
const holderCandidate = team.find(id => id !== leader.playerId) ?? team[0];
255-
leader.socket.emit('assign-excalibur', { roomCode: h.code, targetPlayerId: holderCandidate });
255+
leader.socket.emit('assign-excalibur', { roomCode: h.code, targetPlayerId: holderCandidate, teamPlayerIds: team });
256256
await h.waitFor(() => h.room.excaliburHolder === holderCandidate, 'excalibur atribuída');
257257

258258
await h.proposeAndApprove(team);
@@ -275,7 +275,7 @@ describe('Excalibur', () => {
275275
const team = h.currentTeam(0);
276276
const leader = h.leader();
277277
const holderCandidate = team.find(id => id !== leader.playerId) ?? team[0];
278-
leader.socket.emit('assign-excalibur', { roomCode: h.code, targetPlayerId: holderCandidate });
278+
leader.socket.emit('assign-excalibur', { roomCode: h.code, targetPlayerId: holderCandidate, teamPlayerIds: team });
279279
await h.waitFor(() => h.room.excaliburHolder === holderCandidate, 'excalibur atribuída');
280280
await h.proposeAndApprove(team);
281281
team.forEach(id => h.byId(id).socket.emit('vote-mission', { roomCode: h.code, vote: 'success' }));

tests/rules.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,39 @@ describe('guards do assign-excalibur (fix C)', () => {
161161
await new Promise(r => setTimeout(r, 300));
162162
expect(h.room.excaliburHolder).toBeNull();
163163
});
164+
165+
it('portador precisa estar na equipe proposta (regra oficial)', async () => {
166+
h = await Harness.create(5);
167+
await h.startGame({ excaliburEnabled: true });
168+
const team = h.currentTeam(0);
169+
const outsider = h.clients.find(c => !team.includes(c.playerId) && c !== h.leader())!;
170+
// Sem equipe enviada: rejeitado
171+
h.leader().socket.emit('assign-excalibur', { roomCode: h.code, targetPlayerId: outsider.playerId });
172+
await new Promise(r => setTimeout(r, 300));
173+
expect(h.room.excaliburHolder).toBeNull();
174+
// Alvo fora da equipe enviada: rejeitado
175+
h.leader().socket.emit('assign-excalibur', { roomCode: h.code, targetPlayerId: outsider.playerId, teamPlayerIds: team });
176+
await new Promise(r => setTimeout(r, 300));
177+
expect(h.room.excaliburHolder).toBeNull();
178+
});
179+
180+
it('mudar a equipe depois de atribuir Excalibur limpa a designação ao propor', async () => {
181+
h = await Harness.create(5);
182+
await h.startGame({ excaliburEnabled: true });
183+
const team = h.currentTeam(0);
184+
const leader = h.leader();
185+
const holderCandidate = team.find(id => id !== leader.playerId)!;
186+
leader.socket.emit('assign-excalibur', { roomCode: h.code, targetPlayerId: holderCandidate, teamPlayerIds: team });
187+
await h.waitFor(() => h.room.excaliburHolder === holderCandidate, 'excalibur atribuída');
188+
189+
// Propõe uma equipe diferente, sem o antigo portador
190+
const missionSize = h.room.missions[0].size;
191+
const others = h.room.players.map((p: any) => p.id).filter((id: string) => id !== holderCandidate);
192+
const newTeam = others.slice(0, missionSize);
193+
leader.socket.emit('propose-team', { roomCode: h.code, teamPlayerIds: newTeam });
194+
await h.waitPhase('team-voting');
195+
expect(h.room.excaliburHolder).toBeNull();
196+
});
164197
});
165198

166199
describe('leave-room no meio da partida (fix D)', () => {

0 commit comments

Comments
 (0)