@jjlmoya/utils-tabletop 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/src/entries.ts +4 -0
  3. package/src/tests/locale_completeness.test.ts +1 -1
  4. package/src/tests/tool_validation.test.ts +1 -1
  5. package/src/tool/decision-wheel/client.ts +5 -1
  6. package/src/tool/decision-wheel/components/SegmentEditor.astro +1 -1
  7. package/src/tool/decision-wheel/decision-wheel.css +23 -7
  8. package/src/tool/hidden-role-dealer/bibliography.astro +16 -0
  9. package/src/tool/hidden-role-dealer/bibliography.ts +12 -0
  10. package/src/tool/hidden-role-dealer/client.ts +265 -0
  11. package/src/tool/hidden-role-dealer/component.astro +184 -0
  12. package/src/tool/hidden-role-dealer/entry.ts +145 -0
  13. package/src/tool/hidden-role-dealer/hidden-role-dealer.css +604 -0
  14. package/src/tool/hidden-role-dealer/i18n/de.ts +195 -0
  15. package/src/tool/hidden-role-dealer/i18n/en.ts +279 -0
  16. package/src/tool/hidden-role-dealer/i18n/es.ts +279 -0
  17. package/src/tool/hidden-role-dealer/i18n/fr.ts +215 -0
  18. package/src/tool/hidden-role-dealer/i18n/id.ts +191 -0
  19. package/src/tool/hidden-role-dealer/i18n/it.ts +191 -0
  20. package/src/tool/hidden-role-dealer/i18n/ja.ts +191 -0
  21. package/src/tool/hidden-role-dealer/i18n/ko.ts +191 -0
  22. package/src/tool/hidden-role-dealer/i18n/nl.ts +191 -0
  23. package/src/tool/hidden-role-dealer/i18n/pl.ts +191 -0
  24. package/src/tool/hidden-role-dealer/i18n/pt.ts +191 -0
  25. package/src/tool/hidden-role-dealer/i18n/ru.ts +191 -0
  26. package/src/tool/hidden-role-dealer/i18n/sv.ts +191 -0
  27. package/src/tool/hidden-role-dealer/i18n/tr.ts +191 -0
  28. package/src/tool/hidden-role-dealer/i18n/zh.ts +191 -0
  29. package/src/tool/hidden-role-dealer/index.ts +10 -0
  30. package/src/tool/hidden-role-dealer/logic.test.ts +99 -0
  31. package/src/tool/hidden-role-dealer/logic.ts +134 -0
  32. package/src/tool/hidden-role-dealer/modules/custom-role-manager.ts +35 -0
  33. package/src/tool/hidden-role-dealer/modules/dealer-logic.ts +73 -0
  34. package/src/tool/hidden-role-dealer/modules/hold-controller.ts +38 -0
  35. package/src/tool/hidden-role-dealer/modules/particle-effects.ts +40 -0
  36. package/src/tool/hidden-role-dealer/modules/player-manager.ts +44 -0
  37. package/src/tool/hidden-role-dealer/modules/presets.ts +46 -0
  38. package/src/tool/hidden-role-dealer/modules/setup-handlers.ts +34 -0
  39. package/src/tool/hidden-role-dealer/modules/storage.ts +23 -0
  40. package/src/tool/hidden-role-dealer/modules/utils.ts +18 -0
  41. package/src/tool/hidden-role-dealer/modules/wizard-html.ts +161 -0
  42. package/src/tool/hidden-role-dealer/modules/wizard-renderer.ts +161 -0
  43. package/src/tool/hidden-role-dealer/modules/writer-handler.ts +70 -0
  44. package/src/tool/hidden-role-dealer/seo.astro +16 -0
  45. package/src/tool/hidden-role-dealer/types.ts +31 -0
  46. package/src/tools.ts +2 -0
@@ -0,0 +1,73 @@
1
+ import { assignRoles, generateImpostorRoles, calculateImpostorCount, getRandomSecretWord } from '../logic';
2
+ import { PRESETS } from './presets';
3
+ import type { Player, Role, ImpostorValues } from '../types';
4
+
5
+ export interface AssignRolesOptions {
6
+ players: Player[];
7
+ activePreset: string;
8
+ customRoles: Role[];
9
+ ui: Record<string, string>;
10
+ vals: ImpostorValues;
11
+ mode: string;
12
+ writerId: string;
13
+ secretInput: string;
14
+ }
15
+
16
+ export interface AssignResult {
17
+ players: Player[];
18
+ preroundActive: boolean;
19
+ impostorSecret: string;
20
+ }
21
+
22
+ function assignImpostorRandom(options: AssignRolesOptions): { players: Player[]; secret: string } {
23
+ const secret = options.secretInput || getRandomSecretWord();
24
+ const count = options.players.length;
25
+ const impCount = calculateImpostorCount(options.mode, count, options.vals);
26
+ return {
27
+ players: assignRoles(options.players, generateImpostorRoles(count, impCount, secret, options.ui)),
28
+ secret
29
+ };
30
+ }
31
+
32
+ function assignSpecificWriter(options: AssignRolesOptions): Player[] {
33
+ const others = options.players.filter(p => p.id !== options.writerId);
34
+ const writerP = options.players.find(p => p.id === options.writerId)!;
35
+ writerP.role = {
36
+ id: 'writer',
37
+ name: options.ui.roleWriterName || 'Writer',
38
+ description: options.ui.roleWriterDesc || 'You decide the secret word.',
39
+ team: options.ui.teamCrewmate || 'Crewmate',
40
+ secretInfo: '',
41
+ alignment: 'good'
42
+ };
43
+ const impCount = calculateImpostorCount(options.mode, others.length + 1, options.vals);
44
+ const otherRoles = generateImpostorRoles(others.length, impCount, '', options.ui);
45
+ return [writerP, ...assignRoles(others, otherRoles)];
46
+ }
47
+
48
+ export function performRoleAssignment(options: AssignRolesOptions): AssignResult {
49
+ const count = options.players.length;
50
+ if (options.activePreset === 'custom') {
51
+ return {
52
+ players: options.customRoles.length === count ? assignRoles(options.players, options.customRoles) : options.players,
53
+ preroundActive: false,
54
+ impostorSecret: ''
55
+ };
56
+ }
57
+ if (options.activePreset === 'impostor') {
58
+ if (options.writerId === 'preround') {
59
+ return { players: options.players, preroundActive: true, impostorSecret: '' };
60
+ }
61
+ if (options.writerId === 'random') {
62
+ const res = assignImpostorRandom(options);
63
+ return { players: res.players, preroundActive: false, impostorSecret: res.secret };
64
+ }
65
+ const players = assignSpecificWriter(options);
66
+ return { players, preroundActive: false, impostorSecret: '' };
67
+ }
68
+ return {
69
+ players: assignRoles(options.players, PRESETS[options.activePreset](count, options.ui)),
70
+ preroundActive: false,
71
+ impostorSecret: ''
72
+ };
73
+ }
@@ -0,0 +1,38 @@
1
+ import { ParticleEffects } from './particle-effects';
2
+
3
+ export class HoldController {
4
+ private container: HTMLElement;
5
+ private particles: ParticleEffects;
6
+ private holdTimer: number | null = null;
7
+ private isHolding: boolean = false;
8
+ private onReveal: () => void;
9
+
10
+ constructor(container: HTMLElement, onReveal: () => void) {
11
+ this.container = container;
12
+ this.particles = new ParticleEffects(container);
13
+ this.onReveal = onReveal;
14
+ }
15
+
16
+ public start(x: number, y: number): void {
17
+ const wrapper = this.container.querySelector('.hrd-hold-button-wrapper');
18
+ if (!wrapper) return;
19
+ this.isHolding = true;
20
+ wrapper.classList.add('holding');
21
+ this.holdTimer = window.setTimeout(() => {
22
+ if (this.isHolding) {
23
+ this.particles.spawn(x, y, 'var(--dealer-neutral)');
24
+ this.onReveal();
25
+ }
26
+ }, 1200);
27
+ }
28
+
29
+ public end(): void {
30
+ this.isHolding = false;
31
+ if (this.holdTimer) {
32
+ clearTimeout(this.holdTimer);
33
+ this.holdTimer = null;
34
+ }
35
+ const wrapper = this.container.querySelector('.hrd-hold-button-wrapper');
36
+ if (wrapper) wrapper.classList.remove('holding');
37
+ }
38
+ }
@@ -0,0 +1,40 @@
1
+ export class ParticleEffects {
2
+ private container: HTMLElement;
3
+
4
+ constructor(container: HTMLElement) {
5
+ this.container = container;
6
+ }
7
+
8
+ public spawn(x: number, y: number, color: string): void {
9
+ const count = 30;
10
+ const parentRect = this.container.getBoundingClientRect();
11
+ const relativeX = x - parentRect.left;
12
+ const relativeY = y - parentRect.top;
13
+
14
+ for (let i = 0; i < count; i++) {
15
+ const particle = document.createElement('div');
16
+ particle.className = 'hrd-role-particle';
17
+
18
+ const size = Math.random() * 8 + 6;
19
+ const angle = Math.random() * Math.PI * 2;
20
+ const distance = Math.random() * 80 + 40;
21
+
22
+ const destinationX = Math.cos(angle) * distance;
23
+ const destinationY = Math.sin(angle) * distance;
24
+
25
+ particle.style.width = `${size}px`;
26
+ particle.style.height = `${size}px`;
27
+ particle.style.backgroundColor = color;
28
+ particle.style.left = `${relativeX}px`;
29
+ particle.style.top = `${relativeY}px`;
30
+ particle.style.setProperty('--tx', `${destinationX}px`);
31
+ particle.style.setProperty('--ty', `${destinationY}px`);
32
+
33
+ this.container.appendChild(particle);
34
+
35
+ setTimeout(() => {
36
+ particle.remove();
37
+ }, 1000);
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,44 @@
1
+ import { addPlayer } from '../logic';
2
+ import { loadSavedPlayers, savePlayersList } from './storage';
3
+ import type { Player } from '../types';
4
+
5
+ export class PlayerManager {
6
+ private players: Player[] = [];
7
+
8
+ constructor() {
9
+ this.players = loadSavedPlayers();
10
+ }
11
+
12
+ public getPlayers(): Player[] {
13
+ return this.players;
14
+ }
15
+
16
+ public setPlayers(players: Player[]): void {
17
+ this.players = players;
18
+ savePlayersList(this.players);
19
+ }
20
+
21
+ public add(name: string): void {
22
+ this.players = addPlayer(this.players, name);
23
+ savePlayersList(this.players);
24
+ }
25
+
26
+ public addFromInput(container: HTMLElement): void {
27
+ const input = container.querySelector('#input-player-name') as HTMLInputElement;
28
+ if (input && input.value.trim()) {
29
+ this.add(input.value.trim());
30
+ input.value = '';
31
+ input.focus();
32
+ }
33
+ }
34
+
35
+ public delete(id: string): void {
36
+ this.players = this.players.filter(p => p.id !== id);
37
+ savePlayersList(this.players);
38
+ }
39
+
40
+ public clear(): void {
41
+ this.players = [];
42
+ savePlayersList([]);
43
+ }
44
+ }
@@ -0,0 +1,46 @@
1
+ import type { Role } from '../types';
2
+
3
+ export const PRESETS: Record<string, (count: number, ui: Record<string, string>) => Role[]> = {
4
+ werewolf: (count, ui) => {
5
+ const roles: Role[] = [];
6
+ const werewolfCount = count >= 6 ? 2 : 1;
7
+ for (let i = 0; i < werewolfCount; i++) {
8
+ roles.push({ id: `wolf-${i}`, name: ui.roleWerewolfName, description: ui.roleWerewolfDesc, team: ui.teamWerewolves, secretInfo: ui.roleWerewolfSecret, alignment: 'evil' });
9
+ }
10
+ roles.push({ id: 'seer', name: ui.roleSeerName, description: ui.roleSeerDesc, team: ui.teamVillagers, secretInfo: ui.roleSeerSecret, alignment: 'good' });
11
+ roles.push({ id: 'doctor', name: ui.roleDoctorName, description: ui.roleDoctorDesc, team: ui.teamVillagers, secretInfo: ui.roleDoctorSecret, alignment: 'good' });
12
+ while (roles.length < count) {
13
+ roles.push({ id: `villager-${roles.length}`, name: ui.roleVillagerName, description: ui.roleVillagerDesc, team: ui.teamVillagers, secretInfo: ui.roleVillagerSecret, alignment: 'neutral' });
14
+ }
15
+ return roles.slice(0, count);
16
+ },
17
+ avalon: (count, ui) => {
18
+ const roles: Role[] = [];
19
+ roles.push({ id: 'merlin', name: ui.roleMerlinName, description: ui.roleMerlinDesc, team: ui.teamGood, secretInfo: ui.roleMerlinSecret, alignment: 'good' });
20
+ roles.push({ id: 'assassin', name: ui.roleAssassinName, description: ui.roleAssassinDesc, team: ui.teamEvil, secretInfo: ui.roleAssassinSecret, alignment: 'evil' });
21
+ roles.push({ id: 'minion', name: ui.roleMinionName, description: ui.roleMinionDesc, team: ui.teamEvil, secretInfo: ui.roleMinionSecret, alignment: 'evil' });
22
+ while (roles.length < count) {
23
+ roles.push({ id: `servant-${roles.length}`, name: ui.roleServantName, description: ui.roleServantDesc, team: ui.teamGood, secretInfo: ui.roleServantSecret, alignment: 'neutral' });
24
+ }
25
+ return roles.slice(0, count);
26
+ },
27
+ hitler: (count, ui) => {
28
+ const roles: Role[] = [];
29
+ roles.push({ id: 'hitler', name: ui.roleHitlerName, description: ui.roleHitlerDesc, team: ui.teamFascist, secretInfo: ui.roleHitlerSecret, alignment: 'evil' });
30
+ roles.push({ id: 'fascist-0', name: ui.roleFascistName, description: ui.roleFascistDesc, team: ui.teamFascist, secretInfo: ui.roleFascistSecret, alignment: 'evil' });
31
+ if (count >= 7) {
32
+ roles.push({ id: 'fascist-1', name: ui.roleFascistName, description: ui.roleFascistDesc, team: ui.teamFascist, secretInfo: ui.roleFascistSecret, alignment: 'evil' });
33
+ }
34
+ while (roles.length < count) {
35
+ roles.push({ id: `liberal-${roles.length}`, name: ui.roleLiberalName, description: ui.roleLiberalDesc, team: ui.teamLiberal, secretInfo: ui.roleLiberalSecret, alignment: 'neutral' });
36
+ }
37
+ return roles.slice(0, count);
38
+ },
39
+ custom: (count, ui) => {
40
+ const roles: Role[] = [];
41
+ for (let i = 0; i < count; i++) {
42
+ roles.push({ id: `custom-${i}`, name: `${ui.roleCustomName} ${i + 1}`, description: ui.roleCustomDesc, team: ui.teamCustom, secretInfo: ui.roleCustomSecret, alignment: 'neutral' });
43
+ }
44
+ return roles;
45
+ }
46
+ };
@@ -0,0 +1,34 @@
1
+ import { addPlayer, addCustomRole, removeCustomRole } from '../logic';
2
+ import { savePlayersList } from './storage';
3
+ import type { Player, Role } from '../types';
4
+
5
+ export function createNewPlayer(container: HTMLElement, players: Player[]): Player[] {
6
+ const input = container.querySelector('#input-player-name') as HTMLInputElement;
7
+ if (input && input.value.trim()) {
8
+ const next = addPlayer(players, input.value.trim());
9
+ savePlayersList(next);
10
+ input.value = '';
11
+ input.focus();
12
+ return next;
13
+ }
14
+ return players;
15
+ }
16
+
17
+ export function createNewCustomRole(container: HTMLElement, customRoles: Role[]): Role[] {
18
+ const input = container.querySelector('#input-role-name') as HTMLInputElement;
19
+ const select = container.querySelector('#select-role-alignment') as HTMLSelectElement;
20
+ if (input && select && input.value.trim()) {
21
+ const next = addCustomRole(customRoles, input.value.trim(), select.value as 'good' | 'evil' | 'neutral');
22
+ localStorage.setItem('hidden-role-dealer-custom-roles', JSON.stringify(next));
23
+ input.value = '';
24
+ input.focus();
25
+ return next;
26
+ }
27
+ return customRoles;
28
+ }
29
+
30
+ export function deleteCustomRole(id: string, customRoles: Role[]): Role[] {
31
+ const next = removeCustomRole(customRoles, id);
32
+ localStorage.setItem('hidden-role-dealer-custom-roles', JSON.stringify(next));
33
+ return next;
34
+ }
@@ -0,0 +1,23 @@
1
+ import type { Player, Role } from '../types';
2
+
3
+ export function loadSavedPlayers(): Player[] {
4
+ const saved = localStorage.getItem('hidden-role-dealer-players');
5
+ try {
6
+ return saved ? JSON.parse(saved) : ['Alice', 'Bob', 'Charlie', 'Diana', 'Ethan'].map((name, i) => ({ id: `p-${i}`, name, role: null, revealed: false }));
7
+ } catch {
8
+ return [];
9
+ }
10
+ }
11
+
12
+ export function savePlayersList(players: Player[]): void {
13
+ localStorage.setItem('hidden-role-dealer-players', JSON.stringify(players.map((p) => ({ ...p, role: null, revealed: false }))));
14
+ }
15
+
16
+ export function loadSavedCustomRoles(): Role[] {
17
+ const savedRoles = localStorage.getItem('hidden-role-dealer-custom-roles');
18
+ try {
19
+ return savedRoles ? JSON.parse(savedRoles) : [];
20
+ } catch {
21
+ return [];
22
+ }
23
+ }
@@ -0,0 +1,18 @@
1
+ import type { Player } from '../types';
2
+
3
+ export function parseInputValue(el: HTMLInputElement | HTMLSelectElement | null, defaultValue: number): number {
4
+ if (!el) return defaultValue;
5
+ return parseInt(el.value) || defaultValue;
6
+ }
7
+
8
+ export function getUIValue(ui: Record<string, string>, key: string, fallback: string): string {
9
+ return ui[key] || fallback;
10
+ }
11
+
12
+ export function isValidWriter(current: string, players: Player[]): boolean {
13
+ return current === 'random' || current === 'preround' || players.some(p => p.id === current);
14
+ }
15
+
16
+ export function setWrapperDisplay(el: HTMLElement | null, show: boolean): void {
17
+ if (el) el.style.display = show ? 'flex' : 'none';
18
+ }
@@ -0,0 +1,161 @@
1
+ import type { Player, Role } from '../types';
2
+
3
+ export interface WizardRenderOptions {
4
+ state: 'distributing' | 'revealed' | 'complete';
5
+ wizardView: HTMLElement;
6
+ preroundActive: boolean;
7
+ preroundContributors: Player[];
8
+ preroundStep: number;
9
+ players: Player[];
10
+ currentStep: number;
11
+ impostorSecret: string;
12
+ ui: Record<string, string>;
13
+ }
14
+
15
+ export function renderDistributing(player: Player, ui: Record<string, string>): string {
16
+ return `
17
+ <div class="hrd-pass-screen">
18
+ <div class="hrd-pass-label">${ui.passTo}</div>
19
+ <div class="hrd-player-highlight">${player.name}</div>
20
+ <div class="hrd-hold-button-wrapper">
21
+ <div class="hrd-hold-progress-ring"></div>
22
+ <button class="hrd-hold-button" id="btn-hold-reveal" type="button">
23
+ ${ui.holdToReveal}
24
+ </button>
25
+ </div>
26
+ <div class="hrd-hold-hint">${ui.releasingResets}</div>
27
+ </div>
28
+ `;
29
+ }
30
+
31
+ function getSecretHTML(role: Role, ui: Record<string, string>, prefix: string): string {
32
+ if (role.secretInfo.includes(prefix)) {
33
+ const word = role.secretInfo.replace(prefix, '').trim();
34
+ return `
35
+ <div class="hrd-epic-secret-box good">
36
+ <div class="hrd-epic-label">${prefix}</div>
37
+ <div class="hrd-epic-word">${word}</div>
38
+ </div>
39
+ `;
40
+ }
41
+ const isImp = role.name === (ui.roleImpostorName || 'Impostor') || role.id.startsWith('imp-');
42
+ if (role.alignment === 'evil' && isImp) {
43
+ return `
44
+ <div class="hrd-epic-secret-box evil">
45
+ <div class="hrd-epic-label">${role.secretInfo}</div>
46
+ <div class="hrd-epic-word">IMPOSTOR</div>
47
+ </div>
48
+ `;
49
+ }
50
+ return `<div class="hrd-secret-box">${role.secretInfo}</div>`;
51
+ }
52
+
53
+ export function renderRevealed(player: Player, ui: Record<string, string>): string {
54
+ const role = player.role;
55
+ if (!role) return '';
56
+ const prefix = ui.roleCrewmateSecret || 'The Secret Word is:';
57
+ const isImpostorPreset = role.id.startsWith('crew-') || role.id.startsWith('imp-');
58
+ const secretHTML = getSecretHTML(role, ui, prefix);
59
+ const badgeHTML = isImpostorPreset ? '' : `<div class="hrd-role-team hrd-role-badge ${role.alignment}">${role.team}</div>`;
60
+ const descHTML = isImpostorPreset ? '' : `<p class="hrd-role-desc">${role.description}</p>`;
61
+ return `
62
+ <div class="hrd-reveal-screen">
63
+ <div class="hrd-pass-label">${player.name}, ${ui.secretRoleIs}</div>
64
+ <div class="hrd-card-container">
65
+ <div class="hrd-role-card ${role.alignment} ${isImpostorPreset ? 'hrd-compact-card' : ''}">
66
+ ${badgeHTML}
67
+ <h3 class="hrd-role-name">${role.name}</h3>
68
+ ${descHTML}
69
+ ${secretHTML}
70
+ </div>
71
+ </div>
72
+ <button class="hrd-btn-primary" id="btn-hide-role" type="button">
73
+ ${ui.hideRole}
74
+ </button>
75
+ </div>
76
+ `;
77
+ }
78
+
79
+ export function renderComplete(ui: Record<string, string>): string {
80
+ return `
81
+ <div class="hrd-complete-screen">
82
+ <div class="hrd-complete-icon">
83
+ <svg viewBox="0 0 24 24" width="64" height="64" fill="none" stroke="currentColor" stroke-width="2.5">
84
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
85
+ </svg>
86
+ </div>
87
+ <h3 class="hrd-complete-title">${ui.finishedTitle}</h3>
88
+ <p class="hrd-complete-desc">${ui.finishedDesc}</p>
89
+ <button class="hrd-btn-primary" id="btn-restart" type="button">
90
+ ${ui.restart}
91
+ </button>
92
+ </div>
93
+ `;
94
+ }
95
+
96
+ export function renderWriterInputScreen(player: Player, ui: Record<string, string>): string {
97
+ return `
98
+ <div class="hrd-reveal-screen">
99
+ <div class="hrd-pass-label">${player.name}</div>
100
+ <p style="color: var(--dealer-text-muted); font-size: 0.95rem; margin-bottom: 1.5rem; text-align: center;">
101
+ ${ui.writerInputPrompt}
102
+ </p>
103
+ <div class="hrd-input-row" style="margin-bottom: 1.5rem; width: 100%; max-width: 320px; margin-left: auto; margin-right: auto;">
104
+ <input
105
+ id="input-writer-secret"
106
+ class="hrd-input"
107
+ type="text"
108
+ placeholder="${ui.writerInputPlaceholder}"
109
+ maxlength="50"
110
+ style="text-align: center;"
111
+ />
112
+ </div>
113
+ <button class="hrd-btn-primary" id="btn-save-writer-word" type="button">
114
+ ${ui.btnSetWord}
115
+ </button>
116
+ </div>
117
+ `;
118
+ }
119
+
120
+ function renderPreroundWizard(options: WizardRenderOptions): void {
121
+ const { state, wizardView, preroundContributors, preroundStep, ui } = options;
122
+ if (state === 'distributing') {
123
+ wizardView.innerHTML = renderDistributing(preroundContributors[preroundStep], ui);
124
+ } else if (state === 'revealed') {
125
+ const fakePlayer = {
126
+ ...preroundContributors[preroundStep],
127
+ role: {
128
+ id: 'preround-writer',
129
+ name: ui.roleWriterName || 'Writer',
130
+ description: ui.preroundInputPrompt,
131
+ team: '',
132
+ secretInfo: '',
133
+ alignment: 'good' as const
134
+ },
135
+ revealed: false
136
+ };
137
+ wizardView.innerHTML = renderWriterInputScreen(fakePlayer, ui);
138
+ }
139
+ }
140
+
141
+ function renderGameWizard(options: WizardRenderOptions): void {
142
+ const { state, wizardView, players, currentStep, impostorSecret, ui } = options;
143
+ if (state === 'distributing') {
144
+ const p = players[currentStep];
145
+ wizardView.innerHTML = (p.role?.id === 'writer' && !impostorSecret)
146
+ ? renderWriterInputScreen(p, ui)
147
+ : renderDistributing(p, ui);
148
+ } else if (state === 'revealed') {
149
+ wizardView.innerHTML = renderRevealed(players[currentStep], ui);
150
+ } else if (state === 'complete') {
151
+ wizardView.innerHTML = renderComplete(ui);
152
+ }
153
+ }
154
+
155
+ export function renderWizardDOM(options: WizardRenderOptions): void {
156
+ if (options.preroundActive) {
157
+ renderPreroundWizard(options);
158
+ } else {
159
+ renderGameWizard(options);
160
+ }
161
+ }
@@ -0,0 +1,161 @@
1
+ import type { Player, Role } from '../types';
2
+ import { parseInputValue, getUIValue, isValidWriter, setWrapperDisplay } from './utils';
3
+
4
+ export interface SetupDOMOptions {
5
+ container: HTMLElement;
6
+ players: Player[];
7
+ customRoles: Role[];
8
+ activePreset: string;
9
+ ui: Record<string, string>;
10
+ }
11
+
12
+ export function renderPlayersListHTML(players: Player[]): string {
13
+ return players
14
+ .map(
15
+ (p) => `
16
+ <div class="hrd-player-item">
17
+ <span class="hrd-player-name">${p.name}</span>
18
+ <button class="hrd-btn-delete" data-id="${p.id}" type="button">
19
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
20
+ <line x1="18" y1="6" x2="6" y2="18"></line>
21
+ <line x1="6" y1="6" x2="18" y2="18"></line>
22
+ </svg>
23
+ </button>
24
+ </div>
25
+ `
26
+ )
27
+ .join('');
28
+ }
29
+
30
+ export function renderCustomRolesListHTML(customRoles: Role[]): string {
31
+ return customRoles
32
+ .map(
33
+ (r) => `
34
+ <div class="hrd-player-item">
35
+ <span class="hrd-player-name">
36
+ ${r.name}
37
+ <span class="hrd-role-badge ${r.alignment}">${r.alignment}</span>
38
+ </span>
39
+ <button class="hrd-btn-delete-role" data-id="${r.id}" type="button">
40
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
41
+ <line x1="18" y1="6" x2="6" y2="18"></line>
42
+ <line x1="6" y1="6" x2="18" y2="18"></line>
43
+ </svg>
44
+ </button>
45
+ </div>
46
+ `
47
+ )
48
+ .join('');
49
+ }
50
+
51
+ function updatePresetToggles(container: HTMLElement, activePreset: string): void {
52
+ container.querySelectorAll('.hrd-preset-card').forEach((card) => {
53
+ card.classList.toggle('active', card.getAttribute('data-key') === activePreset);
54
+ });
55
+ const impSettings = container.querySelector('#impostor-settings') as HTMLElement;
56
+ if (impSettings) impSettings.style.display = activePreset === 'impostor' ? 'block' : 'none';
57
+ const editor = container.querySelector('#custom-roles-editor') as HTMLElement;
58
+ if (editor) editor.style.display = activePreset === 'custom' ? 'block' : 'none';
59
+ }
60
+
61
+ function updateCustomRoles(container: HTMLElement, customRoles: Role[]): void {
62
+ const rolesList = container.querySelector('#custom-roles-list') as HTMLElement;
63
+ if (rolesList) rolesList.innerHTML = renderCustomRolesListHTML(customRoles);
64
+ }
65
+
66
+ function updateImpostorWriterSelect(container: HTMLElement, players: Player[], ui: Record<string, string>): void {
67
+ const select = container.querySelector('#select-impostor-writer') as HTMLSelectElement;
68
+ if (!select) return;
69
+ const current = select.value;
70
+ const host = getUIValue(ui, 'impostorWriterRandom', 'Non-player host');
71
+ const pre = getUIValue(ui, 'impostorWriterPreround', 'Secret Pre-round (All players)');
72
+ select.innerHTML = `<option value="random">${host}</option>` +
73
+ `<option value="preround">${pre}</option>` +
74
+ players.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
75
+ if (isValidWriter(current, players)) {
76
+ select.value = current;
77
+ }
78
+ const secretWrap = container.querySelector('#impostor-secret-wrapper') as HTMLElement;
79
+ if (secretWrap) {
80
+ secretWrap.style.display = select.value === 'random' ? 'flex' : 'none';
81
+ }
82
+ const descEl = container.querySelector('#impostor-writer-desc') as HTMLElement;
83
+ if (descEl) {
84
+ if (select.value === 'random') {
85
+ descEl.textContent = getUIValue(ui, 'impostorWriterDescHost', 'Non-player host uses a random word.');
86
+ } else if (select.value === 'preround') {
87
+ descEl.textContent = getUIValue(ui, 'impostorWriterDescPreround', 'Pre-round lets all players secretly write words, then picks one randomly.');
88
+ } else {
89
+ descEl.textContent = getUIValue(ui, 'impostorWriterDescPlayer', 'Selected player writes the word and cannot be the impostor.');
90
+ }
91
+ }
92
+ }
93
+
94
+ function getImpostorLimit(container: HTMLElement, mode: string, count: number): number {
95
+ if (mode === 'fixed') {
96
+ const el = container.querySelector('#select-impostor-fixed-count') as HTMLSelectElement;
97
+ return parseInputValue(el, 1);
98
+ }
99
+ if (mode === 'percentage') {
100
+ const el = container.querySelector('#input-impostor-percent') as HTMLInputElement;
101
+ const pct = parseInputValue(el, 25);
102
+ return Math.max(1, Math.round((pct / 100) * count));
103
+ }
104
+ const minEl = container.querySelector('#input-impostor-min') as HTMLInputElement;
105
+ return parseInputValue(minEl, 1);
106
+ }
107
+
108
+ function checkImpostorInvalid(container: HTMLElement, players: Player[], ui: Record<string, string>): { invalid: boolean; msg: string } {
109
+ const select = container.querySelector('#select-impostor-writer') as HTMLSelectElement;
110
+ const writerId = select?.value || 'random';
111
+ const count = players.length;
112
+ const activeCount = (writerId === 'random' || writerId === 'preround') ? count : count - 1;
113
+ const mode = (container.querySelector('#select-impostor-mode') as HTMLSelectElement)?.value || 'fixed';
114
+ const limit = getImpostorLimit(container, mode, count);
115
+ if (activeCount <= limit) {
116
+ return { invalid: true, msg: ui.impostorWarning };
117
+ }
118
+ return { invalid: false, msg: '' };
119
+ }
120
+
121
+ function checkValidation(options: SetupDOMOptions): { invalid: boolean; msg: string } {
122
+ const count = options.players.length;
123
+ if (count < 3) {
124
+ return { invalid: true, msg: options.ui.mismatchWarning };
125
+ }
126
+ if (options.activePreset === 'impostor') {
127
+ return checkImpostorInvalid(options.container, options.players, options.ui);
128
+ }
129
+ if (options.activePreset === 'custom' && options.customRoles.length !== count) {
130
+ return { invalid: true, msg: options.ui.customMismatchWarning };
131
+ }
132
+ return { invalid: false, msg: '' };
133
+ }
134
+
135
+ function updateValidationAndButton(options: SetupDOMOptions): void {
136
+ const btn = options.container.querySelector('#btn-deal-roles') as HTMLButtonElement;
137
+ const warn = options.container.querySelector('#deal-warning') as HTMLElement;
138
+ if (!btn || !warn) return;
139
+ const { invalid, msg } = checkValidation(options);
140
+ btn.disabled = invalid;
141
+ warn.style.display = invalid ? 'block' : 'none';
142
+ warn.textContent = invalid ? msg : '';
143
+ }
144
+
145
+ function updateImpostorModeWrappers(container: HTMLElement): void {
146
+ const modeEl = container.querySelector('#select-impostor-mode') as HTMLSelectElement;
147
+ const mode = modeEl ? modeEl.value : 'fixed';
148
+ setWrapperDisplay(container.querySelector('#impostor-fixed-wrapper') as HTMLElement, mode === 'fixed');
149
+ setWrapperDisplay(container.querySelector('#impostor-percent-wrapper') as HTMLElement, mode === 'percentage');
150
+ setWrapperDisplay(container.querySelector('#impostor-range-wrapper') as HTMLElement, mode === 'range');
151
+ }
152
+
153
+ export function updateSetupDOM(options: SetupDOMOptions): void {
154
+ const list = options.container.querySelector('#players-list') as HTMLElement;
155
+ if (list) list.innerHTML = renderPlayersListHTML(options.players);
156
+ updatePresetToggles(options.container, options.activePreset);
157
+ updateCustomRoles(options.container, options.customRoles);
158
+ updateImpostorWriterSelect(options.container, options.players, options.ui);
159
+ updateImpostorModeWrappers(options.container);
160
+ updateValidationAndButton(options);
161
+ }