@jjlmoya/utils-tabletop 1.34.0 → 1.37.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.
- package/package.json +18 -4
- package/scripts/postbuild.mjs +17 -0
- package/scripts/postinstall.mjs +2 -4
- package/src/category/index.ts +3 -1
- package/src/components/ProductionBreadcrumb.astro +132 -0
- package/src/components/ProductionWidget.astro +182 -0
- package/src/entries.ts +2 -0
- package/src/i18n/header-ui.ts +15 -0
- package/src/i18n/language-ui.ts +9 -0
- package/src/i18n/languages.ts +24 -0
- package/src/identity/brands.ts +5 -0
- package/src/index.ts +1 -0
- package/src/layouts/ProductionCategoryPage.astro +184 -0
- package/src/layouts/ProductionPage.astro +209 -0
- package/src/layouts/ProductionUtilityPage.astro +237 -0
- package/src/mfe/assets.ts +15 -0
- package/src/mfe/category-ui.ts +26 -0
- package/src/mfe/routes.ts +50 -0
- package/src/pages/[locale]/[utilities]/[categories]/[category]/[slug].astro +100 -0
- package/src/pages/[locale]/[utilities]/[categories]/[category].astro +44 -0
- package/src/pages/index.astro +1 -1
- package/src/pages/mfe-sitemaps/[locale]/[vertical]/sitemap.xml.ts +75 -0
- package/src/pages/utilidades/[slug].astro +90 -0
- package/src/pages/utilidades/categorias/[category].astro +38 -0
- package/src/tests/category_ui.test.ts +16 -0
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/mfe_assets_contract.test.ts +44 -0
- package/src/tests/qa-test-helpers.ts +32 -0
- package/src/tests/qa_bibliography_links.test.ts +46 -0
- package/src/tests/qa_claim_evidence.test.ts +69 -0
- package/src/tests/qa_logic_reference_coverage.test.ts +46 -0
- package/src/tests/qa_runtime_i18n.test.ts +100 -0
- package/src/tests/registry_contract.test.ts +67 -0
- package/src/tests/routing_contract.test.ts +41 -0
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tests/translation_copy.test.ts +0 -1
- package/src/tool/board-game-timer/component.astro +0 -1
- package/src/tool/board-game-timer/i18n/en.ts +1 -2
- package/src/tool/board-game-timer/i18n/es.ts +2 -2
- package/src/tool/card-draw-odds-calculator/bibliography.astro +16 -0
- package/src/tool/card-draw-odds-calculator/bibliography.ts +7 -0
- package/src/tool/card-draw-odds-calculator/card-draw-odds-calculator.css +382 -0
- package/src/tool/card-draw-odds-calculator/component.astro +88 -0
- package/src/tool/card-draw-odds-calculator/controller.ts +118 -0
- package/src/tool/card-draw-odds-calculator/dom-views.ts +90 -0
- package/src/tool/card-draw-odds-calculator/entry.ts +26 -0
- package/src/tool/card-draw-odds-calculator/evaluator.ts +30 -0
- package/src/tool/card-draw-odds-calculator/i18n/de.ts +48 -0
- package/src/tool/card-draw-odds-calculator/i18n/en.ts +76 -0
- package/src/tool/card-draw-odds-calculator/i18n/es.ts +48 -0
- package/src/tool/card-draw-odds-calculator/i18n/fr.ts +48 -0
- package/src/tool/card-draw-odds-calculator/i18n/id.ts +27 -0
- package/src/tool/card-draw-odds-calculator/i18n/it.ts +15 -0
- package/src/tool/card-draw-odds-calculator/i18n/ja.ts +15 -0
- package/src/tool/card-draw-odds-calculator/i18n/ko.ts +15 -0
- package/src/tool/card-draw-odds-calculator/i18n/nl.ts +15 -0
- package/src/tool/card-draw-odds-calculator/i18n/pl.ts +15 -0
- package/src/tool/card-draw-odds-calculator/i18n/pt.ts +15 -0
- package/src/tool/card-draw-odds-calculator/i18n/ru.ts +15 -0
- package/src/tool/card-draw-odds-calculator/i18n/sv.ts +15 -0
- package/src/tool/card-draw-odds-calculator/i18n/tr.ts +15 -0
- package/src/tool/card-draw-odds-calculator/i18n/zh.ts +15 -0
- package/src/tool/card-draw-odds-calculator/index.ts +11 -0
- package/src/tool/card-draw-odds-calculator/logic.test.ts +50 -0
- package/src/tool/card-draw-odds-calculator/logic.ts +100 -0
- package/src/tool/card-draw-odds-calculator/seo.astro +16 -0
- package/src/tool/card-draw-odds-calculator/storage.ts +22 -0
- package/src/tool/card-draw-odds-calculator/ui.ts +37 -0
- package/src/tool/dice-roller-simulator/i18n/es.ts +3 -3
- package/src/tool/dungeon-map-generator/component.astro +16 -12
- package/src/tool/dungeon-map-generator/dom-views.ts +5 -4
- package/src/tool/dungeon-map-generator/i18n/es.ts +2 -2
- package/src/tool/encounter-difficulty-calculator/dom-views.ts +8 -1
- package/src/tool/fantasy-runes-translator/component.astro +0 -1
- package/src/tool/fantasy-runes-translator/logic.test.ts +32 -0
- package/src/tool/first-player-selector/component.astro +0 -1
- package/src/tool/first-player-selector/i18n/en.ts +1 -1
- package/src/tool/first-player-selector/i18n/es.ts +3 -3
- package/src/tool/hidden-role-dealer/component.astro +0 -1
- package/src/tool/lunar-tide-tracker/component.astro +0 -1
- package/src/tool/mus-scoreboard/component.astro +3 -4
- package/src/tool/mus-scoreboard/controller.ts +2 -1
- package/src/tool/mus-scoreboard/dom-views.ts +6 -1
- package/src/tool/rpg-initiative-tracker/component.astro +0 -1
- package/src/tool/rpg-settlement-exploration-map-generator/component.astro +14 -4
- package/src/tool/rpg-settlement-exploration-map-generator/editor.ts +38 -6
- package/src/tool/scatter-direction-selector/component.astro +0 -1
- package/src/tool/score-tracker/component.astro +0 -1
- package/src/tool/token-stamp-studio/component.astro +0 -1
- package/src/tools.ts +2 -0
- package/src/types.ts +6 -8
- package/src/worker.ts +26 -0
- package/src/pages/[locale]/[slug].astro +0 -162
- package/src/pages/[locale].astro +0 -251
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
2
|
+
import { bibliography } from '../bibliography';
|
|
3
|
+
import type { CardDrawOddsUI } from '../ui';
|
|
4
|
+
|
|
5
|
+
export const content: ToolLocaleContent<CardDrawOddsUI> = {
|
|
6
|
+
slug: 'kart-cekme-olasilik-hesaplayici', title: 'Kart Çekme Olasılığı Hesaplayıcı',
|
|
7
|
+
description: 'Hipergeometrik dağılımla belirli bir tura kadar bir kartın en az bir kopyasını çekme olasılığını hesaplayın.',
|
|
8
|
+
ui: {
|
|
9
|
+
configureLabel: 'Deste kurallarını ayarla', configureHint: 'Deste, el, çekim ve mulligan varsayımlarını değiştirin. Sonuçlar anında güncellenir.', presetLabel: 'Başlangıç noktası', presetCustom: 'Özel kurallar', presetStandard: '60 kartlık deste · 4 kopya', presetLimited: '40 kartlık deste · 4 kopya', presetSingleton: '100 kartlık deste · 1 kopya', deckSizeLabel: 'Deste boyutu', targetCopiesLabel: 'Kartın kopyaları', openingHandLabel: 'Başlangıç eli', drawsPerTurnLabel: 'Tur başına çekim', throughTurnLabel: 'Şu tura kadar göster', mulliganLabel: 'Mulligan modeli', mulliganNone: 'Mulligan yok', mulliganFullRedraw: 'Kaçırınca yeniden çek', visualLabel: 'Çekim yolun', visualHint: 'Örneğe daha fazla kart girdikçe kart yelpazesi açılır.', openingHandStage: 'Başlangıç eli', turnLabel: 'Tur', cardsSeenLabel: 'Görülen kartlar', finalChanceLabel: 'Son turdaki olasılık', expectedCopiesLabel: 'O zamana kadar beklenen kopya', probabilityByTurnLabel: 'Tura göre olasılık', explanationLabel: 'En az bir kopya', noMulliganNote: 'Mulligan yok: model karıştırılmış desteden tek bir örnek sayar.', fullRedrawNote: 'Kaçırınca yeniden çek: başlangıç elinde hedef yoksa el bir kez tamamen değiştirilir.', invalidInput: 'Devam etmek için deste kurallarını kontrol edin.', invalidDeck: 'Geçerli bir deste boyutu seçin.', invalidCopies: 'Kopya sayısı sıfır ile deste boyutu arasında olmalıdır.', invalidHand: 'Başlangıç eli desteden büyük olamaz.', invalidDraws: 'Tur başına çekim sıfır veya daha fazla olmalıdır.', invalidTurns: 'Sıfır ile 60 arasında bir tur seçin.', cardsLabel: 'kart', copyLabel: 'kopya',
|
|
10
|
+
},
|
|
11
|
+
seo: [{ type: 'title', level: 2, text: 'Deste kart çekme olasılığını hesaplayın' }, { type: 'paragraph', html: 'Bu hesaplayıcı deste kurarken şu pratik soruyu yanıtlar: belirli bir tura kadar bir kartın en az bir kopyasını görme olasılığım nedir? Deste boyutu, kopyalar, başlangıç eli, çekimler ve mulligan kuralını girerek tur tur sonucu görün.' }, { type: 'paragraph', html: 'Kartlar geri konmadan çekildiği için hipergeometrik dağılım kullanılır. Bu model koleksiyon kart oyunları, masa oyunları, limited desteler ve diğer sonlu kart havuzları için uygundur.' }, { type: 'title', level: 2, text: 'Çekim zaman çizelgesi nasıl okunur' }, { type: 'list', items: ['Başlangıç eli, karıştırılmış desteden alınan ilk örnektir.', 'Her tur ayarlanan sayıda kart ekler.', 'Yüzde en az bir kopyayı gösterir; tam olarak bir kopyayı değil.', 'Beklenen kopya ayrı bir ortalamadır ve isabet olasılığıyla aynı şeyi ölçmez.'] }, { type: 'title', level: 2, text: 'Mulligan varsayımları ayarlanabilir' }, { type: 'paragraph', html: 'Yeniden çekme seçeneği basit bir stratejiyi modeller: hedefi içeren eli tut; hedef yoksa eli bir kez tamamen değiştir ve yeni yoldan devam et. Kısmi mulligan, scry, tutor ve diğer kartlara göre kararlar dahil değildir.' }, { type: 'tip', title: 'Oyunun gerçek kurallarını kullanın', html: 'Oyununuzda farklı bir başlangıç eli, ek çekim veya mulligan prosedürü varsa alanları değiştirin. Doğru sonuç kurallara ve kopya sayısına bağlıdır.' }, { type: 'title', level: 2, text: 'Hipergeometrik fikir tek satırda' }, { type: 'paragraph', html: 'N kartlık destede K hedef kopyası varsa, n kartta hedef bulamama olasılığı C(N − K, n) / C(N, n) olur. Hesaplayıcı bunun tümleyenini, 1 − hedefsiz olasılığı, kullanarak en az bir hedef kartı bulma ihtimalini verir.' }, { type: 'diagnostic', variant: 'info', title: 'Bu hesaplayıcı neyi simüle etmez', badge: 'SINIRLAR', html: 'İsimli kartları, seçimleri, değiştirme etkilerini, scry işlemlerini, tutorları, birden çok hedefi veya tam oyun durumunu izlemez. Sonucu temel alın ve ek kuralları ayrıca modelleyin.' }],
|
|
12
|
+
faq: [{ question: 'Kart çekme olasılığı formülü nedir?', answer: 'n kartta en az bir kopya için 1 − C(N − K, n) / C(N, n) kullanılır; N deste boyutu, K kopya sayısıdır.' }, { question: 'Beş kartlık çekim için çalışır mı?', answer: 'Evet. Başlangıç eli değerini 5 yapın ve oyununuzdaki çekim ve turları seçin.' }, { question: 'Mulligan seçeneği nasıl çalışır?', answer: 'Kaçırınca yeniden çek, hedef kopyası olmayan başlangıç elinin bir kez tamamen değiştirilmesini modeller. Oyunlara özel kısmi kurallardan daha basittir.' }, { question: 'Beklenen kopya neden isabet olasılığından farklıdır?', answer: 'İsabet olasılığı örnekte bir veya daha fazla kopya olup olmadığını sorar. Beklenen kopya örnekteki ortalama kopya sayısıdır.' }],
|
|
13
|
+
bibliography, howTo: [{ name: 'Deste boyutunu ve kopyaları girin', text: 'Toplam kart sayısını ve aradığınız kopya sayısını belirleyin.' }, { name: 'El ve çekim kurallarını eşleştirin', text: 'Başlangıç eli, tur başına kart ve gösterilecek son turu girin.' }, { name: 'Mulligan modeli seçin', text: 'Tek örnek için Mulligan yok seçeneğini veya tek tam değişim için kaçırınca yeniden çek seçeneğini kullanın.' }, { name: 'Zaman çizelgesini okuyun', text: 'Başlangıç elinde ve her turda en az bir kopya yüzdesini karşılaştırın.' }],
|
|
14
|
+
schemas: [{ '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: 'Kart Çekme Olasılığı Hesaplayıcı', applicationCategory: 'GameApplication', operatingSystem: 'Web', description: 'Sonlu tabletop desteleri için kart çekme olasılıkları.' }, { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: [{ '@type': 'Question', name: 'Kart çekme olasılığı formülü nedir?', acceptedAnswer: { '@type': 'Answer', text: 'Hesaplayıcı, hedef bulamamanın hipergeometrik olasılığının tümleyenini kullanır.' } }] }, { '@context': 'https://schema.org', '@type': 'HowTo', name: 'Kart çekme olasılığını hesaplama', step: [{ '@type': 'HowToStep', name: 'Deste kurallarını ayarla', text: 'Deste boyutu, kopyalar, el, çekimler ve turları girin.' }, { '@type': 'HowToStep', name: 'Sonucu okuyun', text: 'Zaman çizelgesinde en az bir kopya görme olasılığını karşılaştırın.' }] }],
|
|
15
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
2
|
+
import { bibliography } from '../bibliography';
|
|
3
|
+
import type { CardDrawOddsUI } from '../ui';
|
|
4
|
+
|
|
5
|
+
export const content: ToolLocaleContent<CardDrawOddsUI> = {
|
|
6
|
+
slug: 'card-draw-odds-calculator', title: '卡牌抽取概率计算器',
|
|
7
|
+
description: '使用超几何分布,计算在指定回合前抽到至少一张目标卡的概率。',
|
|
8
|
+
ui: {
|
|
9
|
+
configureLabel: '设置牌组规则', configureHint: '修改牌组、起手、抽牌和调度规则,结果会立即更新。', presetLabel: '起始方案', presetCustom: '自定义规则', presetStandard: '60张牌组 · 4张', presetLimited: '40张牌组 · 4张', presetSingleton: '100张牌组 · 1张', deckSizeLabel: '牌组大小', targetCopiesLabel: '目标卡数量', openingHandLabel: '起手牌', drawsPerTurnLabel: '每回合抽牌', throughTurnLabel: '显示至回合', mulliganLabel: '调度模型', mulliganNone: '不调度', mulliganFullRedraw: '未命中后重抽', visualLabel: '你的抽牌路径', visualHint: '样本中的牌越来越多时,扇形牌面会展开。', openingHandStage: '起手牌', turnLabel: '回合', cardsSeenLabel: '已看牌数', finalChanceLabel: '最终回合概率', expectedCopiesLabel: '预计看到的目标牌', probabilityByTurnLabel: '按回合查看概率', explanationLabel: '至少一张', noMulliganNote: '不调度:模型只计算洗牌后牌组的一次样本。', fullRedrawNote: '未命中后重抽:起手没有目标牌时,将整手牌替换一次。', invalidInput: '请检查牌组规则后继续。', invalidDeck: '请输入有效的牌组大小。', invalidCopies: '目标牌数量必须在0和牌组大小之间。', invalidHand: '起手牌不能多于牌组。', invalidDraws: '每回合抽牌数不能小于0。', invalidTurns: '请选择0到60之间的回合。', cardsLabel: '张牌', copyLabel: '张',
|
|
10
|
+
},
|
|
11
|
+
seo: [{ type: 'title', level: 2, text: '计算牌组抽到目标卡的概率' }, { type: 'paragraph', html: '这个计算器回答组牌时的实际问题:在指定回合前看到至少一张目标卡的概率是多少?输入牌组大小、目标牌数量、起手牌、每回合抽牌和调度规则,即可查看每回合的变化。' }, { type: 'paragraph', html: '由于抽牌后不会放回,计算使用超几何分布。它适用于集换式卡牌、桌游、限制赛牌组以及其他有限的牌池。' }, { type: 'title', level: 2, text: '如何阅读抽牌时间线' }, { type: 'list', items: ['起手牌是洗牌后牌组的第一份样本。', '每个回合会加入设置数量的牌。', '百分比表示至少一张,而不是刚好一张。', '预计牌数是独立的平均值,与命中概率不同。'] }, { type: 'title', level: 2, text: '调度假设可以调整' }, { type: 'paragraph', html: '重抽选项模拟简单策略:起手有目标卡就保留,没有目标卡时将整手牌替换一次,然后继续计算。它不包含部分调度、占卜、检索或基于其他牌的决策。' }, { type: 'tip', title: '使用游戏的真实规则', html: '如果游戏有不同的起手牌、额外抽牌或调度流程,请修改这些字段。准确结果取决于规则和牌组中的目标牌数量。' }, { type: 'title', level: 2, text: '一行理解超几何分布' }, { type: 'paragraph', html: '从含有N张牌、其中K张目标牌的牌组中看n张时,没有目标牌的概率是 C(N − K, n) / C(N, n)。计算器使用其补集,得到至少一张目标牌的概率。' }, { type: 'diagnostic', variant: 'info', title: '本计算器不会模拟什么', badge: '限制', html: '它不会追踪具体牌名、选牌、替换效果、占卜、检索、多个目标或完整的游戏状态。请把结果当作基准,再单独处理额外规则。' }],
|
|
12
|
+
faq: [{ question: '抽牌概率使用什么公式?', answer: '在n张牌中至少有一张目标牌的概率是 1 − C(N − K, n) / C(N, n),其中N是牌组大小,K是目标牌数量。' }, { question: '可以计算抽5张牌吗?', answer: '可以。把起手牌设置为5,并按照游戏规则填写抽牌数和回合。' }, { question: '调度选项如何计算?', answer: '未命中后重抽表示将没有目标牌的起手整手替换一次,比具体游戏的部分调度规则更简单。' }, { question: '预计牌数为什么不同于命中概率?', answer: '命中概率只询问样本中是否有一张或更多目标牌,预计牌数表示样本中的平均数量。' }],
|
|
13
|
+
bibliography, howTo: [{ name: '输入牌组大小和目标数量', text: '设置牌组总数以及想抽到的目标牌数量。' }, { name: '匹配起手和抽牌规则', text: '填写起手牌、每回合抽牌数和最后显示的回合。' }, { name: '选择调度模型', text: '连续样本选择不调度,需要整手替换时选择未命中后重抽。' }, { name: '阅读时间线', text: '比较起手和每个回合看到至少一张目标牌的概率。' }],
|
|
14
|
+
schemas: [{ '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: '卡牌抽取概率计算器', applicationCategory: 'GameApplication', operatingSystem: 'Web', description: '用于有限桌游牌组的抽牌概率计算器。' }, { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: [{ '@type': 'Question', name: '抽牌概率使用什么公式?', acceptedAnswer: { '@type': 'Answer', text: '计算器使用没有目标牌的超几何概率的补集。' } }] }, { '@context': 'https://schema.org', '@type': 'HowTo', name: '计算抽牌概率', step: [{ '@type': 'HowToStep', name: '设置牌组规则', text: '输入牌组大小、目标数量、起手、抽牌和回合。' }, { '@type': 'HowToStep', name: '查看结果', text: '在时间线中比较看到至少一张目标牌的概率。' }] }],
|
|
15
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../../types';
|
|
2
|
+
import { cardDrawOddsCalculator } from './entry';
|
|
3
|
+
|
|
4
|
+
export * from './entry';
|
|
5
|
+
|
|
6
|
+
export const CARD_DRAW_ODDS_CALCULATOR_TOOL: ToolDefinition = {
|
|
7
|
+
entry: cardDrawOddsCalculator,
|
|
8
|
+
Component: () => import('./component.astro'),
|
|
9
|
+
SEOComponent: () => import('./seo.astro'),
|
|
10
|
+
BibliographyComponent: () => import('./bibliography.astro'),
|
|
11
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { evaluateInput } from './evaluator';
|
|
3
|
+
import { calculateCardDrawOdds, DEFAULT_INPUT, probabilityAtLeastOne } from './logic';
|
|
4
|
+
|
|
5
|
+
describe('card draw odds calculator', () => {
|
|
6
|
+
it('matches the exact no-target complement for a 60 card deck', () => {
|
|
7
|
+
const result = probabilityAtLeastOne({ ...DEFAULT_INPUT, mulliganMode: 'none' }, 7);
|
|
8
|
+
expect(result).toBeCloseTo(1 - (56 * 55 * 54 * 53 * 52 * 51 * 50) / (60 * 59 * 58 * 57 * 56 * 55 * 54), 10);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('increases the opening chance when a full redraw is taken after a miss', () => {
|
|
12
|
+
const input = { ...DEFAULT_INPUT, mulliganMode: 'fullRedraw' as const };
|
|
13
|
+
const noMulligan = probabilityAtLeastOne({ ...input, mulliganMode: 'none' }, 7);
|
|
14
|
+
const withMulligan = probabilityAtLeastOne(input, 7);
|
|
15
|
+
expect(withMulligan).toBeGreaterThan(noMulligan);
|
|
16
|
+
expect(withMulligan).toBeCloseTo(1 - (1 - noMulligan) ** 2, 10);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('returns a monotonic timeline and a finite expected count', () => {
|
|
20
|
+
const result = calculateCardDrawOdds(DEFAULT_INPUT);
|
|
21
|
+
expect(result.points).toHaveLength(7);
|
|
22
|
+
expect(result.points[0]?.cardsSeen).toBe(7);
|
|
23
|
+
expect(result.points.at(-1)?.cardsSeen).toBe(13);
|
|
24
|
+
expect(result.points.every((point, index, points) => index === 0 || point.probability >= (points[index - 1]?.probability ?? 0))).toBe(true);
|
|
25
|
+
expect(result.expectedCopiesAtFinal).toBeGreaterThan(4 * 13 / 60);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('handles a deck with no copies as zero probability', () => {
|
|
29
|
+
const result = calculateCardDrawOdds({ ...DEFAULT_INPUT, targetCopies: 0 });
|
|
30
|
+
expect(result.finalProbability).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('rejects each invalid input family with a localized error key', () => {
|
|
34
|
+
expect(evaluateInput({ ...DEFAULT_INPUT, deckSize: 0 }).errorKey).toBe('invalidDeck');
|
|
35
|
+
expect(evaluateInput({ ...DEFAULT_INPUT, targetCopies: 61 }).errorKey).toBe('invalidCopies');
|
|
36
|
+
expect(evaluateInput({ ...DEFAULT_INPUT, openingHand: 61 }).errorKey).toBe('invalidHand');
|
|
37
|
+
expect(evaluateInput({ ...DEFAULT_INPUT, drawsPerTurn: -1 }).errorKey).toBe('invalidDraws');
|
|
38
|
+
expect(evaluateInput({ ...DEFAULT_INPUT, throughTurn: 61 }).errorKey).toBe('invalidTurns');
|
|
39
|
+
expect(evaluateInput(DEFAULT_INPUT)).toEqual({ valid: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('handles boundary samples without producing invalid probabilities', () => {
|
|
43
|
+
expect(probabilityAtLeastOne({ ...DEFAULT_INPUT, targetCopies: DEFAULT_INPUT.deckSize }, 0)).toBe(1);
|
|
44
|
+
expect(probabilityAtLeastOne({ ...DEFAULT_INPUT, targetCopies: 0 }, 13)).toBe(0);
|
|
45
|
+
const result = calculateCardDrawOdds({ ...DEFAULT_INPUT, throughTurn: 0 });
|
|
46
|
+
expect(result.points).toHaveLength(1);
|
|
47
|
+
expect(result.points[0]?.stage).toBe('opening');
|
|
48
|
+
expect(result.points.every((point) => point.probability >= 0 && point.probability <= 1)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
export type MulliganMode = 'none' | 'fullRedraw';
|
|
2
|
+
|
|
3
|
+
export interface CardDrawInput {
|
|
4
|
+
deckSize: number;
|
|
5
|
+
targetCopies: number;
|
|
6
|
+
openingHand: number;
|
|
7
|
+
drawsPerTurn: number;
|
|
8
|
+
throughTurn: number;
|
|
9
|
+
mulliganMode: MulliganMode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DrawOddsPoint {
|
|
13
|
+
stage: 'opening' | 'turn';
|
|
14
|
+
turn: number;
|
|
15
|
+
cardsSeen: number;
|
|
16
|
+
probability: number;
|
|
17
|
+
expectedCopies: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CardDrawResult {
|
|
21
|
+
points: DrawOddsPoint[];
|
|
22
|
+
finalProbability: number;
|
|
23
|
+
openingProbability: number;
|
|
24
|
+
expectedCopiesAtFinal: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_INPUT: CardDrawInput = {
|
|
28
|
+
deckSize: 60,
|
|
29
|
+
targetCopies: 4,
|
|
30
|
+
openingHand: 7,
|
|
31
|
+
drawsPerTurn: 1,
|
|
32
|
+
throughTurn: 6,
|
|
33
|
+
mulliganMode: 'fullRedraw',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const PRESETS: Record<string, Partial<CardDrawInput>> = {
|
|
37
|
+
standard: { deckSize: 60, targetCopies: 4, openingHand: 7, drawsPerTurn: 1, throughTurn: 6 },
|
|
38
|
+
limited: { deckSize: 40, targetCopies: 4, openingHand: 7, drawsPerTurn: 1, throughTurn: 6 },
|
|
39
|
+
singleton: { deckSize: 100, targetCopies: 1, openingHand: 7, drawsPerTurn: 1, throughTurn: 6 },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const clampProbability = (value: number): number => Math.min(1, Math.max(0, value));
|
|
43
|
+
|
|
44
|
+
const logCombination = (n: number, k: number): number => {
|
|
45
|
+
if (k < 0 || k > n) return Number.NEGATIVE_INFINITY;
|
|
46
|
+
const count = Math.min(k, n - k);
|
|
47
|
+
let total = 0;
|
|
48
|
+
for (let index = 1; index <= count; index += 1) {
|
|
49
|
+
total += Math.log(n - count + index) - Math.log(index);
|
|
50
|
+
}
|
|
51
|
+
return total;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const noTargetProbability = (input: CardDrawInput, cardsSeen: number): number => {
|
|
55
|
+
if (input.targetCopies === 0) return 1;
|
|
56
|
+
if (cardsSeen > input.deckSize - input.targetCopies) return 0;
|
|
57
|
+
const logNoTarget = logCombination(input.deckSize - input.targetCopies, cardsSeen);
|
|
58
|
+
const logSample = logCombination(input.deckSize, cardsSeen);
|
|
59
|
+
return clampProbability(Math.exp(logNoTarget - logSample));
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const expectedCopiesAt = (input: CardDrawInput, cardsSeen: number): number => {
|
|
63
|
+
const baseline = input.targetCopies * Math.min(cardsSeen, input.deckSize) / input.deckSize;
|
|
64
|
+
if (input.mulliganMode === 'none' || input.openingHand === input.deckSize) return baseline;
|
|
65
|
+
const drawCards = Math.max(0, cardsSeen - input.openingHand);
|
|
66
|
+
const expectedDrawsAfterMiss = drawCards * input.targetCopies / (input.deckSize - input.openingHand);
|
|
67
|
+
const expectedReplacement = baseline;
|
|
68
|
+
return baseline + noTargetProbability(input, input.openingHand) * (expectedReplacement - expectedDrawsAfterMiss);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const probabilityAtLeastOne = (input: CardDrawInput, cardsSeen: number): number => {
|
|
72
|
+
const noTarget = noTargetProbability(input, cardsSeen);
|
|
73
|
+
if (input.mulliganMode === 'none') return clampProbability(1 - noTarget);
|
|
74
|
+
const noOpeningTarget = noTargetProbability(input, input.openingHand);
|
|
75
|
+
return clampProbability(1 - noOpeningTarget * noTarget);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const createPoint = (input: CardDrawInput, stage: 'opening' | 'turn', turn: number, cardsSeen: number): DrawOddsPoint => ({
|
|
79
|
+
stage,
|
|
80
|
+
turn,
|
|
81
|
+
cardsSeen,
|
|
82
|
+
probability: probabilityAtLeastOne(input, cardsSeen),
|
|
83
|
+
expectedCopies: expectedCopiesAt(input, cardsSeen),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export const calculateCardDrawOdds = (input: CardDrawInput): CardDrawResult => {
|
|
87
|
+
const points = [createPoint(input, 'opening', 0, input.openingHand)];
|
|
88
|
+
for (let turn = 1; turn <= input.throughTurn; turn += 1) {
|
|
89
|
+
const cardsSeen = Math.min(input.deckSize, input.openingHand + input.drawsPerTurn * turn);
|
|
90
|
+
points.push(createPoint(input, 'turn', turn, cardsSeen));
|
|
91
|
+
}
|
|
92
|
+
const openingPoint = points[0] as DrawOddsPoint;
|
|
93
|
+
const finalPoint = points[points.length - 1] as DrawOddsPoint;
|
|
94
|
+
return {
|
|
95
|
+
points,
|
|
96
|
+
finalProbability: finalPoint.probability,
|
|
97
|
+
openingProbability: openingPoint.probability,
|
|
98
|
+
expectedCopiesAtFinal: finalPoint.expectedCopies,
|
|
99
|
+
};
|
|
100
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { cardDrawOddsCalculator } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props;
|
|
11
|
+
const loader = cardDrawOddsCalculator.i18n[locale] || cardDrawOddsCalculator.i18n.en;
|
|
12
|
+
const content = await loader?.();
|
|
13
|
+
if (!content) return null;
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { CardDrawInput } from './logic';
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY = 'card-draw-odds-calculator-input';
|
|
4
|
+
|
|
5
|
+
export const loadCardDrawInput = (): Partial<CardDrawInput> => {
|
|
6
|
+
if (typeof localStorage === 'undefined') return {};
|
|
7
|
+
try {
|
|
8
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
9
|
+
return stored ? JSON.parse(stored) as Partial<CardDrawInput> : {};
|
|
10
|
+
} catch {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const saveCardDrawInput = (input: CardDrawInput): void => {
|
|
16
|
+
if (typeof localStorage === 'undefined') return;
|
|
17
|
+
try {
|
|
18
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(input));
|
|
19
|
+
} catch {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface CardDrawOddsUI {
|
|
2
|
+
[key: string]: string;
|
|
3
|
+
configureLabel: string;
|
|
4
|
+
configureHint: string;
|
|
5
|
+
presetLabel: string;
|
|
6
|
+
presetCustom: string;
|
|
7
|
+
presetStandard: string;
|
|
8
|
+
presetLimited: string;
|
|
9
|
+
presetSingleton: string;
|
|
10
|
+
deckSizeLabel: string;
|
|
11
|
+
targetCopiesLabel: string;
|
|
12
|
+
openingHandLabel: string;
|
|
13
|
+
drawsPerTurnLabel: string;
|
|
14
|
+
throughTurnLabel: string;
|
|
15
|
+
mulliganLabel: string;
|
|
16
|
+
mulliganNone: string;
|
|
17
|
+
mulliganFullRedraw: string;
|
|
18
|
+
visualLabel: string;
|
|
19
|
+
visualHint: string;
|
|
20
|
+
openingHandStage: string;
|
|
21
|
+
turnLabel: string;
|
|
22
|
+
cardsSeenLabel: string;
|
|
23
|
+
finalChanceLabel: string;
|
|
24
|
+
expectedCopiesLabel: string;
|
|
25
|
+
probabilityByTurnLabel: string;
|
|
26
|
+
explanationLabel: string;
|
|
27
|
+
noMulliganNote: string;
|
|
28
|
+
fullRedrawNote: string;
|
|
29
|
+
invalidInput: string;
|
|
30
|
+
invalidDeck: string;
|
|
31
|
+
invalidCopies: string;
|
|
32
|
+
invalidHand: string;
|
|
33
|
+
invalidDraws: string;
|
|
34
|
+
invalidTurns: string;
|
|
35
|
+
cardsLabel: string;
|
|
36
|
+
copyLabel: string;
|
|
37
|
+
}
|
|
@@ -71,7 +71,7 @@ export const content: ToolLocaleContent = {
|
|
|
71
71
|
con: 'Los resultados de D20 son muy inestables, haciendo que la habilidad del personaje dependa demasiado del azar.',
|
|
72
72
|
},
|
|
73
73
|
{
|
|
74
|
-
pro: 'Las curvas de campana de 3d6 priorizan los resultados medios,
|
|
74
|
+
pro: 'Las curvas de campana de 3d6 priorizan los resultados medios, por lo que los personajes hábiles suelen fallar menos tareas fáciles.',
|
|
75
75
|
con: 'Los sistemas de 3d6 exigen sumar tres dados y hacen que los impactos críticos (un 18 perfecto) sean extremadamente raros (0.46%).',
|
|
76
76
|
},
|
|
77
77
|
],
|
|
@@ -182,7 +182,7 @@ export const content: ToolLocaleContent = {
|
|
|
182
182
|
},
|
|
183
183
|
{
|
|
184
184
|
question: '¿Qué son los éxitos críticos y las pifias?',
|
|
185
|
-
answer: 'Un éxito crítico ocurre cuando sacas el máximo del dado (un 20 natural en D20),
|
|
185
|
+
answer: 'Un éxito crítico ocurre cuando sacas el máximo del dado (un 20 natural en D20), y suele considerarse un éxito. Una pifia es sacar un 1 en el dado, que suele considerarse un fallo.',
|
|
186
186
|
},
|
|
187
187
|
{
|
|
188
188
|
question: '¿Cómo calcula el simulador las probabilidades de las tiradas?',
|
|
@@ -258,7 +258,7 @@ export const content: ToolLocaleContent = {
|
|
|
258
258
|
'name': '¿Qué son los éxitos críticos y las pifias?',
|
|
259
259
|
'acceptedAnswer': {
|
|
260
260
|
'@type': 'Answer',
|
|
261
|
-
'text': 'Un éxito crítico ocurre cuando sacas el máximo del dado (un 20 natural en D20),
|
|
261
|
+
'text': 'Un éxito crítico ocurre cuando sacas el máximo del dado (un 20 natural en D20), y suele considerarse un éxito. Una pifia es sacar un 1 en el dado, que suele considerarse un fallo.'
|
|
262
262
|
}
|
|
263
263
|
},
|
|
264
264
|
{
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
---
|
|
2
|
-
import { buildMapSvg } from './dom-views';
|
|
1
|
+
---
|
|
2
|
+
import { buildMapSvg } from './dom-views';
|
|
3
3
|
import { evaluateMap } from './evaluator';
|
|
4
4
|
import { DEFAULT_CONFIG, generateDungeon } from './logic';
|
|
5
5
|
import type { DungeonMapGeneratorUI } from './ui';
|
|
@@ -15,13 +15,15 @@ const initialEvaluation = evaluateMap(initialMap, ui);
|
|
|
15
15
|
|
|
16
16
|
<div class="dmg" data-dungeon-generator data-style="dungeon">
|
|
17
17
|
<script is:inline type="application/json" data-ui set:html={JSON.stringify(ui)} />
|
|
18
|
-
<section class="dmg-controls" aria-label={ui.styleLabel}>
|
|
19
|
-
<div class="dmg-
|
|
18
|
+
<section class="dmg-controls" aria-label={ui.styleLabel}>
|
|
19
|
+
<div class="dmg-intro">{ui.intro}</div>
|
|
20
|
+
<div class="dmg-field dmg-field-seed">
|
|
20
21
|
<label for="dmg-seed-input">{ui.seedLabel}</label>
|
|
21
22
|
<div class="dmg-seed-input-group">
|
|
22
|
-
<input id="dmg-seed-input" name="seed" type="text" value={DEFAULT_CONFIG.seed} maxlength="48" spellcheck="false" />
|
|
23
|
-
<button class="dmg-shuffle" type="button" data-random-seed>{ui.randomSeed}</button>
|
|
24
|
-
</div>
|
|
23
|
+
<input id="dmg-seed-input" name="seed" type="text" value={DEFAULT_CONFIG.seed} maxlength="48" spellcheck="false" />
|
|
24
|
+
<button class="dmg-shuffle" type="button" data-random-seed>{ui.randomSeed}</button>
|
|
25
|
+
</div>
|
|
26
|
+
<small>{ui.seedHint}</small>
|
|
25
27
|
</div>
|
|
26
28
|
|
|
27
29
|
<fieldset class="dmg-styles">
|
|
@@ -103,11 +105,13 @@ const initialEvaluation = evaluateMap(initialMap, ui);
|
|
|
103
105
|
<div><dt>{ui.doorsLabel}</dt><dd data-door-count>{initialMap.doorCount}</dd></div>
|
|
104
106
|
<div><dt>{ui.floorLabel}</dt><dd data-floor-percent>{initialEvaluation.coverage}%</dd></div>
|
|
105
107
|
</dl>
|
|
106
|
-
<div class="dmg-legend" aria-label={ui.mapReady}>
|
|
107
|
-
<span
|
|
108
|
-
<span
|
|
109
|
-
<span><i data-tile="
|
|
110
|
-
|
|
108
|
+
<div class="dmg-legend" aria-label={ui.mapReady}>
|
|
109
|
+
<span class="dmg-connected-badge">{ui.connectedBadge}</span>
|
|
110
|
+
<span class="dmg-dimensions" data-dimensions>{initialMap.config.columns} × {initialMap.config.rows} {ui.dimensionsUnit}</span>
|
|
111
|
+
<span><i data-tile="floor"></i>{ui.legendFloor}</span>
|
|
112
|
+
<span><i data-tile="wall"></i>{ui.legendWall}</span>
|
|
113
|
+
<span><i data-tile="door"></i>{ui.legendDoor}</span>
|
|
114
|
+
</div>
|
|
111
115
|
</div>
|
|
112
116
|
|
|
113
117
|
<div class="dmg-actions">
|
|
@@ -67,10 +67,11 @@ export function renderMap(context: RenderContext): void {
|
|
|
67
67
|
const mapHost = root.querySelector<HTMLElement>('[data-map-host]');
|
|
68
68
|
if (mapHost) mapHost.innerHTML = buildMapSvg(map);
|
|
69
69
|
setText(root, '[data-map-seed]', map.config.seed);
|
|
70
|
-
setText(root, '[data-room-count]', String(map.rooms.length));
|
|
71
|
-
setText(root, '[data-door-count]', String(map.doorCount));
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
setText(root, '[data-room-count]', String(map.rooms.length));
|
|
71
|
+
setText(root, '[data-door-count]', String(map.doorCount));
|
|
72
|
+
setText(root, '[data-dimensions]', `${map.config.columns} × ${map.config.rows} ${context.ui.dimensionsUnit}`);
|
|
73
|
+
renderEvaluation(context);
|
|
74
|
+
}
|
|
74
75
|
|
|
75
76
|
function renderEvaluation(context: RenderContext): void {
|
|
76
77
|
const evaluation = evaluateMap(context.map, context.ui);
|
|
@@ -63,7 +63,7 @@ const faq = [
|
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
question: '¿Todas las salas generadas son accesibles entre sí?',
|
|
66
|
-
answer: 'Sí. Las salas se conectan mediante una ruta principal tras su colocación,
|
|
66
|
+
answer: 'Sí. Las salas se conectan mediante una ruta principal tras su colocación, de modo que cada región transitable queda dentro de un único mapa conectado.',
|
|
67
67
|
},
|
|
68
68
|
{
|
|
69
69
|
question: '¿Qué cambia entre los estilos mazmorra, cueva y ciencia ficción?',
|
|
@@ -139,7 +139,7 @@ export const content: DungeonMapGeneratorLocaleContent = {
|
|
|
139
139
|
ui,
|
|
140
140
|
seo: [
|
|
141
141
|
{ type: 'title', text: 'Genera un Mapa de Mazmorra Jugable mediante una Semilla Reutilizable', level: 2 },
|
|
142
|
-
{ type: 'paragraph', html: 'Un generador de mapas de mazmorras aleatorios necesita ir más allá del ruido visual aleatorio. Cada cámara debe ser alcanzable mediante pasillos conectados, la cuadrícula debe adaptarse a la superficie de juego y el resultado debe poder recuperarse en cualquier momento. Este generador trata la semilla y los controles como una especificación de mapa compacta y determinista. Mantener la misma semilla, filas, columnas y estilo
|
|
142
|
+
{ type: 'paragraph', html: 'Un generador de mapas de mazmorras aleatorios necesita ir más allá del ruido visual aleatorio. Cada cámara debe ser alcanzable mediante pasillos conectados, la cuadrícula debe adaptarse a la superficie de juego y el resultado debe poder recuperarse en cualquier momento. Este generador trata la semilla y los controles como una especificación de mapa compacta y determinista. Mantener la misma semilla, filas, columnas y estilo permite reconstruir la misma estructura más adelante.' },
|
|
143
143
|
{ type: 'title', text: 'Dimensiones de Cuadrícula para Impresión y Tableros Virtuales', level: 2 },
|
|
144
144
|
{ type: 'paragraph', html: 'Configurar el tamaño de la cuadrícula es el primer paso práctico. Una cuadrícula pequeña es fácil de imprimir en papel y resulta ideal para aventuras cortas, mientras que un mapa amplio deja espacio para exploración compleja y múltiples facciones. La imagen exportada mantiene celdas perfectamente cuadradas para alinearse sin esfuerzo con la rejilla de cualquier software de tablero virtual (VTT).' },
|
|
145
145
|
{
|
|
@@ -16,7 +16,14 @@ function renderMarkers(count: number, className: string, element: string): strin
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
function renderHint(result: EncounterResult, labels: Labels): string {
|
|
19
|
-
|
|
19
|
+
const hints: Record<EncounterDifficulty, string> = {
|
|
20
|
+
belowEasy: labels.belowEasyHint ?? '',
|
|
21
|
+
easy: labels.easyHint ?? '',
|
|
22
|
+
medium: labels.mediumHint ?? '',
|
|
23
|
+
hard: labels.hardHint ?? '',
|
|
24
|
+
deadly: labels.deadlyHint ?? '',
|
|
25
|
+
};
|
|
26
|
+
return hints[result.difficulty];
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
function renderWarnings(result: EncounterResult, labels: Labels): string {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALPHABETS, getAlphabet, getShuffledMapping, translateText } from './logic';
|
|
3
|
+
|
|
4
|
+
describe('fantasy runes translator logic', () => {
|
|
5
|
+
it('translates a documented Elder Futhark sample', () => {
|
|
6
|
+
expect(translateText('abc', 'elder-futhark').raw).toBe('ᚨᛒᚲ');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('falls back to the first alphabet for an unknown key', () => {
|
|
10
|
+
expect(getAlphabet('not-a-real-alphabet' as never)).toBe(ALPHABETS[0]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('preserves spaces and leaves unsupported characters unchanged', () => {
|
|
14
|
+
const result = translateText('a 1', 'elder-futhark');
|
|
15
|
+
expect(result.raw).toBe('ᚨ 1');
|
|
16
|
+
expect(result.characters).toHaveLength(3);
|
|
17
|
+
expect(result.characters[1]?.char.name).toBe('Space');
|
|
18
|
+
expect(result.characters[2]?.char).toBeNull();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('keeps seeded mappings deterministic and changes the assignment', () => {
|
|
22
|
+
const first = getShuffledMapping('elder-futhark', 42);
|
|
23
|
+
const second = getShuffledMapping('elder-futhark', 42);
|
|
24
|
+
expect(second).toEqual(first);
|
|
25
|
+
expect(first).not.toEqual(getShuffledMapping('elder-futhark', null));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('handles an empty input without creating characters', () => {
|
|
29
|
+
expect(translateText('', 'fantasy').characters).toEqual([]);
|
|
30
|
+
expect(translateText('', 'fantasy').raw).toBe('');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -84,7 +84,7 @@ export const content: ToolLocaleContent<FirstPlayerSelectorUI> = {
|
|
|
84
84
|
ui,
|
|
85
85
|
seo: [
|
|
86
86
|
{ type: 'title', text: 'Fairness and Strategy: Resolving the First-Player Advantage in Board Games', level: 2 },
|
|
87
|
-
{ type: 'paragraph', html: 'Determining the starting player in tabletop sessions can heavily influence game dynamics. In game theory, the First-Player Advantage (FPA) refers to the statistical edge that the player taking the first turn has over their opponents. In strategic chess matches, wargames, and modern Eurogames like Agricola or Puerto Rico, taking the first action allows a player to secure critical resources or position pieces before opponents can react. To mitigate FPA and
|
|
87
|
+
{ type: 'paragraph', html: 'Determining the starting player in tabletop sessions can heavily influence game dynamics. In game theory, the First-Player Advantage (FPA) refers to the statistical edge that the player taking the first turn has over their opponents. In strategic chess matches, wargames, and modern Eurogames like Agricola or Puerto Rico, taking the first action allows a player to secure critical resources or position pieces before opponents can react. To mitigate FPA and support a balanced playing field, a reliable, unbiased selection method is useful. Our digital starting player selector uses a random selection step, allowing gaming groups to establish turn order instantly and get straight to playing.' },
|
|
88
88
|
{
|
|
89
89
|
type: 'stats',
|
|
90
90
|
items: [
|
|
@@ -34,7 +34,7 @@ const faq = [
|
|
|
34
34
|
},
|
|
35
35
|
{
|
|
36
36
|
question: '¿La selección es realmente aleatoria?',
|
|
37
|
-
answer: 'Sí. El algoritmo de selección utiliza Math.random() de JavaScript
|
|
37
|
+
answer: 'Sí. El algoritmo de selección utiliza Math.random() de JavaScript para buscar resultados imparciales. En el modo táctil, el ganador se elige al azar entre todos los puntos de contacto detectados. En el modo ruleta, la rueda se desacelera de forma realista usando física de fricción, y el ángulo final determina matemáticamente al ganador.',
|
|
38
38
|
},
|
|
39
39
|
{
|
|
40
40
|
question: '¿Cómo elimino un pin de jugador en el modo ruleta?',
|
|
@@ -84,7 +84,7 @@ export const content: ToolLocaleContent<FirstPlayerSelectorUI> = {
|
|
|
84
84
|
ui,
|
|
85
85
|
seo: [
|
|
86
86
|
{ type: 'title', text: 'Justicia y Estrategia: Cómo Evitar la Ventaja del Primer Jugador', level: 2 },
|
|
87
|
-
{ type: 'paragraph', html: 'Determinar el jugador que comienza en las partidas de mesa puede influir enormemente en la dinámica del juego. En teoría de juegos, la Ventaja del Primer Jugador (FPA, por sus siglas en inglés) se refiere a la ventaja estadística que tiene el jugador que realiza el primer turno sobre sus oponentes. En partidas de ajedrez estratégico, wargames y Eurogames modernos como Agricola o Puerto Rico, realizar la primera acción permite asegurar recursos críticos o posicionar piezas antes de que los oponentes puedan reaccionar. Para mitigar la FPA y
|
|
87
|
+
{ type: 'paragraph', html: 'Determinar el jugador que comienza en las partidas de mesa puede influir enormemente en la dinámica del juego. En teoría de juegos, la Ventaja del Primer Jugador (FPA, por sus siglas en inglés) se refiere a la ventaja estadística que tiene el jugador que realiza el primer turno sobre sus oponentes. En partidas de ajedrez estratégico, wargames y Eurogames modernos como Agricola o Puerto Rico, realizar la primera acción permite asegurar recursos críticos o posicionar piezas antes de que los oponentes puedan reaccionar. Para mitigar la FPA y favorecer un campo de juego equilibrado, es útil un método de selección fiable e imparcial. Nuestro selector digital de jugador inicial usa una selección aleatoria, permitiendo a los grupos de juego establecer el orden de turnos al instante y pasar directamente a jugar.' },
|
|
88
88
|
{
|
|
89
89
|
type: 'stats',
|
|
90
90
|
items: [
|
|
@@ -109,7 +109,7 @@ export const content: ToolLocaleContent<FirstPlayerSelectorUI> = {
|
|
|
109
109
|
{
|
|
110
110
|
type: 'tip',
|
|
111
111
|
title: 'Optimizando tu configuración de selección multitáctil',
|
|
112
|
-
html: 'Para obtener la mejor experiencia en el modo táctil, coloca tu tableta o teléfono inteligente plano en el centro de la mesa. Todos los jugadores deben colocar un dedo claramente sobre el lienzo. La cuenta atrás se reinicia si un jugador levanta o mueve el dedo, evitando selecciones accidentales y
|
|
112
|
+
html: 'Para obtener la mejor experiencia en el modo táctil, coloca tu tableta o teléfono inteligente plano en el centro de la mesa. Todos los jugadores deben colocar un dedo claramente sobre el lienzo. La cuenta atrás se reinicia si un jugador levanta o mueve el dedo, evitando selecciones accidentales y dando al grupo la oportunidad de confirmar antes de que el selector se active.',
|
|
113
113
|
},
|
|
114
114
|
{
|
|
115
115
|
type: 'title',
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
---
|
|
2
|
-
import './online-mus-scoreboard.css';
|
|
3
2
|
import { musScoreboard } from './index';
|
|
4
3
|
import type { KnownLocale } from '../../types';
|
|
5
4
|
import type { MusScoreboardUI } from './ui';
|
|
@@ -113,13 +112,13 @@ if (!ui) return null;
|
|
|
113
112
|
<div class="mus-felt-center">
|
|
114
113
|
<span class="mus-center-label">{ui.scoreLabel}</span>
|
|
115
114
|
<strong><span data-score="0">0</span><i>:</i><span data-score="1">0</span></strong>
|
|
116
|
-
<span class="mus-center-status" data-status-message>{ui.
|
|
115
|
+
<span class="mus-center-status" data-status-message>{ui.matchReady}</span>
|
|
117
116
|
</div>
|
|
118
117
|
</div>
|
|
119
118
|
|
|
120
119
|
<div class="mus-pair-score-row">
|
|
121
120
|
<article class="mus-pair-card mus-pair-card-left">
|
|
122
|
-
<header><span class="mus-pair-index">A</span><h3 data-pair-label="0"></h3><span class="mus-game-count"><span data-games="0">0</span> {ui.gameLabel}</span></header>
|
|
121
|
+
<header><span class="mus-pair-index">A</span><h3 data-pair-label="0"></h3><span class="mus-game-count"><span data-games="0">0</span> <span data-game-unit="0">{ui.gameLabel}</span></span></header>
|
|
123
122
|
<p class="mus-score-breakdown" data-breakdown="0">0 {ui.amarracoPlural} + 0 {ui.stonePlural}</p>
|
|
124
123
|
<p class="mus-remaining" data-remaining="0">40 {ui.pointsToGo}</p>
|
|
125
124
|
<div class="mus-tally" data-tally="0" aria-label={ui.stonesLabel}></div>
|
|
@@ -130,7 +129,7 @@ if (!ui) return null;
|
|
|
130
129
|
</div>
|
|
131
130
|
</article>
|
|
132
131
|
<article class="mus-pair-card mus-pair-card-right">
|
|
133
|
-
<header><span class="mus-pair-index">B</span><h3 data-pair-label="1"></h3><span class="mus-game-count"><span data-games="1">0</span> {ui.gameLabel}</span></header>
|
|
132
|
+
<header><span class="mus-pair-index">B</span><h3 data-pair-label="1"></h3><span class="mus-game-count"><span data-games="1">0</span> <span data-game-unit="1">{ui.gameLabel}</span></span></header>
|
|
134
133
|
<p class="mus-score-breakdown" data-breakdown="1">0 {ui.amarracoPlural} + 0 {ui.stonePlural}</p>
|
|
135
134
|
<p class="mus-remaining" data-remaining="1">40 {ui.pointsToGo}</p>
|
|
136
135
|
<div class="mus-tally" data-tally="1" aria-label={ui.stonesLabel}></div>
|
|
@@ -84,7 +84,8 @@ const confirmClose = (context: ControllerContext, team: Team): void => {
|
|
|
84
84
|
showConfirmation(context, `${context.state.config.pairNames[team]}: ${context.ui.closeGamePrompt}`, () => {
|
|
85
85
|
context.state = closeGame(context.state, team);
|
|
86
86
|
const winner = context.state.games[team] >= context.state.config.gamesToWin;
|
|
87
|
-
|
|
87
|
+
const gameMessage = `${context.state.config.pairNames[team]} ${context.ui.gameWon}`;
|
|
88
|
+
refresh(context, winner ? `${gameMessage}. ${context.ui.vacaWon}` : `${context.state.config.pairNames[team]} ${context.ui.gameClosed}`);
|
|
88
89
|
});
|
|
89
90
|
};
|
|
90
91
|
|
|
@@ -55,6 +55,11 @@ const renderHistory = (root: ParentNode, state: MusState, ui: MusScoreboardUI):
|
|
|
55
55
|
});
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
const renderGameCount = (root: ParentNode, team: Team, games: number, ui: MusScoreboardUI): void => {
|
|
59
|
+
setText(root, `[data-games="${team}"]`, String(games));
|
|
60
|
+
setText(root, `[data-game-unit="${team}"]`, games === 1 ? ui.gameLabel : ui.gamePlural);
|
|
61
|
+
};
|
|
62
|
+
|
|
58
63
|
export const renderMusState = (root: ParentNode, state: MusState, ui: MusScoreboardUI, message: string): void => {
|
|
59
64
|
const felt = query<HTMLElement>(root, '[data-mus-felt]');
|
|
60
65
|
felt.dataset.handTeam = String(state.hand);
|
|
@@ -73,7 +78,7 @@ export const renderMusState = (root: ParentNode, state: MusState, ui: MusScorebo
|
|
|
73
78
|
setTeamProgress(root, index, score, state.config.targetPoints);
|
|
74
79
|
renderTally(root, index, score, ui);
|
|
75
80
|
});
|
|
76
|
-
state.games.forEach((games, team) =>
|
|
81
|
+
state.games.forEach((games, team) => renderGameCount(root, team, games, ui));
|
|
77
82
|
const matchWinner = getMatchWinner(state);
|
|
78
83
|
state.points.forEach((_, team) => {
|
|
79
84
|
const closeButton = query<HTMLButtonElement>(root, `[data-action="close-game"][data-team="${team}"]`);
|