@jjlmoya/utils-tabletop 1.3.0 → 1.4.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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/category/TabletopCategorySEO.astro +2 -7
  3. package/src/category/i18n/en.ts +4 -4
  4. package/src/category/i18n/ru.ts +2 -2
  5. package/src/category/i18n/zh.ts +8 -8
  6. package/src/category/index.ts +2 -1
  7. package/src/entries.ts +4 -1
  8. package/src/pages/[locale]/[slug].astro +7 -6
  9. package/src/tests/locale_completeness.test.ts +1 -1
  10. package/src/tests/no_em_dash.test.ts +47 -0
  11. package/src/tests/seo_length.test.ts +1 -0
  12. package/src/tests/title_quality.test.ts +1 -1
  13. package/src/tests/tool_validation.test.ts +1 -1
  14. package/src/tool/board-game-timer/modules/ColorHelper.ts +2 -2
  15. package/src/tool/board-game-timer/modules/DuelViewManager.ts +4 -4
  16. package/src/tool/board-game-timer/modules/GameRunner.ts +1 -1
  17. package/src/tool/board-game-timer/modules/MultiplayerViewManager.ts +2 -2
  18. package/src/tool/board-game-timer/modules/SetupPanelManager.ts +1 -1
  19. package/src/tool/board-game-timer/modules/TimerEngine.ts +1 -1
  20. package/src/tool/board-game-timer/types.ts +1 -1
  21. package/src/tool/dice-roller-simulator/client.ts +2 -2
  22. package/src/tool/dice-roller-simulator/i18n/ru.ts +8 -8
  23. package/src/tool/dice-roller-simulator/modules/history.ts +1 -1
  24. package/src/tool/first-player-selector/bibliography.astro +16 -0
  25. package/src/tool/first-player-selector/bibliography.ts +12 -0
  26. package/src/tool/first-player-selector/client.ts +197 -0
  27. package/src/tool/first-player-selector/component.astro +77 -0
  28. package/src/tool/first-player-selector/entry.ts +42 -0
  29. package/src/tool/first-player-selector/first-player-selector.css +303 -0
  30. package/src/tool/first-player-selector/i18n/de.ts +226 -0
  31. package/src/tool/first-player-selector/i18n/en.ts +226 -0
  32. package/src/tool/first-player-selector/i18n/es.ts +226 -0
  33. package/src/tool/first-player-selector/i18n/fr.ts +227 -0
  34. package/src/tool/first-player-selector/i18n/id.ts +226 -0
  35. package/src/tool/first-player-selector/i18n/it.ts +227 -0
  36. package/src/tool/first-player-selector/i18n/ja.ts +227 -0
  37. package/src/tool/first-player-selector/i18n/ko.ts +227 -0
  38. package/src/tool/first-player-selector/i18n/nl.ts +226 -0
  39. package/src/tool/first-player-selector/i18n/pl.ts +227 -0
  40. package/src/tool/first-player-selector/i18n/pt.ts +226 -0
  41. package/src/tool/first-player-selector/i18n/ru.ts +227 -0
  42. package/src/tool/first-player-selector/i18n/sv.ts +226 -0
  43. package/src/tool/first-player-selector/i18n/tr.ts +226 -0
  44. package/src/tool/first-player-selector/i18n/zh.ts +227 -0
  45. package/src/tool/first-player-selector/index.ts +9 -0
  46. package/src/tool/first-player-selector/modules/AnimationEffects.ts +256 -0
  47. package/src/tool/first-player-selector/modules/SelectionEngine.ts +249 -0
  48. package/src/tool/first-player-selector/modules/SoundEffects.ts +87 -0
  49. package/src/tool/first-player-selector/modules/TouchTracker.ts +148 -0
  50. package/src/tool/first-player-selector/seo.astro +16 -0
  51. package/src/tool/first-player-selector/types.ts +21 -0
  52. package/src/tools.ts +2 -0
  53. package/src/types.ts +1 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-tabletop",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -1,14 +1,9 @@
1
1
  ---
2
2
  import { SEORenderer } from '@jjlmoya/utils-shared';
3
3
  import { tabletopCategory } from './index';
4
- import type { KnownLocale } from '../types';
5
4
 
6
- interface Props {
7
- locale?: string;
8
- }
9
-
10
- const { locale = 'es' } = Astro.props;
11
- const categoryContent = await tabletopCategory.i18n[locale as KnownLocale]?.();
5
+ const locale: string = Astro.props.locale ?? 'es';
6
+ const categoryContent = await tabletopCategory.i18n[locale as keyof typeof tabletopCategory.i18n]?.();
12
7
  ---
13
8
 
14
9
  {categoryContent && <SEORenderer content={{ locale, sections: categoryContent.seo }} />}
@@ -1,4 +1,4 @@
1
- import type { CategoryLocaleContent } from '../../types';
1
+ import type { CategoryLocaleContent } from '../../types';
2
2
 
3
3
  const slug = 'tabletop';
4
4
  const title = 'Tabletop Game Utilities & Tools';
@@ -10,13 +10,13 @@ export const content: CategoryLocaleContent = {
10
10
  description,
11
11
  seo: [
12
12
  { type: 'title', text: 'Tabletop Game Tools | Roll Dice Online & Track Turn Times', level: 2 },
13
- { type: 'paragraph', html: 'Game nights should be about fun, not hunting for lost dice or waiting for someone to add up their roll. This utility library gives you and your group fast, well-designed tools that work instantlyno strings attached. Whether you are a dungeon master juggling a hundred things or a player who just wants to roll without the fuss, these tools are built for you.' },
13
+ { type: 'paragraph', html: 'Game nights should be about fun, not hunting for lost dice or waiting for someone to add up their roll. This utility library gives you and your group fast, well-designed tools that work instantly-no strings attached. Whether you are a dungeon master juggling a hundred things or a player who just wants to roll without the fuss, these tools are built for you.' },
14
14
  { type: 'title', text: 'Roll Virtual Dice with Real-Time Statistics', level: 2 },
15
15
  { type: 'paragraph', html: 'Tired of scrambling under the table for missing dice or waiting for someone to tally results? The virtual dice roller lets you throw any combination of polyhedral dice instantly, with modifiers, advantage, disadvantage, and a full roll history. The built-in probability analyzer shows your real odds before you roll, helping you make smarter decisions at the table. Perfect for Dungeons & Dragons, Pathfinder, Call of Cthulhu, Shadowrun, or any game that relies on chance.' },
16
16
  { type: 'title', text: 'Keep Your Game on Pace with a Smart Turn Timer', level: 2 },
17
- { type: 'paragraph', html: 'We all know that one player who takes forever on every turn. The game timer lets you set fair and fun time limits, with modes that adapt to every play stylefrom a simple shared countdown to chess-style Fischer increment or Bronstein delay. Works for two-player duels with split-screen rotation or groups of up to eight with central turn control. When the game ends, review detailed stats on timing, rounds, and play pace.' },
17
+ { type: 'paragraph', html: 'We all know that one player who takes forever on every turn. The game timer lets you set fair and fun time limits, with modes that adapt to every play style-from a simple shared countdown to chess-style Fischer increment or Bronstein delay. Works for two-player duels with split-screen rotation or groups of up to eight with central turn control. When the game ends, review detailed stats on timing, rounds, and play pace.' },
18
18
  { type: 'title', text: 'Privacy First: Everything Runs on Your Device', level: 2 },
19
- { type: 'paragraph', html: 'One of the best things about this library is that everything processes in your browser. No data is sent to any server, no tracking cookies, no email or registration required. Use the tools offline, share your screen with the table, or project to a big screen TVnothing to worry about except the game itself. And more tools are coming, always designed to make life easier for tabletop players.' },
19
+ { type: 'paragraph', html: 'One of the best things about this library is that everything processes in your browser. No data is sent to any server, no tracking cookies, no email or registration required. Use the tools offline, share your screen with the table, or project to a big screen TV-nothing to worry about except the game itself. And more tools are coming, always designed to make life easier for tabletop players.' },
20
20
  { type: 'stats', items: [
21
21
  { label: 'Tools', value: '2+', icon: 'mdi:tools' },
22
22
  { label: 'Players', value: '2-8', icon: 'mdi:account-group' },
@@ -1,4 +1,4 @@
1
- import type { CategoryLocaleContent } from '../../types';
1
+ import type { CategoryLocaleContent } from '../../types';
2
2
 
3
3
  const slug = 'nastolnye-igry';
4
4
  const title = 'Инструменты и утилиты для настольных игр';
@@ -16,7 +16,7 @@ export const content: CategoryLocaleContent = {
16
16
  { type: 'title', text: 'Контролируйте темп игры с умным таймером ходов', level: 2 },
17
17
  { type: 'paragraph', html: 'Все мы знаем того игрока, который тратит вечность на каждый ход. Игровой таймер позволяет устанавливать справедливые и увлекательные лимиты времени с режимами на любой стиль: от простого общего отсчёта до шахматных систем с инкрементом Фишера или задержкой Бронштейна. Работает для дуэлей с разделённым экраном или групп до восьми игроков с централизованным управлением. После игры доступна подробная статистика времени и темпа.' },
18
18
  { type: 'title', text: 'Конфиденциальность превыше всего: все данные остаются на вашем устройстве', level: 2 },
19
- { type: 'paragraph', html: 'Одно из главных преимуществ этой библиотеки вся обработка происходит в вашем браузере. Никакие данные не отправляются на сервер, нет отслеживающих cookie, не требуется email или регистрация. Используйте инструменты офлайн, делитесь экраном с игроками или выводите на телевизор ни о чём, кроме игры, беспокоиться не придётся. А новые инструменты уже в разработке, всегда нацеленные на то, чтобы облегчить жизнь любителям настольных игр.' },
19
+ { type: 'paragraph', html: 'Одно из главных преимуществ этой библиотеки - вся обработка происходит в вашем браузере. Никакие данные не отправляются на сервер, нет отслеживающих cookie, не требуется email или регистрация. Используйте инструменты офлайн, делитесь экраном с игроками или выводите на телевизор - ни о чём, кроме игры, беспокоиться не придётся. А новые инструменты уже в разработке, всегда нацеленные на то, чтобы облегчить жизнь любителям настольных игр.' },
20
20
  { type: 'stats', items: [
21
21
  { label: 'Инструментов', value: '2+', icon: 'mdi:tools' },
22
22
  { label: 'Игроков', value: '2-8', icon: 'mdi:account-group' },
@@ -1,22 +1,22 @@
1
- import type { CategoryLocaleContent } from '../../types';
1
+ import type { CategoryLocaleContent } from '../../types';
2
2
 
3
3
  const slug = 'tabletop';
4
4
  const title = '桌游工具与实用程序';
5
- const description = '免费的桌游和TRPG工具:虚拟骰子带实时统计,多人回合计时器等。在浏览器中运行,无需注册,100%隐私保护。';
5
+ const description = '免费的桌游和TRPG工具:虚拟骰子带实�-�统计,多人回合计�-�器等。在浏览器中运行,�-�需注册,100%隐私保护。';
6
6
 
7
7
  export const content: CategoryLocaleContent = {
8
8
  slug,
9
9
  title,
10
10
  description,
11
11
  seo: [
12
- { type: 'title', text: '桌游工具 | 在线掷骰子和回合计时器', level: 2 },
13
- { type: 'paragraph', html: '游戏之夜本该充满乐趣,而不是到处找丢失的骰子或等待别人计算结果。这款实用工具库为您和您的团队提供快速、精心设计的工具,即刻可用,无需任何回报。无论您是手忙脚乱的 dungeon master,还是只想轻松掷骰的玩家,这些工具都是为您打造的。' },
14
- { type: 'title', text: '使用虚拟骰子进行实时统计分析', level: 2 },
12
+ { type: 'title', text: '桌游工具 | 在线掷骰子和回合计�-�器', level: 2 },
13
+ { type: 'paragraph', html: '游戏之夜本该充满乐趣,而不是到处找丢失的骰子或等待别人计�-结果。这款实用工具库为您和您的团队提供快速、精心设计的工具,即刻可用,�-�需任何回报。�-�论您是手忙脚乱的 dungeon master,还是只想轻松掷骰的玩家,这些工具都是为您打造的。' },
14
+ { type: 'title', text: '使用虚拟骰子进行实�-�统计分析', level: 2 },
15
15
  { type: 'paragraph', html: '厌倦了在桌子下找骰子或等待别人加总结果?虚拟骰子掷投器让您立即投掷任何组合的多面骰子,支持调整值、优势、劣势和完整历史记录。内置概率分析器在投掷前显示您的真实成功率。非常适合 Dungeons & Dragons、Pathfinder、Call of Cthulhu、Shadowrun 或任何依赖运气的游戏。' },
16
- { type: 'title', text: '使用智能回合计时器掌控游戏节奏', level: 2 },
17
- { type: 'paragraph', html: '我们都知道那个每回合都要花上大量时间的玩家。游戏计时器让您设定公平又有趣的时间限制,提供适应各种风格的模式:从简单的共享倒计时到 Fischer 增量或 Bronstein 延迟等国际象棋系统。支持双人分屏对决和最多八人的中央控制模式。游戏结束后,可查看时间和游戏节奏的详细统计数据。' },
16
+ { type: 'title', text: '使用智能回合计�-�器掌控游戏节奏', level: 2 },
17
+ { type: 'paragraph', html: '我们都知道那个每回合都要花上大量�-��-�的玩家。游戏计�-�器让您设定公平又有趣的�-��-�限制,提供适应各种风格的模式:从简单的共享倒计�-�到 Fischer 增量或 Bronstein 延迟等国际象棋系统。支持双人分屏对决和最多八人的中央控制模式。游戏结束后,可查看�-��-�和游戏节奏的详细统计数据。' },
18
18
  { type: 'title', text: '隐私至上:所有数据保留在您的设备上', level: 2 },
19
- { type: 'paragraph', html: '该库的最大优势之一是所有处理都在您的浏览器内完成。不会有数据发送到服务器,没有跟踪 Cookie,无需电子邮件或注册。可离线使用工具,与桌上玩家共享屏幕,或投影到电视上——除了游戏本身,无需担心任何事。更多的工具正在开发中,始终以方便桌游玩家为设计目标。' },
19
+ { type: 'paragraph', html: '该库的最大优势之一是所有处理都在您的浏览器内完成。不会有数据发送到服务器,没有跟踪 Cookie,�-�需电子邮件或注册。可离线使用工具,与桌上玩家共享屏幕,或投影到电视上--除了游戏本身,�-�需担心任何事。更多的工具正在开发中,始终以方便桌游玩家为设计目标。' },
20
20
  { type: 'stats', items: [
21
21
  { label: '工具', value: '2+', icon: 'mdi:tools' },
22
22
  { label: '玩家', value: '2-8', icon: 'mdi:account-group' },
@@ -1,5 +1,6 @@
1
1
  import { diceRollerSimulator } from '../tool/dice-roller-simulator/entry';
2
2
  import { boardGameTimer } from '../tool/board-game-timer/entry';
3
+ import type { CategoryLocaleContent, KnownLocale } from '../types';
3
4
 
4
5
  export const tabletopCategory = {
5
6
  icon: 'mdi:dice-multiple-outline',
@@ -23,5 +24,5 @@ export const tabletopCategory = {
23
24
  sv: () => import('./i18n/sv').then((m) => m.content),
24
25
  tr: () => import('./i18n/tr').then((m) => m.content),
25
26
  zh: () => import('./i18n/zh').then((m) => m.content),
26
- },
27
+ } as Partial<Record<KnownLocale, () => Promise<CategoryLocaleContent>>>,
27
28
  };
package/src/entries.ts CHANGED
@@ -2,9 +2,12 @@ export { diceRollerSimulator } from './tool/dice-roller-simulator/entry';
2
2
  export type { DiceRollerUI, DiceRollerLocaleContent } from './tool/dice-roller-simulator/entry';
3
3
  export { boardGameTimer } from './tool/board-game-timer/entry';
4
4
  export type { BoardGameTimerUI, BoardGameTimerLocaleContent } from './tool/board-game-timer/entry';
5
+ export { firstPlayerSelector } from './tool/first-player-selector/entry';
6
+ export type { FirstPlayerSelectorUI, FirstPlayerSelectorLocaleContent } from './tool/first-player-selector/entry';
5
7
  export { tabletopCategory } from './category';
6
8
 
7
9
  import { diceRollerSimulator } from './tool/dice-roller-simulator/entry';
8
10
  import { boardGameTimer } from './tool/board-game-timer/entry';
11
+ import { firstPlayerSelector } from './tool/first-player-selector/entry';
9
12
 
10
- export const ALL_ENTRIES = [diceRollerSimulator, boardGameTimer];
13
+ export const ALL_ENTRIES = [diceRollerSimulator, boardGameTimer, firstPlayerSelector];
@@ -15,7 +15,7 @@ export async function getStaticPaths() {
15
15
  const paths = [];
16
16
 
17
17
  for (const { entry, Component: lazyComp } of ALL_TOOLS) {
18
- const { default: Component } = await lazyComp();
18
+ const { default: Component } = await (lazyComp as () => Promise<{ default: unknown }>)();
19
19
  const localeEntries = Object.entries(entry.i18n) as [
20
20
  KnownLocale,
21
21
  () => Promise<ToolLocaleContent>,
@@ -70,20 +70,21 @@ interface NavItem {
70
70
  isActive?: boolean;
71
71
  }
72
72
 
73
- interface Props {
73
+ type PageProps = {
74
74
  Component: unknown;
75
75
  locale: KnownLocale;
76
76
  content: ToolLocaleContent;
77
77
  localeUrls: Partial<Record<KnownLocale, string>>;
78
78
  allToolsNav: NavItem[];
79
79
  englishSlug: string;
80
- }
80
+ };
81
81
 
82
- const { Component, locale, content, localeUrls, allToolsNav, englishSlug } = Astro.props;
82
+ const { Component, locale, content, localeUrls, allToolsNav, englishSlug } = Astro.props as PageProps;
83
83
 
84
- const cssFiles = import.meta.glob("../../tool/*/*.css", { query: "?raw", import: "default" });
84
+ const cssFiles = import.meta.glob<{ default: string }>("../../tool/*/*.css", { query: "?raw", import: "default" });
85
85
  const cssKey = Object.keys(cssFiles).find((k) => k.endsWith(`/${englishSlug}.css`));
86
- const toolCss = cssKey ? await cssFiles[cssKey]() as string : "";
86
+ const cssLoader = cssKey ? cssFiles[cssKey] : undefined;
87
+ const toolCss = cssLoader ? await cssLoader() as string : "";
87
88
 
88
89
  const seoContent: UtilitySEOContent = { locale, sections: content.seo ?? [] };
89
90
 
@@ -22,7 +22,7 @@ describe('Locale Completeness Validation', () => {
22
22
  });
23
23
 
24
24
  it('all tools registered', () => {
25
- expect(ALL_TOOLS.length).toBe(2);
25
+ expect(ALL_TOOLS.length).toBe(3);
26
26
  });
27
27
 
28
28
  });
@@ -0,0 +1,47 @@
1
+ import { describe, it } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ function getFiles(dir: string, ext: string[]): string[] {
6
+ const results: string[] = [];
7
+ if (!fs.existsSync(dir)) return results;
8
+ const list = fs.readdirSync(dir);
9
+ for (const file of list) {
10
+ const fullPath = path.join(dir, file);
11
+ const stat = fs.statSync(fullPath);
12
+ if (stat && stat.isDirectory()) {
13
+ results.push(...getFiles(fullPath, ext));
14
+ } else if (ext.some((e) => file.endsWith(e))) {
15
+ results.push(fullPath);
16
+ }
17
+ }
18
+ return results;
19
+ }
20
+
21
+ const EM_DASH = '\u2014';
22
+
23
+ describe('Em Dash Prohibition', () => {
24
+ const files = [
25
+ ...getFiles(path.join(process.cwd(), 'src', 'tool'), ['.ts']),
26
+ ...getFiles(path.join(process.cwd(), 'src', 'category'), ['.ts']),
27
+ ].filter(f => f.includes('i18n'));
28
+
29
+ it.each(files)('should not contain em dash (—) in %s', (filePath) => {
30
+ const content = fs.readFileSync(filePath, 'utf-8');
31
+ const relativePath = path.relative(process.cwd(), filePath);
32
+
33
+ const lines = content.split('\n');
34
+ const findings: { line: number; text: string }[] = [];
35
+
36
+ lines.forEach((line, index) => {
37
+ if (line.includes(EM_DASH)) {
38
+ findings.push({ line: index + 1, text: line.trim() });
39
+ }
40
+ });
41
+
42
+ if (findings.length > 0) {
43
+ const list = findings.map((f) => ` Line ${f.line}: ${f.text}`).join('\n');
44
+ throw new Error(`Em dash (—) found in ${relativePath}:\n${list}`);
45
+ }
46
+ });
47
+ });
@@ -11,6 +11,7 @@ describe('SEO Content Length Validation', () => {
11
11
  Object.keys(entry.i18n).forEach((locale) => {
12
12
  it(`${locale}: SEO section should exist`, async () => {
13
13
  const loader = (entry.i18n as Record<string, () => Promise<{ seo?: unknown[] }>>)[locale];
14
+ if (!loader) return;
14
15
  const content = await loader();
15
16
  if (!content.seo) return;
16
17
  expect(Array.isArray(content.seo)).toBe(true);
@@ -40,7 +40,7 @@ describe('Project Titles - Separator Validation', () => {
40
40
  for (const pattern of titlePatterns) {
41
41
  let match;
42
42
  while ((match = pattern.exec(content)) !== null) {
43
- const title = match[1];
43
+ const title = match[1]!;
44
44
  if (title.includes('|') || title.includes('-')) {
45
45
  findings.push(title);
46
46
  }
@@ -5,7 +5,7 @@ import { tabletopCategory } from '../data';
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
7
  it('should have tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(2);
8
+ expect(ALL_TOOLS.length).toBe(3);
9
9
  });
10
10
 
11
11
  it('tabletopCategory should be defined', () => {
@@ -5,8 +5,8 @@ export function getLuminance(hex: string): number {
5
5
  const r = parseInt(cleanHex.substring(0, 2), 16) / 255;
6
6
  const g = parseInt(cleanHex.substring(2, 4), 16) / 255;
7
7
  const b = parseInt(cleanHex.substring(4, 6), 16) / 255;
8
- const a = [r, g, b].map(v => v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
9
- return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
8
+ const [lr, lg, lb] = [r, g, b].map(v => v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
9
+ return lr * 0.2126 + lg * 0.7152 + lb * 0.0722;
10
10
  }
11
11
 
12
12
  export function getContrastColorForBackground(colorName: PlayerColor): string {
@@ -48,8 +48,8 @@ export class DuelViewManager {
48
48
 
49
49
  public initView() {
50
50
  this.duelPanel.style.display = 'grid';
51
- const p1 = this.state.players[0];
52
- const p2 = this.state.players[1];
51
+ const p1 = this.state.players[0]!;
52
+ const p2 = this.state.players[1]!;
53
53
  const d1 = document.getElementById('duel-p1') as HTMLElement;
54
54
  const d2 = document.getElementById('duel-p2') as HTMLElement;
55
55
  d1.setAttribute('data-player-id', p1.id);
@@ -86,8 +86,8 @@ export class DuelViewManager {
86
86
  }
87
87
 
88
88
  public update() {
89
- const p1 = this.state.players[0];
90
- const p2 = this.state.players[1];
89
+ const p1 = this.state.players[0]!;
90
+ const p2 = this.state.players[1]!;
91
91
  const t1 = this.state.engine.getTimer(p1.id);
92
92
  const t2 = this.state.engine.getTimer(p2.id);
93
93
  if (!t1 || !t2) return;
@@ -45,7 +45,7 @@ export class GameRunner {
45
45
  this.state.activeMode = 'multi';
46
46
  this.multiView.initView();
47
47
  }
48
- const firstPlayer = this.state.players[0];
48
+ const firstPlayer = this.state.players[0]!;
49
49
  this.state.engine.start(firstPlayer.id);
50
50
  this.state.stats.startTurn();
51
51
  }
@@ -21,7 +21,7 @@ export class MultiplayerViewManager {
21
21
  private handleGiantButtonClick() {
22
22
  if (this.state.isPaused) return;
23
23
  this.state.sound.playTurn();
24
- const currPlayer = this.state.players[this.state.activePlayerIndex];
24
+ const currPlayer = this.state.players[this.state.activePlayerIndex]!;
25
25
  this.state.stats.endTurn(currPlayer.id, this.state.roundNumber);
26
26
  this.state.activePlayerIndex = (this.state.activePlayerIndex + 1) % this.state.players.length;
27
27
  if (this.state.activePlayerIndex === 0) {
@@ -29,7 +29,7 @@ export class MultiplayerViewManager {
29
29
  const roundEl = document.getElementById('multi-round-number');
30
30
  if (roundEl) roundEl.textContent = this.state.roundNumber.toString();
31
31
  }
32
- const nextPlayer = this.state.players[this.state.activePlayerIndex];
32
+ const nextPlayer = this.state.players[this.state.activePlayerIndex]!;
33
33
  this.state.engine.switchTurn(nextPlayer.id);
34
34
  this.state.stats.startTurn();
35
35
  this.update();
@@ -138,7 +138,7 @@ export class SetupPanelManager {
138
138
  const name = this.nameInput.value.trim();
139
139
  if (!name) return;
140
140
  this.state.players.push({
141
- id: `p-${Date.now()}-${Math.random().toString(36).substr(2, 4)}`,
141
+ id: `p-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`,
142
142
  name,
143
143
  color: this.state.selectedColor,
144
144
  time: this.getBaseDuration()
@@ -136,7 +136,7 @@ export class TimerEngineImpl extends TimerEngineClock implements TimerEngine {
136
136
 
137
137
  public switchToNextPlayer(playerIds: string[], currentIndex: number) {
138
138
  const nextIndex = (currentIndex + 1) % playerIds.length;
139
- const nextId = playerIds[nextIndex];
139
+ const nextId = playerIds[nextIndex]!;
140
140
  return { nextId, nextIndex };
141
141
  }
142
142
 
@@ -26,7 +26,7 @@ export interface TimerConfig {
26
26
  export interface PlayerConfig {
27
27
  id: string;
28
28
  name: string;
29
- color: string;
29
+ color: PlayerColor;
30
30
  avatar?: string;
31
31
  time: number;
32
32
  }
@@ -185,14 +185,14 @@ function rollDiceTumble(selectedDice: { type: string; max: number }[], visualDic
185
185
  return new Promise((resolve) => {
186
186
  const intervalId = setInterval(() => {
187
187
  visualDiceEls.forEach((el, index) => {
188
- const d = selectedDice[index];
188
+ const d = selectedDice[index]!;
189
189
  el.textContent = (Math.floor(Math.random() * d.max) + 1).toString();
190
190
  });
191
191
  }, 60);
192
192
  setTimeout(() => {
193
193
  clearInterval(intervalId);
194
194
  const results = visualDiceEls.map((el, index) => {
195
- const d = selectedDice[index];
195
+ const d = selectedDice[index]!;
196
196
  el.classList.remove('rolling-pulse');
197
197
  const val = Math.floor(Math.random() * d.max) + 1;
198
198
  if (val === d.max) {
@@ -1,4 +1,4 @@
1
- import { bibliography } from '../bibliography';
1
+ import { bibliography } from '../bibliography';
2
2
  import type { ToolLocaleContent } from '../../../types';
3
3
 
4
4
  export const content: ToolLocaleContent = {
@@ -20,7 +20,7 @@ export const content: ToolLocaleContent = {
20
20
  },
21
21
  seo: [
22
22
  { type: 'title', text: 'Управление вероятностями в настольных играх: Как математика кубиков влияет на игровой процесс', level: 2 },
23
- { type: 'paragraph', html: 'Многогранные кости это сердце настольных ролевых игр и современных настолок. Стремитесь ли вы выбросить высокий спасбросок в Dungeons & Dragons или бросаете кости для получения ресурсов, результат подчиняется математической вероятности. Понимание того, как сочетание различных типов кубиков влияет на ваши шансы, имеет решающее значение для принятия тактических решений во время игры.' },
23
+ { type: 'paragraph', html: 'Многогранные кости - это сердце настольных ролевых игр и современных настолок. Стремитесь ли вы выбросить высокий спасбросок в Dungeons & Dragons или бросаете кости для получения ресурсов, результат подчиняется математической вероятности. Понимание того, как сочетание различных типов кубиков влияет на ваши шансы, имеет решающее значение для принятия тактических решений во время игры.' },
24
24
  {
25
25
  type: 'stats',
26
26
  items: [
@@ -33,7 +33,7 @@ export const content: ToolLocaleContent = {
33
33
  { type: 'title', text: 'Математика нескольких костей: Почему 3d6 надежнее, чем 1d20', level: 2 },
34
34
  { type: 'paragraph', html: 'Когда вы бросаете одну кость D20, каждый результат от 1 до 20 имеет равный шанс в 5%, что дает равномерное распределение вероятностей. Однако при броске нескольких костей, например 3d6, результаты группируются вокруг среднего значения 10.5. Эта куполообразная кривая (распределение Гаусса) означает, что вы гораздо чаще будете выбрасывать средние значения, чем крайние, что обеспечивает стабильность и сглаживает непредсказуемость одиночного кубика.' },
35
35
  { type: 'title', text: 'Разбор вероятностей D20: Критические попадания, критические провалы (pifia) и роль модификаторов', level: 2 },
36
- { type: 'paragraph', html: 'Простой модификатор «плюс два» может кардинально изменить вероятность успеха, превращая сложные задачи в легко выполнимые. Во многих игровых системах чистая 20 означает автоматический критический успех (шанс 5%), тогда как чистая 1 критический провал (pifia). Визуализация этих исходов помогает демистифицировать случайность и позволяет планировать тактические ходы на основе статистики.' },
36
+ { type: 'paragraph', html: 'Простой модификатор «плюс два» может кардинально изменить вероятность успеха, превращая сложные задачи в легко выполнимые. Во многих игровых системах чистая 20 означает автоматический критический успех (шанс 5%), тогда как чистая 1 - критический провал (pifia). Визуализация этих исходов помогает демистифицировать случайность и позволяет планировать тактические ходы на основе статистики.' },
37
37
  {
38
38
  type: 'title',
39
39
  text: 'Таблица характеристик стандартных многогранных костей',
@@ -108,7 +108,7 @@ export const content: ToolLocaleContent = {
108
108
  },
109
109
  {
110
110
  title: 'Процентная система (Бросок под значение)',
111
- description: 'Использует две 10-гранные кости для получения числа от 1 до 100. Цель выбросить значение меньше показателя персонажа, что делает шансы наглядными.',
111
+ description: 'Использует две 10-гранные кости для получения числа от 1 до 100. Цель - выбросить значение меньше показателя персонажа, что делает шансы наглядными.',
112
112
  icon: 'mdi:percent-box-outline',
113
113
  highlight: false,
114
114
  points: [
@@ -126,7 +126,7 @@ export const content: ToolLocaleContent = {
126
126
  title: 'Ошибка игрока: Почему у кубиков нет памяти',
127
127
  icon: 'mdi:information-outline',
128
128
  badge: 'PROBABILITY TIP',
129
- html: 'Распространенное заблуждение в настольных играх вера в то, что предыдущие броски влияют на будущие. Если вы выбросили три «1» подряд на D20, вам может показаться, что высокий результат уже «на подходе». На самом деле каждый бросок независимое событие. Вероятность выбросить 20 при следующем броске по-прежнему составляет ровно 5%. Знание этого помогает игрокам делать тактический выбор объективно.',
129
+ html: 'Распространенное заблуждение в настольных играх - вера в то, что предыдущие броски влияют на будущие. Если вы выбросили три «1» подряд на D20, вам может показаться, что высокий результат уже «на подходе». На самом деле каждый бросок - независимое событие. Вероятность выбросить 20 при следующем броске по-прежнему составляет ровно 5%. �-нание этого помогает игрокам делать тактический выбор объективно.',
130
130
  },
131
131
  {
132
132
  type: 'title',
@@ -154,7 +154,7 @@ export const content: ToolLocaleContent = {
154
154
  },
155
155
  {
156
156
  term: 'Математическое ожидание',
157
- definition: 'Долгосрочный средний результат ваших бросков. Для одного D6 математическое ожидание составляет 3.5; для D20 10.5.',
157
+ definition: 'Долгосрочный средний результат ваших бросков. Для одного D6 математическое ожидание составляет 3.5; для D20 - 10.5.',
158
158
  },
159
159
  {
160
160
  term: 'Процентные кости',
@@ -174,7 +174,7 @@ export const content: ToolLocaleContent = {
174
174
  },
175
175
  {
176
176
  question: 'Что такое модификаторы и как они работают?',
177
- answer: 'Модификаторы это фиксированные значения (+1, -2 и т. д.), которые прибавляются к сумме броска или вычитаются из нее для отражения навыков персонажа, бонусов или штрафов.',
177
+ answer: 'Модификаторы - это фиксированные значения (+1, -2 и т. д.), которые прибавляются к сумме броска или вычитаются из нее для отражения навыков персонажа, бонусов или штрафов.',
178
178
  },
179
179
  {
180
180
  question: 'Что означает бросок с Преимуществом или Помехой?',
@@ -242,7 +242,7 @@ export const content: ToolLocaleContent = {
242
242
  'name': 'Что такое модификаторы и как они работают?',
243
243
  'acceptedAnswer': {
244
244
  '@type': 'Answer',
245
- 'text': 'Модификаторы это фиксированные значения (+1, -2 и т. д.), которые прибавляются к сумме броска или вычитаются из нее для отражения навыков персонажа, бонусов или штрафов.'
245
+ 'text': 'Модификаторы - это фиксированные значения (+1, -2 и т. д.), которые прибавляются к сумме броска или вычитаются из нее для отражения навыков персонажа, бонусов или штрафов.'
246
246
  }
247
247
  },
248
248
  {
@@ -81,7 +81,7 @@ export function renderHistory(refs: HistoryUIRefs, onDrawChart: (totals: number[
81
81
  setText(refs.totalVal, history.length.toString());
82
82
  setText(refs.badge, history.length.toString());
83
83
  setText(refs.averageVal, (sum / history.length).toFixed(1));
84
- setText(refs.lastVal, history[history.length - 1].total.toString());
84
+ setText(refs.lastVal, history[history.length - 1]!.total.toString());
85
85
 
86
86
  onDrawChart(totals);
87
87
  }
@@ -0,0 +1,16 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { firstPlayerSelector } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props as Props;
11
+ const loader = firstPlayerSelector.i18n[locale] || firstPlayerSelector.i18n.en;
12
+ const content = await loader?.();
13
+ if (!content) return null;
14
+ ---
15
+
16
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,12 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'Multi-Touch Finger Chooser Mechanics',
6
+ url: 'https://en.wikipedia.org/wiki/Multi-touch',
7
+ },
8
+ {
9
+ name: 'Randomness in Game Design',
10
+ url: 'https://en.wikipedia.org/wiki/Randomness_in_game_design',
11
+ },
12
+ ];