게임 4개 원래 이름으로, .5 + NPMplus 배포 구성

- 보석 상인 → 스플렌더, 벽 쌓기 길찾기 → 쿼리도, 숫자 타일 → 루미큐브, 주사위 땅따먹기 → 부루마불
- deploy/docker-compose.proxy.yml: Caddy 없이 앱만(.5:2970), HTTPS·헤더는 NPMplus
- deploy/npmplus-advanced.conf: Caddyfile과 같은 보안 헤더
- 문서: 2026-10-06 결정, 배포 7절 (e2e 구십구 0 카드 방향 버튼 반영 포함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-06 22:16:32 +09:00
parent 7da06d1b27
commit 6390e3e64c
45 changed files with 143 additions and 78 deletions

View File

@@ -58,7 +58,7 @@ async function finishHand(pages: Page[]) {
for (const p of pages) await p.getByText('이번 판 결과').first().waitFor({ timeout: 10_000 });
}
/** 숫자 타일: whoever has the turn tries "도와줘" → "새 묶음" → "확정"; otherwise takes one tile. */
/** 루미큐브: whoever has the turn tries "도와줘" → "새 묶음" → "확정"; otherwise takes one tile. */
async function rummikubTurns(pages: Page[], turns: number) {
const stats = { plays: 0, draws: 0, undo: false };
const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'));
@@ -220,7 +220,7 @@ async function halliTotals(p: Page): Promise<Record<string, number>> {
}
/**
* 주사위 땅따먹기: one UI step for whoever can act on `p` — answer a trade, sell, buy/build (or skip),
* 부루마불: one UI step for whoever can act on `p` — answer a trade, sell, buy/build (or skip),
* pick a space-travel square on the board, escape the island or roll. Returns the label pressed.
*/
const tag = (p: Page) => ((p.viewportSize()?.width ?? 0) < 600 ? 'phone' : 'pc');
@@ -329,7 +329,7 @@ async function dalmutiStep(p: Page, isHost: boolean, st: Record<string, number>)
}
/**
* 보석 상인: one UI action for whoever has the turn on `p` — return tokens / choose a noble / pass when
* 스플렌더: one UI action for whoever has the turn on `p` — return tokens / choose a noble / pass when
* asked, otherwise buy a highlighted card, reserve now and then, take 2 of a colour, or 3 different gems.
* Returns the kind of action or null when it isn't this page's turn.
*/
@@ -419,7 +419,7 @@ async function splendorStep(p: Page, stats: Record<string, number>, turn: number
return null;
}
/** 벽 쌓기 길찾기: 판의 data 속성(모델 좌표 a1~i9)을 읽어 PC는 클릭, 휴대폰은 탭으로 조작한다. */
/** 쿼리도: 판의 data 속성(모델 좌표 a1~i9)을 읽어 PC는 클릭, 휴대폰은 탭으로 조작한다. */
const qdSvg = (p: Page) => p.locator('svg[data-my-seat]');
async function qdTap(p: Page, target: ReturnType<Page['locator']>) {
if (tag(p) === 'phone') await target.tap();
@@ -623,9 +623,11 @@ async function ninetyNineStep(p: Page, stats: Record<string, number>): Promise<b
await press(usePlus ? plus : minus);
stats[usePlus ? 'plus' : 'minus'] = (stats[usePlus ? 'plus' : 'minus'] ?? 0) + 1;
} else if (zero && (total >= 60 || !nums.length)) {
// The 0 card asks whether to keep or reverse the direction: alternate between the two.
const reverse = (stats.reverse ?? 0) <= (stats.keep ?? 0);
await press(cards.nth(zero.i));
await press(cards.nth(zero.i));
stats.reverse = (stats.reverse ?? 0) + 1;
await press(p.locator('.nn-pick button', { hasText: reverse ? /^방향 바꾸기/ : /^방향 그대로/ }));
stats[reverse ? 'reverse' : 'keep'] = (stats[reverse ? 'reverse' : 'keep'] ?? 0) + 1;
} else {
// Small cards while the total is low (longer game, more cards seen), then the biggest one.
const pick = total < 55 ? nums[0]! : nums[nums.length - 1]!;
@@ -1793,15 +1795,16 @@ const PLANS: Record<string, Plan> = {
},
'ninety-nine': {
// PC host + 2 phones, 전용 카드 with 6 of each special card and 30-second turns. Real turns through the UI until the
// game ends: joker via the number dial (quick 99, then −1), ± cards via their +/− buttons, 0 to turn the direction,
// otherwise a number card (PC: keyboard Enter twice, phones: tap twice). Players run out of cards and are eliminated.
// game ends: joker via the number dial (quick 99, then −1), ± cards via their +/− buttons, 0 with its keep/reverse
// direction buttons, otherwise a number card (PC: keyboard Enter twice, phones: tap twice). 3 players start at 20
// with 5 cards each (official rules). Players run out of cards and are eliminated.
players: 3,
lobby: async (host) => {
await host.getByRole('group', { name: '한 턴 제한 시간' }).getByRole('button', { name: '30초' }).click();
await host.getByRole('button', { name: '▼ 자세한 설정' }).click();
await host.getByRole('group', { name: '특수 카드 장수' }).getByRole('button', { name: '6장' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/^전용 카드 · 손패 3장 · 목숨 없음 · 30초/).first().waitFor();
await host.getByText(/^전용 카드 · 손패 5장 · 목숨 없음 · 30초/).first().waitFor();
},
drive: async (pages) => {
const [host, guest] = pages as [Page, Page, Page];
@@ -1841,7 +1844,7 @@ const PLANS: Record<string, Plan> = {
await host.waitForTimeout(600);
await host.screenshot({ path: join(SHOTS, 'ninety-nine-result-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'ninety-nine-result-phone.png') });
const specials = (stats.joker ?? 0) + (stats.plus ?? 0) + (stats.minus ?? 0) + (stats.reverse ?? 0);
const specials = (stats.joker ?? 0) + (stats.plus ?? 0) + (stats.minus ?? 0) + (stats.reverse ?? 0) + (stats.keep ?? 0);
if (turns < 6 || specials < 1 || outs < 1) throw new Error(`too few real turns / special cards / eliminations: ${turns} ${JSON.stringify(stats)} outs=${outs}`);
console.log(` ninety-nine: finished after ${turns} UI turns ${JSON.stringify(stats)}, ${outs} eliminations in the log`);
},