From 0449e33b40f53f55a666fd7bcc84ed8a9f015c70 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 23:55:11 -0500 Subject: [PATCH] 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 --- client/src/config/globals.js | 3 +- .../front_end_components/HTMLFragments.js | 8 +- .../modules/game_creation/DeckStateManager.js | 12 +- .../game_creation/GameCreationStepManager.js | 6 +- client/src/modules/game_creation/RoleBox.js | 18 +-- .../modules/game_state/states/InProgress.js | 66 +++++++---- .../states/shared/SharedStateUtil.js | 15 ++- client/src/styles/GLOBAL.css | 9 ++ client/src/styles/game.css | 10 +- client/src/view_templates/CreateTemplate.js | 1 + server/config/globals.js | 3 +- server/model/GameCreationRequest.js | 2 +- .../server/model/GameCreationRequest_Spec.js | 104 ++++++++++++++++++ 13 files changed, 203 insertions(+), 54 deletions(-) create mode 100644 spec/unit/server/model/GameCreationRequest_Spec.js diff --git a/client/src/config/globals.js b/client/src/config/globals.js index ae48a9b..50e32ca 100644 --- a/client/src/config/globals.js +++ b/client/src/config/globals.js @@ -21,7 +21,8 @@ export const STATUS = { export const ALIGNMENT = { GOOD: 'good', - EVIL: 'evil' + EVIL: 'evil', + INDEPENDENT: 'independent' }; export const MESSAGES = { diff --git a/client/src/modules/front_end_components/HTMLFragments.js b/client/src/modules/front_end_components/HTMLFragments.js index e71eb16..c0f675f 100644 --- a/client/src/modules/front_end_components/HTMLFragments.js +++ b/client/src/modules/front_end_components/HTMLFragments.js @@ -194,11 +194,15 @@ export const HTMLFragments = {
-
+ + -
+ diff --git a/client/src/modules/game_creation/DeckStateManager.js b/client/src/modules/game_creation/DeckStateManager.js index ec21ed4..60b0a27 100644 --- a/client/src/modules/game_creation/DeckStateManager.js +++ b/client/src/modules/game_creation/DeckStateManager.js @@ -147,7 +147,8 @@ export class DeckStateManager { } const sortedDeck = this.deck.sort((a, b) => { 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); }); @@ -187,11 +188,7 @@ export class DeckStateManager { roleEl.dataset.roleId = sortedDeck[i].id; roleEl.classList.add('added-role'); roleEl.innerHTML = HTMLFragments.DECK_SELECT_ROLE_ADDED_TO_DECK; - if (sortedDeck[i].team === ALIGNMENT.GOOD) { - roleEl.classList.add(ALIGNMENT.GOOD); - } else { - roleEl.classList.add(ALIGNMENT.EVIL); - } + roleEl.classList.add(sortedDeck[i].team); populateRoleElementInfo(roleEl, sortedDeck, i); document.getElementById('deck-list').appendChild(roleEl); const minusOneHandler = (e) => { @@ -237,8 +234,10 @@ export class DeckStateManager { const nameEl = document.getElementById('custom-role-info-modal-name'); alignmentEl.classList.remove(ALIGNMENT.GOOD); alignmentEl.classList.remove(ALIGNMENT.EVIL); + alignmentEl.classList.remove(ALIGNMENT.INDEPENDENT); nameEl.classList.remove(ALIGNMENT.GOOD); nameEl.classList.remove(ALIGNMENT.EVIL); + nameEl.classList.remove(ALIGNMENT.INDEPENDENT); e.preventDefault(); nameEl.innerText = sortedDeck[i].role; nameEl.classList.add(sortedDeck[i].team); @@ -256,6 +255,7 @@ export class DeckStateManager { function populateRoleElementInfo (roleEl, sortedDeck, i) { roleEl.classList.remove(ALIGNMENT.GOOD); roleEl.classList.remove(ALIGNMENT.EVIL); + roleEl.classList.remove(ALIGNMENT.INDEPENDENT); roleEl.classList.add(sortedDeck[i].team); roleEl.querySelector('.role-name').innerHTML = ` diff --git a/client/src/modules/game_creation/GameCreationStepManager.js b/client/src/modules/game_creation/GameCreationStepManager.js index 4d0b097..c6fc67e 100644 --- a/client/src/modules/game_creation/GameCreationStepManager.js +++ b/client/src/modules/game_creation/GameCreationStepManager.js @@ -463,11 +463,7 @@ function renderReviewAndCreateStep (containerId, stepNumber, game, deckManager) for (const card of game.deck) { const roleEl = document.createElement('div'); roleEl.innerText = card.quantity + 'x ' + card.role; - if (card.team === ALIGNMENT.GOOD) { - roleEl.classList.add(ALIGNMENT.GOOD); - } else { - roleEl.classList.add(ALIGNMENT.EVIL); - } + roleEl.classList.add(card.team); div.querySelector('#roles-option').appendChild(roleEl); } diff --git a/client/src/modules/game_creation/RoleBox.js b/client/src/modules/game_creation/RoleBox.js index 7b996f2..6badd78 100644 --- a/client/src/modules/game_creation/RoleBox.js +++ b/client/src/modules/game_creation/RoleBox.js @@ -31,7 +31,8 @@ export class RoleBox { loadDefaultRoles = () => { this.defaultRoles = defaultRoles.sort((a, b) => { 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); }).map((role) => { @@ -174,10 +175,7 @@ export class RoleBox { defaultRole.innerHTML = HTMLFragments.DECK_SELECT_ROLE_DEFAULT; defaultRole.classList.add('default-role'); defaultRole.dataset.roleId = this.defaultRoles[i].id; - const alignmentClass = this.defaultRoles[i].team === ALIGNMENT.GOOD - ? ALIGNMENT.GOOD - : ALIGNMENT.EVIL; - defaultRole.classList.add(alignmentClass); + defaultRole.classList.add(this.defaultRoles[i].team); defaultRole.querySelector('.role-name').innerText = this.defaultRoles[i].role; selectEl.appendChild(defaultRole); } @@ -202,7 +200,8 @@ export class RoleBox { } this.customRoles.sort((a, b) => { 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); }); @@ -212,8 +211,7 @@ export class RoleBox { customRole.innerHTML = HTMLFragments.DECK_SELECT_ROLE; customRole.classList.add('custom-role'); customRole.dataset.roleId = this.customRoles[i].id; - const alignmentClass = this.customRoles[i].team === ALIGNMENT.GOOD ? ALIGNMENT.GOOD : ALIGNMENT.EVIL; - customRole.classList.add(alignmentClass); + customRole.classList.add(this.customRoles[i].team); customRole.querySelector('.role-name').innerText = this.customRoles[i].role; selectEl.appendChild(customRole); } @@ -282,8 +280,10 @@ export class RoleBox { const nameEl = document.getElementById('custom-role-info-modal-name'); alignmentEl.classList.remove(ALIGNMENT.GOOD); alignmentEl.classList.remove(ALIGNMENT.EVIL); + alignmentEl.classList.remove(ALIGNMENT.INDEPENDENT); nameEl.classList.remove(ALIGNMENT.GOOD); nameEl.classList.remove(ALIGNMENT.EVIL); + nameEl.classList.remove(ALIGNMENT.INDEPENDENT); e.preventDefault(); let role; if (isCustom) { @@ -391,7 +391,7 @@ function validateCustomRoleCookie (cookie) { for (const entry of cookieJSON) { if (entry !== null && typeof entry === 'object') { 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 ) { return false; diff --git a/client/src/modules/game_state/states/InProgress.js b/client/src/modules/game_state/states/InProgress.js index 278754b..31d39c8 100644 --- a/client/src/modules/game_state/states/InProgress.js +++ b/client/src/modules/game_state/states/InProgress.js @@ -295,26 +295,49 @@ export class InProgress { && ((p.userType !== USER_TYPES.MODERATOR && p.userType !== USER_TYPES.SPECTATOR) || p.killed) ); - 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 - ); - 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 + const teamIndependent = this.stateBucket.currentGameState.people.filter((p) => p.alignment === ALIGNMENT.INDEPENDENT + && ((p.userType !== USER_TYPES.MODERATOR && p.userType !== USER_TYPES.SPECTATOR) + || p.killed) ); + 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 = 'Players: ' + this.stateBucket.currentGameState.people.filter((person) => !person.out).length + ' / ' + this.stateBucket.currentGameState.gameSize + ' Alive'; @@ -460,9 +483,12 @@ function renderPlayerRole (gameState) { if (gameState.client.alignment === ALIGNMENT.GOOD) { document.getElementById('game-role').classList.add('game-role-good'); name.classList.add('good'); - } else { + } else if (gameState.client.alignment === ALIGNMENT.EVIL) { document.getElementById('game-role').classList.add('game-role-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); if (gameState.client.out) { diff --git a/client/src/modules/game_state/states/shared/SharedStateUtil.js b/client/src/modules/game_state/states/shared/SharedStateUtil.js index 5c91349..b341865 100644 --- a/client/src/modules/game_state/states/shared/SharedStateUtil.js +++ b/client/src/modules/game_state/states/shared/SharedStateUtil.js @@ -5,8 +5,7 @@ import { ENVIRONMENTS, SOCKET_EVENTS, USER_TYPE_ICONS, - USER_TYPES, - ALIGNMENT + USER_TYPES } from '../../../../config/globals.js'; import { toast } from '../../../front_end_components/Toast.js'; import { Confirmation } from '../../../front_end_components/Confirmation.js'; @@ -149,7 +148,11 @@ export const SharedStateUtil = { document.getElementById('role-info-button').addEventListener('click', (e) => { const deck = stateBucket.currentGameState.deck; 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(); document.getElementById('role-info-prompt').innerHTML = HTMLFragments.ROLE_INFO_MODAL; @@ -168,11 +171,7 @@ export const SharedStateUtil = { roleName.innerText = card.role; roleQuantity.innerText = card.quantity + 'x'; - if (card.team === ALIGNMENT.GOOD) { - roleName.classList.add(ALIGNMENT.GOOD); - } else { - roleName.classList.add(ALIGNMENT.EVIL); - } + roleName.classList.add(card.team); roleNameDiv.appendChild(roleQuantity); roleNameDiv.appendChild(roleName); diff --git a/client/src/styles/GLOBAL.css b/client/src/styles/GLOBAL.css index e00fe1f..ff9624e 100644 --- a/client/src/styles/GLOBAL.css +++ b/client/src/styles/GLOBAL.css @@ -608,6 +608,15 @@ input { 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 { 0%{ background-position: 50% 0 diff --git a/client/src/styles/game.css b/client/src/styles/game.css index eb071b3..cb10a25 100644 --- a/client/src/styles/game.css +++ b/client/src/styles/game.css @@ -424,7 +424,7 @@ h1 { display: none; position: relative; border: 5px solid transparent; - background-color: #e3e3e3; + background-color: #efefef; flex-direction: column; cursor: pointer; justify-content: space-between; @@ -462,6 +462,10 @@ h1 { border: 4px solid #c55454 !important; } +.game-role-independent { + border: 4px solid #af8523 !important; +} + #game-role-back { display: flex; align-items: center; @@ -547,6 +551,10 @@ h1 { color: #e15656 !important; } +#game-role #role-name.independent { + color: #af8523 !important; +} + #role-image { user-select: none; -ms-user-select: none; diff --git a/client/src/view_templates/CreateTemplate.js b/client/src/view_templates/CreateTemplate.js index c7d1053..8d0cb74 100644 --- a/client/src/view_templates/CreateTemplate.js +++ b/client/src/view_templates/CreateTemplate.js @@ -11,6 +11,7 @@ export const hiddenMenus =
diff --git a/server/config/globals.js b/server/config/globals.js index ec01331..7a7afed 100644 --- a/server/config/globals.js +++ b/server/config/globals.js @@ -24,7 +24,8 @@ const LOG_LEVEL = { const ALIGNMENT = { GOOD: 'good', - EVIL: 'evil' + EVIL: 'evil', + INDEPENDENT: 'independent' }; const REDIS_CHANNELS = { diff --git a/server/model/GameCreationRequest.js b/server/model/GameCreationRequest.js index b6ba6cc..d8d8942 100644 --- a/server/model/GameCreationRequest.js +++ b/server/model/GameCreationRequest.js @@ -39,7 +39,7 @@ class GameCreationRequest { && entry.role.length > 0 && entry.role.length <= PRIMITIVES.MAX_CUSTOM_ROLE_NAME_LENGTH && 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' && entry.description.length > 0 && entry.description.length <= PRIMITIVES.MAX_CUSTOM_ROLE_DESCRIPTION_LENGTH diff --git a/spec/unit/server/model/GameCreationRequest_Spec.js b/spec/unit/server/model/GameCreationRequest_Spec.js new file mode 100644 index 0000000..9a9542d --- /dev/null +++ b/spec/unit/server/model/GameCreationRequest_Spec.js @@ -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); + }); + }); +});