Add independent alignment for custom roles (#217)

* Initial plan

* Add independent alignment: constants, validation, UI, and CSS

Co-authored-by: AlecM33 <24642328+AlecM33@users.noreply.github.com>

* Add unit tests for independent alignment validation

Co-authored-by: AlecM33 <24642328+AlecM33@users.noreply.github.com>

* Fix remaining binary alignment checks in role display

Co-authored-by: AlecM33 <24642328+AlecM33@users.noreply.github.com>

* Add independent player container and game-role-independent CSS

Co-authored-by: AlecM33 <24642328+AlecM33@users.noreply.github.com>

* fix a couple bugs, tweak colors

* remove import

* remove duplicate tag

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: AlecM33 <24642328+AlecM33@users.noreply.github.com>
Co-authored-by: AlecM33 <leohfx@gmail.com>
This commit is contained in:
Copilot
2026-01-24 23:55:11 -05:00
committed by GitHub
parent b328bfffe5
commit 0449e33b40
13 changed files with 203 additions and 54 deletions

View File

@@ -21,7 +21,8 @@ export const STATUS = {
export const ALIGNMENT = { export const ALIGNMENT = {
GOOD: 'good', GOOD: 'good',
EVIL: 'evil' EVIL: 'evil',
INDEPENDENT: 'independent'
}; };
export const MESSAGES = { export const MESSAGES = {

View File

@@ -194,11 +194,15 @@ export const HTMLFragments = {
<label id='players-alive-label'></label> <label id='players-alive-label'></label>
<div id="spectator-count" tabindex="0"></div> <div id="spectator-count" tabindex="0"></div>
<div id='game-player-list'> <div id='game-player-list'>
<div class='evil-players'> <div id="independent-players" class='independent-players' style="display: none;">
<label class='independent'>Team Independent</label>
<div id='player-list-moderator-team-independent'></div>
</div>
<div id="evil-players" class='evil-players' style="display: none;">
<label class='evil'>Team Evil</label> <label class='evil'>Team Evil</label>
<div id='player-list-moderator-team-evil'></div> <div id='player-list-moderator-team-evil'></div>
</div> </div>
<div class='good-players'> <div id="good-players" class='good-players' style="display: none;">
<label class='good'>Team Good</label> <label class='good'>Team Good</label>
<div id='player-list-moderator-team-good'></div> <div id='player-list-moderator-team-good'></div>
</div> </div>

View File

@@ -147,7 +147,8 @@ export class DeckStateManager {
} }
const sortedDeck = this.deck.sort((a, b) => { const sortedDeck = this.deck.sort((a, b) => {
if (a.team !== b.team) { if (a.team !== b.team) {
return a.team === ALIGNMENT.GOOD ? -1 : 1; const order = { good: 0, evil: 1, independent: 2 };
return order[a.team] - order[b.team];
} }
return a.role.localeCompare(b.role); return a.role.localeCompare(b.role);
}); });
@@ -187,11 +188,7 @@ export class DeckStateManager {
roleEl.dataset.roleId = sortedDeck[i].id; roleEl.dataset.roleId = sortedDeck[i].id;
roleEl.classList.add('added-role'); roleEl.classList.add('added-role');
roleEl.innerHTML = HTMLFragments.DECK_SELECT_ROLE_ADDED_TO_DECK; roleEl.innerHTML = HTMLFragments.DECK_SELECT_ROLE_ADDED_TO_DECK;
if (sortedDeck[i].team === ALIGNMENT.GOOD) { roleEl.classList.add(sortedDeck[i].team);
roleEl.classList.add(ALIGNMENT.GOOD);
} else {
roleEl.classList.add(ALIGNMENT.EVIL);
}
populateRoleElementInfo(roleEl, sortedDeck, i); populateRoleElementInfo(roleEl, sortedDeck, i);
document.getElementById('deck-list').appendChild(roleEl); document.getElementById('deck-list').appendChild(roleEl);
const minusOneHandler = (e) => { const minusOneHandler = (e) => {
@@ -237,8 +234,10 @@ export class DeckStateManager {
const nameEl = document.getElementById('custom-role-info-modal-name'); const nameEl = document.getElementById('custom-role-info-modal-name');
alignmentEl.classList.remove(ALIGNMENT.GOOD); alignmentEl.classList.remove(ALIGNMENT.GOOD);
alignmentEl.classList.remove(ALIGNMENT.EVIL); alignmentEl.classList.remove(ALIGNMENT.EVIL);
alignmentEl.classList.remove(ALIGNMENT.INDEPENDENT);
nameEl.classList.remove(ALIGNMENT.GOOD); nameEl.classList.remove(ALIGNMENT.GOOD);
nameEl.classList.remove(ALIGNMENT.EVIL); nameEl.classList.remove(ALIGNMENT.EVIL);
nameEl.classList.remove(ALIGNMENT.INDEPENDENT);
e.preventDefault(); e.preventDefault();
nameEl.innerText = sortedDeck[i].role; nameEl.innerText = sortedDeck[i].role;
nameEl.classList.add(sortedDeck[i].team); nameEl.classList.add(sortedDeck[i].team);
@@ -256,6 +255,7 @@ export class DeckStateManager {
function populateRoleElementInfo (roleEl, sortedDeck, i) { function populateRoleElementInfo (roleEl, sortedDeck, i) {
roleEl.classList.remove(ALIGNMENT.GOOD); roleEl.classList.remove(ALIGNMENT.GOOD);
roleEl.classList.remove(ALIGNMENT.EVIL); roleEl.classList.remove(ALIGNMENT.EVIL);
roleEl.classList.remove(ALIGNMENT.INDEPENDENT);
roleEl.classList.add(sortedDeck[i].team); roleEl.classList.add(sortedDeck[i].team);
roleEl.querySelector('.role-name').innerHTML = roleEl.querySelector('.role-name').innerHTML =
`<span class="role-quantity"></span> `<span class="role-quantity"></span>

View File

@@ -463,11 +463,7 @@ function renderReviewAndCreateStep (containerId, stepNumber, game, deckManager)
for (const card of game.deck) { for (const card of game.deck) {
const roleEl = document.createElement('div'); const roleEl = document.createElement('div');
roleEl.innerText = card.quantity + 'x ' + card.role; roleEl.innerText = card.quantity + 'x ' + card.role;
if (card.team === ALIGNMENT.GOOD) { roleEl.classList.add(card.team);
roleEl.classList.add(ALIGNMENT.GOOD);
} else {
roleEl.classList.add(ALIGNMENT.EVIL);
}
div.querySelector('#roles-option').appendChild(roleEl); div.querySelector('#roles-option').appendChild(roleEl);
} }

View File

@@ -31,7 +31,8 @@ export class RoleBox {
loadDefaultRoles = () => { loadDefaultRoles = () => {
this.defaultRoles = defaultRoles.sort((a, b) => { this.defaultRoles = defaultRoles.sort((a, b) => {
if (a.team !== b.team) { if (a.team !== b.team) {
return a.team === ALIGNMENT.GOOD ? -1 : 1; const order = { good: 0, evil: 1, independent: 2 };
return order[a.team] - order[b.team];
} }
return a.role.localeCompare(b.role); return a.role.localeCompare(b.role);
}).map((role) => { }).map((role) => {
@@ -174,10 +175,7 @@ export class RoleBox {
defaultRole.innerHTML = HTMLFragments.DECK_SELECT_ROLE_DEFAULT; defaultRole.innerHTML = HTMLFragments.DECK_SELECT_ROLE_DEFAULT;
defaultRole.classList.add('default-role'); defaultRole.classList.add('default-role');
defaultRole.dataset.roleId = this.defaultRoles[i].id; defaultRole.dataset.roleId = this.defaultRoles[i].id;
const alignmentClass = this.defaultRoles[i].team === ALIGNMENT.GOOD defaultRole.classList.add(this.defaultRoles[i].team);
? ALIGNMENT.GOOD
: ALIGNMENT.EVIL;
defaultRole.classList.add(alignmentClass);
defaultRole.querySelector('.role-name').innerText = this.defaultRoles[i].role; defaultRole.querySelector('.role-name').innerText = this.defaultRoles[i].role;
selectEl.appendChild(defaultRole); selectEl.appendChild(defaultRole);
} }
@@ -202,7 +200,8 @@ export class RoleBox {
} }
this.customRoles.sort((a, b) => { this.customRoles.sort((a, b) => {
if (a.team !== b.team) { if (a.team !== b.team) {
return a.team === ALIGNMENT.GOOD ? -1 : 1; const order = { good: 0, evil: 1, independent: 2 };
return order[a.team] - order[b.team];
} }
return a.role.localeCompare(b.role); return a.role.localeCompare(b.role);
}); });
@@ -212,8 +211,7 @@ export class RoleBox {
customRole.innerHTML = HTMLFragments.DECK_SELECT_ROLE; customRole.innerHTML = HTMLFragments.DECK_SELECT_ROLE;
customRole.classList.add('custom-role'); customRole.classList.add('custom-role');
customRole.dataset.roleId = this.customRoles[i].id; customRole.dataset.roleId = this.customRoles[i].id;
const alignmentClass = this.customRoles[i].team === ALIGNMENT.GOOD ? ALIGNMENT.GOOD : ALIGNMENT.EVIL; customRole.classList.add(this.customRoles[i].team);
customRole.classList.add(alignmentClass);
customRole.querySelector('.role-name').innerText = this.customRoles[i].role; customRole.querySelector('.role-name').innerText = this.customRoles[i].role;
selectEl.appendChild(customRole); selectEl.appendChild(customRole);
} }
@@ -282,8 +280,10 @@ export class RoleBox {
const nameEl = document.getElementById('custom-role-info-modal-name'); const nameEl = document.getElementById('custom-role-info-modal-name');
alignmentEl.classList.remove(ALIGNMENT.GOOD); alignmentEl.classList.remove(ALIGNMENT.GOOD);
alignmentEl.classList.remove(ALIGNMENT.EVIL); alignmentEl.classList.remove(ALIGNMENT.EVIL);
alignmentEl.classList.remove(ALIGNMENT.INDEPENDENT);
nameEl.classList.remove(ALIGNMENT.GOOD); nameEl.classList.remove(ALIGNMENT.GOOD);
nameEl.classList.remove(ALIGNMENT.EVIL); nameEl.classList.remove(ALIGNMENT.EVIL);
nameEl.classList.remove(ALIGNMENT.INDEPENDENT);
e.preventDefault(); e.preventDefault();
let role; let role;
if (isCustom) { if (isCustom) {
@@ -391,7 +391,7 @@ function validateCustomRoleCookie (cookie) {
for (const entry of cookieJSON) { for (const entry of cookieJSON) {
if (entry !== null && typeof entry === 'object') { if (entry !== null && typeof entry === 'object') {
if (typeof entry.role !== 'string' || entry.role.length > PRIMITIVES.MAX_CUSTOM_ROLE_NAME_LENGTH if (typeof entry.role !== 'string' || entry.role.length > PRIMITIVES.MAX_CUSTOM_ROLE_NAME_LENGTH
|| typeof entry.team !== 'string' || (entry.team !== ALIGNMENT.GOOD && entry.team !== ALIGNMENT.EVIL) || typeof entry.team !== 'string' || (entry.team !== ALIGNMENT.GOOD && entry.team !== ALIGNMENT.EVIL && entry.team !== ALIGNMENT.INDEPENDENT)
|| typeof entry.description !== 'string' || entry.description.length > PRIMITIVES.MAX_CUSTOM_ROLE_DESCRIPTION_LENGTH || typeof entry.description !== 'string' || entry.description.length > PRIMITIVES.MAX_CUSTOM_ROLE_DESCRIPTION_LENGTH
) { ) {
return false; return false;

View File

@@ -295,26 +295,49 @@ export class InProgress {
&& ((p.userType !== USER_TYPES.MODERATOR && p.userType !== USER_TYPES.SPECTATOR) && ((p.userType !== USER_TYPES.MODERATOR && p.userType !== USER_TYPES.SPECTATOR)
|| p.killed) || p.killed)
); );
this.renderGroupOfPlayers( const teamIndependent = this.stateBucket.currentGameState.people.filter((p) => p.alignment === ALIGNMENT.INDEPENDENT
teamEvil, && ((p.userType !== USER_TYPES.MODERATOR && p.userType !== USER_TYPES.SPECTATOR)
this.killPlayerHandlers, || p.killed)
this.revealRoleHandlers,
this.stateBucket.currentGameState.accessCode,
ALIGNMENT.EVIL,
this.stateBucket.currentGameState.people.find(person =>
person.id === this.stateBucket.currentGameState.currentModeratorId).userType,
this.socket
);
this.renderGroupOfPlayers(
teamGood,
this.killPlayerHandlers,
this.revealRoleHandlers,
this.stateBucket.currentGameState.accessCode,
ALIGNMENT.GOOD,
this.stateBucket.currentGameState.people.find(person =>
person.id === this.stateBucket.currentGameState.currentModeratorId).userType,
this.socket
); );
if (teamEvil.length > 0) {
document.getElementById(`${ALIGNMENT.EVIL}-players`).style.display = 'block';
this.renderGroupOfPlayers(
teamEvil,
this.killPlayerHandlers,
this.revealRoleHandlers,
this.stateBucket.currentGameState.accessCode,
ALIGNMENT.EVIL,
this.stateBucket.currentGameState.people.find(person =>
person.id === this.stateBucket.currentGameState.currentModeratorId).userType,
this.socket
);
}
if (teamGood.length > 0) {
document.getElementById(`${ALIGNMENT.GOOD}-players`).style.display = 'block';
this.renderGroupOfPlayers(
teamGood,
this.killPlayerHandlers,
this.revealRoleHandlers,
this.stateBucket.currentGameState.accessCode,
ALIGNMENT.GOOD,
this.stateBucket.currentGameState.people.find(person =>
person.id === this.stateBucket.currentGameState.currentModeratorId).userType,
this.socket
);
}
if (teamIndependent.length > 0) {
document.getElementById(`${ALIGNMENT.INDEPENDENT}-players`).style.display = 'block';
this.renderGroupOfPlayers(
teamIndependent,
this.killPlayerHandlers,
this.revealRoleHandlers,
this.stateBucket.currentGameState.accessCode,
ALIGNMENT.INDEPENDENT,
this.stateBucket.currentGameState.people.find(person =>
person.id === this.stateBucket.currentGameState.currentModeratorId).userType,
this.socket
);
}
document.getElementById('players-alive-label').innerText = document.getElementById('players-alive-label').innerText =
'Players: ' + this.stateBucket.currentGameState.people.filter((person) => !person.out).length + ' / ' + 'Players: ' + this.stateBucket.currentGameState.people.filter((person) => !person.out).length + ' / ' +
this.stateBucket.currentGameState.gameSize + ' Alive'; this.stateBucket.currentGameState.gameSize + ' Alive';
@@ -460,9 +483,12 @@ function renderPlayerRole (gameState) {
if (gameState.client.alignment === ALIGNMENT.GOOD) { if (gameState.client.alignment === ALIGNMENT.GOOD) {
document.getElementById('game-role').classList.add('game-role-good'); document.getElementById('game-role').classList.add('game-role-good');
name.classList.add('good'); name.classList.add('good');
} else { } else if (gameState.client.alignment === ALIGNMENT.EVIL) {
document.getElementById('game-role').classList.add('game-role-evil'); document.getElementById('game-role').classList.add('game-role-evil');
name.classList.add('evil'); name.classList.add('evil');
} else if (gameState.client.alignment === ALIGNMENT.INDEPENDENT) {
document.getElementById('game-role').classList.add('game-role-independent');
name.classList.add('independent');
} }
name.setAttribute('title', gameState.client.gameRole); name.setAttribute('title', gameState.client.gameRole);
if (gameState.client.out) { if (gameState.client.out) {

View File

@@ -5,8 +5,7 @@ import {
ENVIRONMENTS, ENVIRONMENTS,
SOCKET_EVENTS, SOCKET_EVENTS,
USER_TYPE_ICONS, USER_TYPE_ICONS,
USER_TYPES, USER_TYPES
ALIGNMENT
} from '../../../../config/globals.js'; } from '../../../../config/globals.js';
import { toast } from '../../../front_end_components/Toast.js'; import { toast } from '../../../front_end_components/Toast.js';
import { Confirmation } from '../../../front_end_components/Confirmation.js'; import { Confirmation } from '../../../front_end_components/Confirmation.js';
@@ -149,7 +148,11 @@ export const SharedStateUtil = {
document.getElementById('role-info-button').addEventListener('click', (e) => { document.getElementById('role-info-button').addEventListener('click', (e) => {
const deck = stateBucket.currentGameState.deck; const deck = stateBucket.currentGameState.deck;
deck.sort((a, b) => { deck.sort((a, b) => {
return a.team === ALIGNMENT.GOOD ? -1 : 1; if (a.team !== b.team) {
const order = { good: 0, evil: 1, independent: 2 };
return order[a.team] - order[b.team];
}
return a.role.localeCompare(b.role);
}); });
e.preventDefault(); e.preventDefault();
document.getElementById('role-info-prompt').innerHTML = HTMLFragments.ROLE_INFO_MODAL; document.getElementById('role-info-prompt').innerHTML = HTMLFragments.ROLE_INFO_MODAL;
@@ -168,11 +171,7 @@ export const SharedStateUtil = {
roleName.innerText = card.role; roleName.innerText = card.role;
roleQuantity.innerText = card.quantity + 'x'; roleQuantity.innerText = card.quantity + 'x';
if (card.team === ALIGNMENT.GOOD) { roleName.classList.add(card.team);
roleName.classList.add(ALIGNMENT.GOOD);
} else {
roleName.classList.add(ALIGNMENT.EVIL);
}
roleNameDiv.appendChild(roleQuantity); roleNameDiv.appendChild(roleQuantity);
roleNameDiv.appendChild(roleName); roleNameDiv.appendChild(roleName);

View File

@@ -608,6 +608,15 @@ input {
font-weight: bold; font-weight: bold;
} }
.independent, .compact-card.independent .card-role {
color: #af8523 !important;
font-weight: bold;
}
.independent-players, #deck-independent {
border: 2px solid rgba(212, 160, 39, 0.39);
}
@keyframes placeholder { @keyframes placeholder {
0%{ 0%{
background-position: 50% 0 background-position: 50% 0

View File

@@ -424,7 +424,7 @@ h1 {
display: none; display: none;
position: relative; position: relative;
border: 5px solid transparent; border: 5px solid transparent;
background-color: #e3e3e3; background-color: #efefef;
flex-direction: column; flex-direction: column;
cursor: pointer; cursor: pointer;
justify-content: space-between; justify-content: space-between;
@@ -462,6 +462,10 @@ h1 {
border: 4px solid #c55454 !important; border: 4px solid #c55454 !important;
} }
.game-role-independent {
border: 4px solid #af8523 !important;
}
#game-role-back { #game-role-back {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -547,6 +551,10 @@ h1 {
color: #e15656 !important; color: #e15656 !important;
} }
#game-role #role-name.independent {
color: #af8523 !important;
}
#role-image { #role-image {
user-select: none; user-select: none;
-ms-user-select: none; -ms-user-select: none;

View File

@@ -11,6 +11,7 @@ export const hiddenMenus =
<select id="role-alignment" required> <select id="role-alignment" required>
<option value="good">good</option> <option value="good">good</option>
<option value="evil">evil</option> <option value="evil">evil</option>
<option value="independent">independent</option>
</select> </select>
</div> </div>
<div> <div>

View File

@@ -24,7 +24,8 @@ const LOG_LEVEL = {
const ALIGNMENT = { const ALIGNMENT = {
GOOD: 'good', GOOD: 'good',
EVIL: 'evil' EVIL: 'evil',
INDEPENDENT: 'independent'
}; };
const REDIS_CHANNELS = { const REDIS_CHANNELS = {

View File

@@ -39,7 +39,7 @@ class GameCreationRequest {
&& entry.role.length > 0 && entry.role.length > 0
&& entry.role.length <= PRIMITIVES.MAX_CUSTOM_ROLE_NAME_LENGTH && entry.role.length <= PRIMITIVES.MAX_CUSTOM_ROLE_NAME_LENGTH
&& typeof entry.team === 'string' && typeof entry.team === 'string'
&& (entry.team === ALIGNMENT.GOOD || entry.team === ALIGNMENT.EVIL) && (entry.team === ALIGNMENT.GOOD || entry.team === ALIGNMENT.EVIL || entry.team === ALIGNMENT.INDEPENDENT)
&& typeof entry.description === 'string' && typeof entry.description === 'string'
&& entry.description.length > 0 && entry.description.length > 0
&& entry.description.length <= PRIMITIVES.MAX_CUSTOM_ROLE_DESCRIPTION_LENGTH && entry.description.length <= PRIMITIVES.MAX_CUSTOM_ROLE_DESCRIPTION_LENGTH

View File

@@ -0,0 +1,104 @@
const GameCreationRequest = require('../../../../server/model/GameCreationRequest');
const { ALIGNMENT } = require('../../../../server/config/globals');
describe('GameCreationRequest', () => {
describe('#deckIsValid', () => {
it('should accept a deck with good, evil, and independent roles', () => {
const deck = [
{
role: 'Villager',
team: ALIGNMENT.GOOD,
description: 'A simple villager',
custom: false,
quantity: 2
},
{
role: 'Werewolf',
team: ALIGNMENT.EVIL,
description: 'A werewolf',
custom: false,
quantity: 1
},
{
role: 'Tanner',
team: ALIGNMENT.INDEPENDENT,
description: 'An independent role',
custom: true,
quantity: 1
}
];
expect(GameCreationRequest.deckIsValid(deck)).toBe(true);
});
it('should accept a deck with only good roles', () => {
const deck = [
{
role: 'Villager',
team: ALIGNMENT.GOOD,
description: 'A simple villager',
custom: false,
quantity: 3
}
];
expect(GameCreationRequest.deckIsValid(deck)).toBe(true);
});
it('should accept a deck with only evil roles', () => {
const deck = [
{
role: 'Werewolf',
team: ALIGNMENT.EVIL,
description: 'A werewolf',
custom: false,
quantity: 2
}
];
expect(GameCreationRequest.deckIsValid(deck)).toBe(true);
});
it('should accept a deck with only independent roles', () => {
const deck = [
{
role: 'Tanner',
team: ALIGNMENT.INDEPENDENT,
description: 'An independent role',
custom: true,
quantity: 1
}
];
expect(GameCreationRequest.deckIsValid(deck)).toBe(true);
});
it('should reject a deck with invalid team values', () => {
const deck = [
{
role: 'InvalidRole',
team: 'invalid',
description: 'Invalid team',
custom: true,
quantity: 1
}
];
expect(GameCreationRequest.deckIsValid(deck)).toBe(false);
});
it('should reject a deck with missing required fields', () => {
const deck = [
{
role: 'Villager',
// missing team
description: 'A simple villager',
custom: false,
quantity: 1
}
];
expect(GameCreationRequest.deckIsValid(deck)).toBe(false);
});
});
});