Merge branch 'm5/bang' into codex/owner/grp_discord_joke-app
# Conflicts: # apps/web/src/games/registry.ts # apps/web/src/lib/catalog-ui.ts # e2e/games.e2e.ts # packages/games/src/index.ts # packages/shared/src/catalog.ts
This commit is contained in:
1285
apps/web/src/games/bang/index.tsx
Normal file
1285
apps/web/src/games/bang/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
|||||||
'davinci-code': () => import('./davinci-code'),
|
'davinci-code': () => import('./davinci-code'),
|
||||||
mahjong: () => import('./mahjong'),
|
mahjong: () => import('./mahjong'),
|
||||||
gostop: () => import('./gostop'),
|
gostop: () => import('./gostop'),
|
||||||
|
bang: () => import('./bang'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const cache = new Map<string, GameUI>();
|
const cache = new Map<string, GameUI>();
|
||||||
|
|||||||
@@ -17,3 +17,8 @@ export const GAME_ICON: Record<string, string> = {
|
|||||||
|
|
||||||
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||||
export const UPCOMING = ['뱅', '할리갈리'];
|
export const UPCOMING = ['뱅', '할리갈리'];
|
||||||
|
bang: '🤠',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||||
|
export const UPCOMING = ['마작', '고스톱', '할리갈리'];
|
||||||
|
|||||||
106
e2e/games.e2e.ts
106
e2e/games.e2e.ts
@@ -424,6 +424,112 @@ const PLANS: Record<string, Plan> = {
|
|||||||
// The next hand has started: the table and my hand are back on every screen.
|
// The next hand has started: the table and my hand are back on every screen.
|
||||||
for (const q of pages) await q.locator('.mj-table').waitFor({ timeout: 10_000 });
|
for (const q of pages) await q.locator('.mj-table').waitFor({ timeout: 10_000 });
|
||||||
console.log(` mahjong: ${discards} discards, ${passes} passes, ${wins} wins, ${results} hand finished → next hand started`);
|
console.log(` mahjong: ${discards} discards, ${passes} passes, ${wins} wins, ${results} hand finished → next hand started`);
|
||||||
|
bang: {
|
||||||
|
// PC host + 3 phone guests, reaction time 25s: real turns through the UI — draw, play usable cards
|
||||||
|
// (뱅! at a highlighted target → confirm, other untargeted cards by double tap), answer reaction
|
||||||
|
// sheets (빗나감!/맥주/결투/인디언!/잡화점), discard down to the hand limit, end the turn.
|
||||||
|
players: 4,
|
||||||
|
lobby: async (host) => {
|
||||||
|
await host.getByRole('group', { name: '반응 제한 시간' }).getByRole('button', { name: '25초' }).click();
|
||||||
|
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||||
|
await host.getByText(/반응 25초/).first().waitFor();
|
||||||
|
},
|
||||||
|
drive: async (pages) => {
|
||||||
|
const stats = { turns: 0, bangs: 0, plays: 0, reactions: 0, discards: 0 };
|
||||||
|
let finished = false;
|
||||||
|
let shot = false;
|
||||||
|
let midShot = false;
|
||||||
|
const tryClick = (l: ReturnType<Page['locator']>) => l.click({ timeout: 1500 }).then(() => true, () => false);
|
||||||
|
/** One UI step on this page; true when something was pressed. */
|
||||||
|
const step = async (p: Page): Promise<boolean> => {
|
||||||
|
const modal = p.locator('.bg-modal');
|
||||||
|
if (await modal.count()) {
|
||||||
|
const store = modal.locator('button.bg-card');
|
||||||
|
const title = (await modal.locator('h2').innerText().catch(() => '')).trim();
|
||||||
|
if (title.startsWith('3장 중')) {
|
||||||
|
await store.nth(0).click();
|
||||||
|
await store.nth(1).click();
|
||||||
|
await modal.getByRole('button', { name: '이 2장 받기' }).click();
|
||||||
|
} else if (title.startsWith('잡화점')) await store.first().click();
|
||||||
|
else {
|
||||||
|
const use = modal.getByRole('button', { name: /(내기|마시기)$/ });
|
||||||
|
if (!(await use.count()) || !(await tryClick(use.first()))) {
|
||||||
|
if (!(await tryClick(modal.getByRole('button', { name: /^(포기하고 맞기|맞기|쓰러지기)/ }).first()))) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats.reactions++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const draw = p.getByRole('button', { name: '카드 더미에서 2장 받기' });
|
||||||
|
if (await draw.count()) return tryClick(draw);
|
||||||
|
const discardBtn = p.getByRole('button', { name: /장 버리기$/ });
|
||||||
|
if (await discardBtn.count()) {
|
||||||
|
const need = Number((await p.locator('.bg-actions').innerText()).match(/버릴 카드 (\d+)장/)?.[1] ?? 0);
|
||||||
|
const cards = p.locator('.bg-me .bg-hand button.bg-card');
|
||||||
|
for (let i = 0; i < need; i++) await cards.nth(i).click();
|
||||||
|
await discardBtn.click();
|
||||||
|
stats.discards++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const end = p.getByRole('button', { name: '턴 마치기' });
|
||||||
|
if (!(await end.count()) || !(await end.isEnabled())) return false;
|
||||||
|
// Shoot first (once per turn is enough), then other untargeted cards, then end the turn.
|
||||||
|
const bang = p.locator('.bg-me button.bg-card[data-kind="bang"][data-ok="true"]').first();
|
||||||
|
if (await bang.count()) {
|
||||||
|
await bang.click();
|
||||||
|
const target = p.locator('button.bg-seat[data-target="ok"]').first();
|
||||||
|
await target.waitFor({ timeout: 2000 });
|
||||||
|
await target.click();
|
||||||
|
const fire = p.getByRole('button', { name: /에게 뱅!$/ });
|
||||||
|
await fire.click();
|
||||||
|
stats.bangs++;
|
||||||
|
if (!shot) {
|
||||||
|
shot = true;
|
||||||
|
await p.waitForTimeout(400);
|
||||||
|
for (const [i, q] of pages.entries()) await q.screenshot({ path: join(SHOTS, `bang-3-shot-${i === 0 ? 'pc' : `phone${i}`}.png`) });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const simple = ['beer', 'stagecoach', 'wellsFargo', 'saloon', 'barrel', 'mustang', 'scope', 'schofield', 'remington', 'carabine', 'winchester', 'volcanic', 'indians', 'gatling', 'generalStore'];
|
||||||
|
for (const k of simple) {
|
||||||
|
const c = p.locator(`.bg-me button.bg-card[data-kind="${k}"][data-ok="true"]`).first();
|
||||||
|
if (!(await c.count())) continue;
|
||||||
|
await c.click();
|
||||||
|
await c.click();
|
||||||
|
stats.plays++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await end.click();
|
||||||
|
stats.turns++;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const until = Date.now() + 240_000;
|
||||||
|
while (Date.now() < until && stats.turns < 10) {
|
||||||
|
if (await pages[0]!.getByRole('dialog', { name: '게임 결과' }).count()) {
|
||||||
|
finished = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let acted = false;
|
||||||
|
for (const p of pages) {
|
||||||
|
if (await step(p)) {
|
||||||
|
acted = true;
|
||||||
|
await p.waitForTimeout(250);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stats.turns >= 4 && !midShot) {
|
||||||
|
midShot = true;
|
||||||
|
await pages[0]!.screenshot({ path: join(SHOTS, 'bang-4-mid-pc.png') });
|
||||||
|
await pages[1]!.screenshot({ path: join(SHOTS, 'bang-4-mid-phone.png'), fullPage: true });
|
||||||
|
await pages[1]!.emulateMedia({ colorScheme: 'dark' });
|
||||||
|
await pages[1]!.screenshot({ path: join(SHOTS, 'bang-4-mid-phone-dark.png') });
|
||||||
|
await pages[1]!.emulateMedia({ colorScheme: 'light' });
|
||||||
|
}
|
||||||
|
if (!acted) await pages[0]!.waitForTimeout(150);
|
||||||
|
}
|
||||||
|
console.log(` bang: ${stats.turns} turns, ${stats.bangs} 뱅!, ${stats.plays} other cards, ${stats.reactions} reactions, ${stats.discards} discards${finished ? ', game finished' : ''}`);
|
||||||
|
if (!finished && stats.turns < 8) throw new Error(`only ${stats.turns} turns were played`);
|
||||||
|
if (stats.bangs < 1) throw new Error('no 뱅! was played');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'davinci-code': {
|
'davinci-code': {
|
||||||
|
|||||||
1321
packages/games/src/bang/bang.test.ts
Normal file
1321
packages/games/src/bang/bang.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
167
packages/games/src/bang/cards.ts
Normal file
167
packages/games/src/bang/cards.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/** 뱅! cards, characters and roles (docs/games/bang.md §3, §4). */
|
||||||
|
|
||||||
|
export type Suit = 'S' | 'H' | 'D' | 'C';
|
||||||
|
export type Rank = 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 'J' | 'Q' | 'K' | 'A';
|
||||||
|
export type CardKind =
|
||||||
|
| 'bang'
|
||||||
|
| 'missed'
|
||||||
|
| 'beer'
|
||||||
|
| 'saloon'
|
||||||
|
| 'stagecoach'
|
||||||
|
| 'wellsFargo'
|
||||||
|
| 'generalStore'
|
||||||
|
| 'panic'
|
||||||
|
| 'catBalou'
|
||||||
|
| 'duel'
|
||||||
|
| 'indians'
|
||||||
|
| 'gatling'
|
||||||
|
| 'barrel'
|
||||||
|
| 'mustang'
|
||||||
|
| 'scope'
|
||||||
|
| 'jail'
|
||||||
|
| 'dynamite'
|
||||||
|
| 'volcanic'
|
||||||
|
| 'schofield'
|
||||||
|
| 'remington'
|
||||||
|
| 'carabine'
|
||||||
|
| 'winchester';
|
||||||
|
|
||||||
|
/** Card ids are strings "c0".."c79" (fixed per card) so hidden-info checks can look for them in views. */
|
||||||
|
export interface Card {
|
||||||
|
id: string;
|
||||||
|
kind: CardKind;
|
||||||
|
suit: Suit;
|
||||||
|
rank: Rank;
|
||||||
|
}
|
||||||
|
/** A card without its id (logs, draw! results, stolen-card notes). */
|
||||||
|
export type CardFace = Omit<Card, 'id'>;
|
||||||
|
|
||||||
|
export type Role = 'sheriff' | 'deputy' | 'outlaw' | 'renegade';
|
||||||
|
export type CharacterId =
|
||||||
|
| 'bart'
|
||||||
|
| 'blackJack'
|
||||||
|
| 'calamity'
|
||||||
|
| 'elGringo'
|
||||||
|
| 'jesse'
|
||||||
|
| 'jourdonnais'
|
||||||
|
| 'kit'
|
||||||
|
| 'lucky'
|
||||||
|
| 'paul'
|
||||||
|
| 'pedro'
|
||||||
|
| 'rose'
|
||||||
|
| 'sid'
|
||||||
|
| 'slab'
|
||||||
|
| 'suzy'
|
||||||
|
| 'vulture'
|
||||||
|
| 'willy';
|
||||||
|
|
||||||
|
export const CHARACTER_IDS: CharacterId[] = [
|
||||||
|
'bart',
|
||||||
|
'blackJack',
|
||||||
|
'calamity',
|
||||||
|
'elGringo',
|
||||||
|
'jesse',
|
||||||
|
'jourdonnais',
|
||||||
|
'kit',
|
||||||
|
'lucky',
|
||||||
|
'paul',
|
||||||
|
'pedro',
|
||||||
|
'rose',
|
||||||
|
'sid',
|
||||||
|
'slab',
|
||||||
|
'suzy',
|
||||||
|
'vulture',
|
||||||
|
'willy',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CHARACTER_LIFE: Record<CharacterId, number> = {
|
||||||
|
bart: 4,
|
||||||
|
blackJack: 4,
|
||||||
|
calamity: 4,
|
||||||
|
elGringo: 3,
|
||||||
|
jesse: 4,
|
||||||
|
jourdonnais: 4,
|
||||||
|
kit: 4,
|
||||||
|
lucky: 4,
|
||||||
|
paul: 3,
|
||||||
|
pedro: 4,
|
||||||
|
rose: 4,
|
||||||
|
sid: 4,
|
||||||
|
slab: 4,
|
||||||
|
suzy: 4,
|
||||||
|
vulture: 4,
|
||||||
|
willy: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** §3.3 table: rank+suit per copy. */
|
||||||
|
const DECK_SPEC: [CardKind, string][] = [
|
||||||
|
['bang', 'AS 2D 3D 4D 5D 6D 7D 8D 9D 10D JD QD KD AD 2C 3C 4C 5C 6C 7C 8C 9C QH KH AH'],
|
||||||
|
['missed', '10C JC QC KC AC 2S 3S 4S 5S 6S 7S 8S'],
|
||||||
|
['beer', '6H 7H 8H 9H 10H JH'],
|
||||||
|
['panic', 'JH QH AH 8D'],
|
||||||
|
['catBalou', 'KH 9D 10D JD'],
|
||||||
|
['duel', 'QD JS 8C'],
|
||||||
|
['generalStore', '9C QS'],
|
||||||
|
['indians', 'KD AD'],
|
||||||
|
['stagecoach', '9S 9S'],
|
||||||
|
['gatling', '10H'],
|
||||||
|
['saloon', '5H'],
|
||||||
|
['wellsFargo', '3H'],
|
||||||
|
['barrel', 'QS KS'],
|
||||||
|
['mustang', '8H 9H'],
|
||||||
|
['scope', 'AS'],
|
||||||
|
['jail', 'JS 10S 4H'],
|
||||||
|
['dynamite', '2H'],
|
||||||
|
['volcanic', '10S 10C'],
|
||||||
|
['schofield', 'JC QC KS'],
|
||||||
|
['remington', 'KC'],
|
||||||
|
['carabine', 'AC'],
|
||||||
|
['winchester', '8S'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseRank(r: string): Rank {
|
||||||
|
if (r === 'J' || r === 'Q' || r === 'K' || r === 'A') return r;
|
||||||
|
return Number(r) as Rank;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 80 cards in a fixed order; `CARDS[n].id === "c" + n`. */
|
||||||
|
export const CARDS: Card[] = (() => {
|
||||||
|
const out: Card[] = [];
|
||||||
|
for (const [kind, list] of DECK_SPEC) {
|
||||||
|
for (const code of list.split(' ')) {
|
||||||
|
out.push({ id: `c${out.length}`, kind, suit: code.slice(-1) as Suit, rank: parseRank(code.slice(0, -1)) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const BY_ID = new Map(CARDS.map((c) => [c.id, c]));
|
||||||
|
export function cardById(id: string): Card {
|
||||||
|
const c = BY_ID.get(id);
|
||||||
|
if (!c) throw new Error(`bang: unknown card ${id}`);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
export const isCardId = (id: string): boolean => BY_ID.has(id);
|
||||||
|
export const kindOf = (id: string): CardKind => cardById(id).kind;
|
||||||
|
export function faceOf(id: string): CardFace {
|
||||||
|
const { id: _id, ...face } = cardById(id);
|
||||||
|
return face;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BLUE_KINDS: CardKind[] = ['barrel', 'mustang', 'scope', 'jail', 'dynamite', 'volcanic', 'schofield', 'remington', 'carabine', 'winchester'];
|
||||||
|
export const WEAPON_RANGE: Partial<Record<CardKind, number>> = { volcanic: 1, schofield: 2, remington: 3, carabine: 4, winchester: 5 };
|
||||||
|
export const isBlue = (k: CardKind): boolean => BLUE_KINDS.includes(k);
|
||||||
|
export const isWeapon = (k: CardKind): boolean => WEAPON_RANGE[k] !== undefined;
|
||||||
|
|
||||||
|
/** Dynamite explodes on ♠2–♠9. */
|
||||||
|
export const explodes = (f: CardFace): boolean => f.suit === 'S' && typeof f.rank === 'number' && f.rank >= 2 && f.rank <= 9;
|
||||||
|
export const isHeart = (f: CardFace): boolean => f.suit === 'H';
|
||||||
|
|
||||||
|
/** §4 role table. */
|
||||||
|
export function rolesFor(n: number, deputyCount7 = false): Role[] {
|
||||||
|
const table: Record<number, [number, number, number]> = { 4: [0, 2, 1], 5: [1, 2, 1], 6: [1, 3, 1], 7: deputyCount7 ? [1, 4, 1] : [2, 3, 1] };
|
||||||
|
const row = table[n];
|
||||||
|
if (!row) throw new Error(`bang: ${n} players not supported`);
|
||||||
|
const [dep, out, ren] = row;
|
||||||
|
return ['sheriff', ...Array<Role>(dep).fill('deputy'), ...Array<Role>(out).fill('outlaw'), ...Array<Role>(ren).fill('renegade')];
|
||||||
|
}
|
||||||
43
packages/games/src/bang/distance.ts
Normal file
43
packages/games/src/bang/distance.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/** Distance and range (docs/games/bang.md §5.1). */
|
||||||
|
import type { PlayerId } from '@bg/engine';
|
||||||
|
import { WEAPON_RANGE, kindOf, type CardKind } from './cards';
|
||||||
|
import type { BangState, PlayerState } from './types';
|
||||||
|
|
||||||
|
export const hasEquip = (p: PlayerState, kind: CardKind): boolean => p.inPlay.some((id) => kindOf(id) === kind);
|
||||||
|
|
||||||
|
export function weaponOf(p: PlayerState): CardKind | null {
|
||||||
|
for (const id of p.inPlay) {
|
||||||
|
const k = kindOf(id);
|
||||||
|
if (WEAPON_RANGE[k] !== undefined) return k;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Weapon range, or 1 for the default Colt .45. */
|
||||||
|
export function rangeOf(p: PlayerState): number {
|
||||||
|
const w = weaponOf(p);
|
||||||
|
return w ? WEAPON_RANGE[w]! : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seats between two living players, the shorter way round, counting living players only. */
|
||||||
|
export function seatDistance(s: BangState, a: PlayerId, b: PlayerId): number {
|
||||||
|
const alive = s.players.filter((p) => p.alive);
|
||||||
|
const ia = alive.findIndex((p) => p.id === a);
|
||||||
|
const ib = alive.findIndex((p) => p.id === b);
|
||||||
|
if (ia < 0 || ib < 0) return Infinity;
|
||||||
|
const d = Math.abs(ia - ib);
|
||||||
|
return Math.min(d, alive.length - d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** distance(A→B) = max(1, seats + B's mustang/Paul Regret − A's scope/Rose Doolan). */
|
||||||
|
export function distance(s: BangState, a: PlayerId, b: PlayerId): number {
|
||||||
|
if (a === b) return 0;
|
||||||
|
const pa = s.players.find((p) => p.id === a)!;
|
||||||
|
const pb = s.players.find((p) => p.id === b)!;
|
||||||
|
let d = seatDistance(s, a, b);
|
||||||
|
if (hasEquip(pb, 'mustang')) d += 1;
|
||||||
|
if (pb.character === 'paul' && !pb.characterOffer) d += 1;
|
||||||
|
if (hasEquip(pa, 'scope')) d -= 1;
|
||||||
|
if (pa.character === 'rose' && !pa.characterOffer) d -= 1;
|
||||||
|
return Math.max(1, d);
|
||||||
|
}
|
||||||
829
packages/games/src/bang/effects.ts
Normal file
829
packages/games/src/bang/effects.ts
Normal file
@@ -0,0 +1,829 @@
|
|||||||
|
/**
|
||||||
|
* 뱅! effects and the turn / reaction state machine (docs/games/bang.md §5, §8).
|
||||||
|
* Every function mutates the (already copied) state passed in; randomness only via `ctx.rng`.
|
||||||
|
*/
|
||||||
|
import type { GameEvent, PlayerId, Rng } from '@bg/engine';
|
||||||
|
import { CHARACTER_LIFE, cardById, explodes, faceOf, isBlue, isHeart, isWeapon, kindOf, type CardFace, type CharacterId } from './cards';
|
||||||
|
import { hasEquip } from './distance';
|
||||||
|
import type { BangFinal, BangState, CheckPurpose, LogEntry, PlayAction, PlayerState, Reaction, Team } from './types';
|
||||||
|
|
||||||
|
export interface Ctx {
|
||||||
|
rng: Rng;
|
||||||
|
now: number;
|
||||||
|
events: GameEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AWAY_MS = 5000;
|
||||||
|
export const AWAY_AFTER = 3;
|
||||||
|
export const DISCARD_MIN_MS = 20_000;
|
||||||
|
const LOG_MAX = 60;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- lookups
|
||||||
|
|
||||||
|
export const P = (s: BangState, id: PlayerId): PlayerState => {
|
||||||
|
const p = s.players.find((x) => x.id === id);
|
||||||
|
if (!p) throw new Error(`bang: no player ${id}`);
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
export const findPlayer = (s: BangState, id: PlayerId): PlayerState | undefined => s.players.find((x) => x.id === id);
|
||||||
|
export const living = (s: BangState): PlayerState[] => s.players.filter((p) => p.alive);
|
||||||
|
/** Character ability is active (picked, not still a hidden choose2 offer). */
|
||||||
|
export const is = (p: PlayerState, c: CharacterId): boolean => p.character === c && !p.characterOffer;
|
||||||
|
|
||||||
|
/** Who must answer this reaction. */
|
||||||
|
export function responder(r: Reaction): PlayerId {
|
||||||
|
switch (r.kind) {
|
||||||
|
case 'bang':
|
||||||
|
case 'indians':
|
||||||
|
return r.target;
|
||||||
|
case 'duel':
|
||||||
|
return r.actor;
|
||||||
|
case 'store':
|
||||||
|
return r.picker;
|
||||||
|
case 'dying':
|
||||||
|
case 'kitKeep':
|
||||||
|
case 'choose2':
|
||||||
|
return r.player;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const reactionOf = (s: BangState, p: PlayerId): Reaction | undefined => s.reactions.find((r) => responder(r) === p);
|
||||||
|
|
||||||
|
/** Next living player clockwise after `from` (which may be dead). */
|
||||||
|
export function nextAlive(s: BangState, from: PlayerId): PlayerId {
|
||||||
|
const n = s.players.length;
|
||||||
|
const i = s.players.findIndex((p) => p.id === from);
|
||||||
|
for (let k = 1; k <= n; k++) {
|
||||||
|
const p = s.players[(i + k) % n]!;
|
||||||
|
if (p.alive) return p.id;
|
||||||
|
}
|
||||||
|
return from;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Living players clockwise starting with `from` (included when alive). */
|
||||||
|
export function clockwiseFrom(s: BangState, from: PlayerId): PlayerId[] {
|
||||||
|
const n = s.players.length;
|
||||||
|
const i = s.players.findIndex((p) => p.id === from);
|
||||||
|
const out: PlayerId[] = [];
|
||||||
|
for (let k = 0; k < n; k++) {
|
||||||
|
const p = s.players[(i + k) % n]!;
|
||||||
|
if (p.alive) out.push(p.id);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reactionMs(s: BangState, p: PlayerState): number {
|
||||||
|
return p.away ? AWAY_MS : s.options.reactionSeconds * 1000;
|
||||||
|
}
|
||||||
|
export function turnMs(s: BangState, p: PlayerState): number {
|
||||||
|
return p.away ? AWAY_MS : s.options.turnSeconds * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cards kept inside reactions (general store, Kit Carlson) — counted for conservation. */
|
||||||
|
export function limboCards(s: BangState): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const r of s.reactions) {
|
||||||
|
if (r.kind === 'store') out.push(...r.revealed);
|
||||||
|
if (r.kind === 'kitKeep') out.push(...r.options);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- log
|
||||||
|
|
||||||
|
type LogBody = LogEntry extends infer E ? (E extends LogEntry ? Omit<E, 'seq'> : never) : never;
|
||||||
|
|
||||||
|
export function log(s: BangState, ctx: Ctx, e: LogBody): void {
|
||||||
|
const entry = { seq: s.seq, ...e } as LogEntry;
|
||||||
|
s.log.push(entry);
|
||||||
|
if (s.log.length > LOG_MAX) s.log.splice(0, s.log.length - LOG_MAX);
|
||||||
|
// Events go to everyone: private card faces are dropped.
|
||||||
|
const { seq: _seq, t, ...rest } = entry as LogEntry & { vis?: PlayerId[]; card?: CardFace };
|
||||||
|
if ('vis' in rest) delete (rest as { card?: CardFace }).card;
|
||||||
|
ctx.events.push({ type: t, ...rest });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- cards
|
||||||
|
|
||||||
|
function refill(s: BangState, ctx: Ctx): void {
|
||||||
|
if (s.deck.length > 0 || s.discard.length === 0) return;
|
||||||
|
const n = s.discard.length;
|
||||||
|
s.deck = ctx.rng.shuffle(s.discard);
|
||||||
|
s.discard = [];
|
||||||
|
log(s, ctx, { t: 'reshuffle', n });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top card of the deck (refilling from the discard pile once); null when both are empty (§5.7). */
|
||||||
|
export function drawTop(s: BangState, ctx: Ctx): string | null {
|
||||||
|
if (s.deck.length === 0) refill(s, ctx);
|
||||||
|
return s.deck.pop() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawCards(s: BangState, ctx: Ctx, p: PlayerState, n: number, why: Extract<LogEntry, { t: 'draw' }>['why']): number {
|
||||||
|
let got = 0;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const id = drawTop(s, ctx);
|
||||||
|
if (!id) break;
|
||||||
|
p.hand.push(id);
|
||||||
|
got++;
|
||||||
|
}
|
||||||
|
if (got > 0) log(s, ctx, { t: 'draw', p: p.id, n: got, why });
|
||||||
|
return got;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes cards from a hand; Suzy Lafayette draws one as soon as her hand is empty (§3.2). */
|
||||||
|
export function removeFromHand(s: BangState, ctx: Ctx, p: PlayerState, ids: string[]): void {
|
||||||
|
for (const id of ids) {
|
||||||
|
const i = p.hand.indexOf(id);
|
||||||
|
if (i < 0) throw new Error(`bang: ${id} not in ${p.id}'s hand`);
|
||||||
|
p.hand.splice(i, 1);
|
||||||
|
}
|
||||||
|
suzyCheck(s, ctx, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function suzyCheck(s: BangState, ctx: Ctx, p: PlayerState): void {
|
||||||
|
if (p.alive && is(p, 'suzy') && p.hand.length === 0 && !s.winner) drawCards(s, ctx, p, 1, 'suzy');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "판정"(draw!): reveal the top card (Lucky Duke: two, keeping the better one — always the favourable card,
|
||||||
|
* so it is chosen automatically), then discard. `empty` is the result when no card is left at all.
|
||||||
|
*/
|
||||||
|
export function drawCheck(s: BangState, ctx: Ctx, p: PlayerState, purpose: CheckPurpose, good: (f: CardFace) => boolean, empty: boolean): boolean {
|
||||||
|
const n = is(p, 'lucky') ? 2 : 1;
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const id = drawTop(s, ctx);
|
||||||
|
if (id) ids.push(id);
|
||||||
|
}
|
||||||
|
if (ids.length === 0) return empty;
|
||||||
|
const best = ids.find((id) => good(faceOf(id))) ?? ids[0]!;
|
||||||
|
const success = good(faceOf(best));
|
||||||
|
// The chosen card ends on top of the discard pile.
|
||||||
|
s.discard.push(...ids.filter((id) => id !== best), best);
|
||||||
|
const cards = ids.map(faceOf);
|
||||||
|
s.lastCheck = { player: p.id, purpose, cards, success, seq: s.seq };
|
||||||
|
log(s, ctx, { t: 'check', p: p.id, purpose, cards, success });
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- life
|
||||||
|
|
||||||
|
export function heal(s: BangState, ctx: Ctx, p: PlayerState, n: number, why: 'beer' | 'saloon' | 'sid', cards?: CardFace[]): void {
|
||||||
|
const before = p.life;
|
||||||
|
p.life = Math.min(p.maxLife, p.life + n);
|
||||||
|
if (p.life !== before || why !== 'saloon') log(s, ctx, { t: 'heal', p: p.id, n: p.life - before, why, ...(cards ? { cards } : {}) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bart Cassidy / El Gringo after losing `n` life to `source` (§5.5). */
|
||||||
|
function lossAbilities(s: BangState, ctx: Ctx, t: PlayerState, n: number, source: PlayerId | null): void {
|
||||||
|
if (!t.alive) return;
|
||||||
|
if (is(t, 'bart')) drawCards(s, ctx, t, n, 'bart');
|
||||||
|
if (is(t, 'elGringo') && source && source !== t.id) {
|
||||||
|
const src = P(s, source);
|
||||||
|
for (let i = 0; i < n && src.alive && src.hand.length > 0; i++) {
|
||||||
|
const id = src.hand[ctx.rng.int(src.hand.length)]!;
|
||||||
|
removeFromHand(s, ctx, src, [id]);
|
||||||
|
t.hand.push(id);
|
||||||
|
log(s, ctx, { t: 'take', p: t.id, from: src.id, how: 'gringo', card: faceOf(id), vis: [t.id, src.id] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loses `n` life because of `source`'s card (null = dynamite). */
|
||||||
|
export function damage(s: BangState, ctx: Ctx, targetId: PlayerId, n: number, source: PlayerId | null): void {
|
||||||
|
const t = P(s, targetId);
|
||||||
|
if (!t.alive || s.winner) return;
|
||||||
|
t.life -= n;
|
||||||
|
log(s, ctx, { t: 'hit', p: t.id, n, by: source, life: t.life });
|
||||||
|
if (s.options.drawBeforeDeath) lossAbilities(s, ctx, t, n, source);
|
||||||
|
if (t.life <= 0) openDying(s, ctx, t, source, n);
|
||||||
|
else if (!s.options.drawBeforeDeath) lossAbilities(s, ctx, t, n, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const beerWorks = (s: BangState): boolean => living(s).length > 2;
|
||||||
|
|
||||||
|
/** Most life the player could still recover while dying (beers, Sid Ketchum's 2-card heals). */
|
||||||
|
export function maxRecovery(s: BangState, p: PlayerState): number {
|
||||||
|
const beers = beerWorks(s) ? p.hand.filter((id) => kindOf(id) === 'beer').length : 0;
|
||||||
|
if (!is(p, 'sid')) return beers;
|
||||||
|
return beers + Math.floor((p.hand.length - beers) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDying(s: BangState, ctx: Ctx, t: PlayerState, killer: PlayerId | null, lost: number): void {
|
||||||
|
if (maxRecovery(s, t) >= 1 - t.life) {
|
||||||
|
s.reactions.push({ kind: 'dying', player: t.id, killer, lost, source: killer, deadline: ctx.now + reactionMs(s, t) });
|
||||||
|
} else {
|
||||||
|
eliminate(s, ctx, t.id, killer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dying player is back to 1+ life. */
|
||||||
|
export function survived(s: BangState, ctx: Ctx, r: Extract<Reaction, { kind: 'dying' }>): void {
|
||||||
|
removeReaction(s, r);
|
||||||
|
if (!s.options.drawBeforeDeath) lossAbilities(s, ctx, P(s, r.player), r.lost, r.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeReaction(s: BangState, r: Reaction): void {
|
||||||
|
const i = s.reactions.indexOf(r);
|
||||||
|
if (i >= 0) s.reactions.splice(i, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- elimination and the end
|
||||||
|
|
||||||
|
/** Reactions that cannot go on without `id` (§5.6). */
|
||||||
|
function dropReactionsOf(s: BangState, ctx: Ctx, id: PlayerId): void {
|
||||||
|
for (const r of [...s.reactions]) {
|
||||||
|
switch (r.kind) {
|
||||||
|
case 'bang':
|
||||||
|
case 'indians':
|
||||||
|
if (r.target === id) removeReaction(s, r);
|
||||||
|
break;
|
||||||
|
case 'duel':
|
||||||
|
if (r.actor === id || r.other === id) removeReaction(s, r);
|
||||||
|
break;
|
||||||
|
case 'dying':
|
||||||
|
case 'choose2':
|
||||||
|
if (r.player === id) removeReaction(s, r);
|
||||||
|
break;
|
||||||
|
case 'kitKeep':
|
||||||
|
if (r.player === id) {
|
||||||
|
s.discard.push(...r.options);
|
||||||
|
removeReaction(s, r);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'store':
|
||||||
|
r.order = r.order.filter((x) => x !== id);
|
||||||
|
if (r.picker === id) storeAdvance(s, ctx, r);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eliminate(s: BangState, ctx: Ctx, id: PlayerId, killer: PlayerId | null, left = false): void {
|
||||||
|
const v = P(s, id);
|
||||||
|
if (!v.alive) return;
|
||||||
|
v.alive = false;
|
||||||
|
v.life = Math.min(v.life, 0);
|
||||||
|
s.deathOrder.push(id);
|
||||||
|
log(s, ctx, { t: 'dead', p: id, role: v.role, by: killer, ...(left ? { left: true } : {}) });
|
||||||
|
dropReactionsOf(s, ctx, id);
|
||||||
|
const k = killer && killer !== id ? findPlayer(s, killer) : undefined;
|
||||||
|
if (k?.alive) {
|
||||||
|
if (v.role === 'outlaw') {
|
||||||
|
log(s, ctx, { t: 'reward', p: k.id, n: 3 });
|
||||||
|
drawCards(s, ctx, k, 3, 'reward');
|
||||||
|
}
|
||||||
|
if (k.role === 'sheriff' && v.role === 'deputy') {
|
||||||
|
log(s, ctx, { t: 'penalty', p: k.id });
|
||||||
|
const hand = [...k.hand];
|
||||||
|
s.discard.push(...k.inPlay);
|
||||||
|
k.inPlay = [];
|
||||||
|
if (hand.length) {
|
||||||
|
removeFromHand(s, ctx, k, hand);
|
||||||
|
s.discard.push(...hand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cards = [...v.hand, ...v.inPlay];
|
||||||
|
v.hand = [];
|
||||||
|
v.inPlay = [];
|
||||||
|
const vulture = living(s).find((p) => is(p, 'vulture'));
|
||||||
|
if (vulture && cards.length) {
|
||||||
|
vulture.hand.push(...cards);
|
||||||
|
log(s, ctx, { t: 'vulture', p: vulture.id, from: id, n: cards.length });
|
||||||
|
} else {
|
||||||
|
s.discard.push(...cards);
|
||||||
|
}
|
||||||
|
if (checkWin(s, ctx)) return;
|
||||||
|
if (s.turn.player === id && s.turn.phase !== 'draft') s.turn.phase = 'ended';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** §6: checked after every death. */
|
||||||
|
export function checkWin(s: BangState, ctx: Ctx): boolean {
|
||||||
|
if (s.winner) return true;
|
||||||
|
const alive = living(s);
|
||||||
|
const sheriff = s.players.find((p) => p.role === 'sheriff')!;
|
||||||
|
let team: Team | null = null;
|
||||||
|
if (!sheriff.alive) team = alive.length === 1 && alive[0]!.role === 'renegade' ? 'renegade' : 'outlaws';
|
||||||
|
else if (!alive.some((p) => p.role === 'outlaw' || p.role === 'renegade')) team = 'law';
|
||||||
|
if (!team) return false;
|
||||||
|
finish(s, ctx, team);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEAM_ROLES: Record<Team, string[]> = { law: ['sheriff', 'deputy'], outlaws: ['outlaw'], renegade: ['renegade'] };
|
||||||
|
|
||||||
|
function finish(s: BangState, ctx: Ctx, team: Team): void {
|
||||||
|
s.winner = team;
|
||||||
|
for (const c of limboCards(s)) s.discard.push(c);
|
||||||
|
s.reactions = [];
|
||||||
|
s.turn.pausedAt = null;
|
||||||
|
const order = (a: PlayerState, b: PlayerState) =>
|
||||||
|
Number(b.alive) - Number(a.alive) || s.deathOrder.indexOf(b.id) - s.deathOrder.indexOf(a.id) || a.seat - b.seat;
|
||||||
|
const won = (p: PlayerState) => TEAM_ROLES[team].includes(p.role);
|
||||||
|
const winners = s.players.filter(won).sort(order);
|
||||||
|
const losers = s.players.filter((p) => !won(p)).sort(order);
|
||||||
|
const summary =
|
||||||
|
team === 'law'
|
||||||
|
? `보안관 팀 승리! ${s.round}라운드에 무법자와 배신자를 모두 잡았어요.`
|
||||||
|
: team === 'outlaws'
|
||||||
|
? `무법자 승리! 보안관이 ${s.round}라운드에 쓰러졌어요.`
|
||||||
|
: `배신자 승리! ${s.round}라운드에 보안관까지 쓰러뜨리고 혼자 살아남았어요.`;
|
||||||
|
const final: BangFinal = {
|
||||||
|
winnerTeam: team,
|
||||||
|
players: s.players.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
role: p.role,
|
||||||
|
character: p.character,
|
||||||
|
alive: p.alive,
|
||||||
|
won: won(p),
|
||||||
|
deathOrder: p.alive ? null : s.deathOrder.indexOf(p.id) + 1,
|
||||||
|
})),
|
||||||
|
ranking: [...winners, ...losers].map((p) => p.id),
|
||||||
|
summary,
|
||||||
|
};
|
||||||
|
s.final = final;
|
||||||
|
log(s, ctx, { t: 'end', winner: team });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- turn flow
|
||||||
|
|
||||||
|
export function startTurn(s: BangState, ctx: Ctx, id: PlayerId): void {
|
||||||
|
const p = P(s, id);
|
||||||
|
if (p.role === 'sheriff') s.round += 1;
|
||||||
|
s.turn = { player: id, phase: 'start', bangsPlayed: 0, deadline: ctx.now + turnMs(s, p), pausedAt: null, dynamiteChecked: false, jailChecked: false };
|
||||||
|
log(s, ctx, { t: 'turn', p: id, round: s.round });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deals the starting hands after characters are fixed and gives the sheriff the first turn (§4). */
|
||||||
|
export function beginGame(s: BangState, ctx: Ctx): void {
|
||||||
|
for (const p of s.players) {
|
||||||
|
p.maxLife = CHARACTER_LIFE[p.character] + (p.role === 'sheriff' ? 1 : 0);
|
||||||
|
if (!p.alive) continue;
|
||||||
|
p.life = p.maxLife;
|
||||||
|
}
|
||||||
|
// One card at a time from the sheriff, clockwise.
|
||||||
|
const sheriff = s.players.find((p) => p.role === 'sheriff')!;
|
||||||
|
const order = clockwiseFrom(s, sheriff.id).map((id) => P(s, id));
|
||||||
|
const most = Math.max(...order.map((p) => p.life));
|
||||||
|
for (let r = 0; r < most; r++) for (const p of order) if (p.hand.length < p.life) p.hand.push(s.deck.pop()!);
|
||||||
|
startTurn(s, ctx, sheriff.alive ? sheriff.id : nextAlive(s, sheriff.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turn start: dynamite, then jail (§5.2). */
|
||||||
|
function runStart(s: BangState, ctx: Ctx): void {
|
||||||
|
const p = P(s, s.turn.player);
|
||||||
|
if (!s.turn.dynamiteChecked) {
|
||||||
|
s.turn.dynamiteChecked = true;
|
||||||
|
const dyn = p.inPlay.find((id) => kindOf(id) === 'dynamite');
|
||||||
|
if (dyn) {
|
||||||
|
const safe = drawCheck(s, ctx, p, 'dynamite', (f) => !explodes(f), true);
|
||||||
|
p.inPlay = p.inPlay.filter((x) => x !== dyn);
|
||||||
|
if (!safe) {
|
||||||
|
s.discard.push(dyn);
|
||||||
|
log(s, ctx, { t: 'dynamite', p: p.id, exploded: true });
|
||||||
|
damage(s, ctx, p.id, 3, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const to = P(s, nextAlive(s, p.id));
|
||||||
|
to.inPlay.push(dyn);
|
||||||
|
log(s, ctx, { t: 'dynamite', p: p.id, exploded: false, to: to.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!p.alive) return;
|
||||||
|
if (!s.turn.jailChecked) {
|
||||||
|
s.turn.jailChecked = true;
|
||||||
|
const jail = p.inPlay.find((id) => kindOf(id) === 'jail');
|
||||||
|
if (jail) {
|
||||||
|
const escaped = drawCheck(s, ctx, p, 'jail', isHeart, true);
|
||||||
|
p.inPlay = p.inPlay.filter((x) => x !== jail);
|
||||||
|
s.discard.push(jail);
|
||||||
|
log(s, ctx, { t: 'jail', p: p.id, escaped });
|
||||||
|
if (!escaped) {
|
||||||
|
s.turn.phase = 'ended';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.turn.phase = 'draw';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jesse Jones / Pedro Ramirez have a real choice in phase 1; everyone else draws automatically. */
|
||||||
|
export function drawChoice(s: BangState, p: PlayerState): { jesse: PlayerId[]; pedro: boolean } | null {
|
||||||
|
const jesse = is(p, 'jesse') ? living(s).filter((x) => x.id !== p.id && x.hand.length > 0).map((x) => x.id) : [];
|
||||||
|
const pedro = is(p, 'pedro') && s.discard.length > 0;
|
||||||
|
return jesse.length || pedro ? { jesse, pedro } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function doDraw(s: BangState, ctx: Ctx, a: { jesseFrom?: PlayerId; pedroDiscard?: boolean }): void {
|
||||||
|
const p = P(s, s.turn.player);
|
||||||
|
s.turn.phase = 'play';
|
||||||
|
if (is(p, 'kit')) {
|
||||||
|
const options: string[] = [];
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const id = drawTop(s, ctx);
|
||||||
|
if (id) options.push(id);
|
||||||
|
}
|
||||||
|
if (options.length <= 2) {
|
||||||
|
p.hand.push(...options);
|
||||||
|
if (options.length) log(s, ctx, { t: 'draw', p: p.id, n: options.length, why: 'kit' });
|
||||||
|
} else {
|
||||||
|
s.reactions.push({ kind: 'kitKeep', player: p.id, options, deadline: ctx.now + reactionMs(s, p) });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (a.jesseFrom) {
|
||||||
|
const src = P(s, a.jesseFrom);
|
||||||
|
const id = src.hand[ctx.rng.int(src.hand.length)]!;
|
||||||
|
removeFromHand(s, ctx, src, [id]);
|
||||||
|
p.hand.push(id);
|
||||||
|
log(s, ctx, { t: 'take', p: p.id, from: src.id, how: 'jesse', card: faceOf(id), vis: [p.id, src.id] });
|
||||||
|
} else if (a.pedroDiscard) {
|
||||||
|
const id = s.discard.pop()!;
|
||||||
|
p.hand.push(id);
|
||||||
|
log(s, ctx, { t: 'pedro', p: p.id, card: faceOf(id) });
|
||||||
|
}
|
||||||
|
const special = !!(a.jesseFrom || a.pedroDiscard);
|
||||||
|
if (is(p, 'blackJack')) {
|
||||||
|
drawCards(s, ctx, p, 1, 'phase');
|
||||||
|
const second = drawTop(s, ctx);
|
||||||
|
if (second) {
|
||||||
|
p.hand.push(second);
|
||||||
|
const f = faceOf(second);
|
||||||
|
const extra = f.suit === 'H' || f.suit === 'D';
|
||||||
|
log(s, ctx, { t: 'blackJack', p: p.id, card: f, extra });
|
||||||
|
if (extra) drawCards(s, ctx, p, 1, 'phase');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drawCards(s, ctx, p, special ? 1 : 2, 'phase');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the automatic parts of the turn until a decision is needed: pauses the turn timer while reactions
|
||||||
|
* are open, finishes the draft, starts turns, resolves dynamite / jail and automatic draws.
|
||||||
|
*/
|
||||||
|
export function settle(s: BangState, ctx: Ctx): void {
|
||||||
|
for (let guard = 0; guard < 500; guard++) {
|
||||||
|
if (s.winner) return;
|
||||||
|
if (s.reactions.length > 0) {
|
||||||
|
if (s.turn.pausedAt === null && (s.turn.phase === 'play' || s.turn.phase === 'discard')) s.turn.pausedAt = ctx.now;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (s.turn.pausedAt !== null) {
|
||||||
|
s.turn.deadline += ctx.now - s.turn.pausedAt;
|
||||||
|
s.turn.pausedAt = null;
|
||||||
|
}
|
||||||
|
const p = P(s, s.turn.player);
|
||||||
|
switch (s.turn.phase) {
|
||||||
|
case 'draft':
|
||||||
|
beginGame(s, ctx);
|
||||||
|
continue;
|
||||||
|
case 'ended':
|
||||||
|
startTurn(s, ctx, nextAlive(s, s.turn.player));
|
||||||
|
continue;
|
||||||
|
case 'start':
|
||||||
|
runStart(s, ctx);
|
||||||
|
continue;
|
||||||
|
case 'draw':
|
||||||
|
if (drawChoice(s, p)) return;
|
||||||
|
doDraw(s, ctx, {});
|
||||||
|
continue;
|
||||||
|
case 'discard':
|
||||||
|
if (p.hand.length <= p.life) {
|
||||||
|
s.turn.phase = 'ended';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'play':
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('bang: settle did not converge');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- playing cards
|
||||||
|
|
||||||
|
function openBang(s: BangState, ctx: Ctx, source: PlayerId, target: PlayerId, via: 'bang' | 'gatling'): void {
|
||||||
|
const t = P(s, target);
|
||||||
|
const r: Extract<Reaction, { kind: 'bang' }> = {
|
||||||
|
kind: 'bang',
|
||||||
|
target,
|
||||||
|
source,
|
||||||
|
via,
|
||||||
|
needed: via === 'bang' && is(P(s, source), 'slab') ? 2 : 1,
|
||||||
|
barrelTried: false,
|
||||||
|
jourTried: false,
|
||||||
|
deadline: ctx.now + reactionMs(s, t),
|
||||||
|
};
|
||||||
|
if (s.options.autoBarrel) {
|
||||||
|
while (r.needed > 0 && tryBarrel(s, ctx, r));
|
||||||
|
if (r.needed <= 0) {
|
||||||
|
log(s, ctx, { t: 'barrelSave', p: target });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.reactions.push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function barrelLeft(s: BangState, r: Extract<Reaction, { kind: 'bang' }>): boolean {
|
||||||
|
const t = P(s, r.target);
|
||||||
|
return (!r.barrelTried && hasEquip(t, 'barrel')) || (!r.jourTried && is(t, 'jourdonnais'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One barrel draw! (the real barrel first, then Jourdonnais). False when none is left to try. */
|
||||||
|
export function tryBarrel(s: BangState, ctx: Ctx, r: Extract<Reaction, { kind: 'bang' }>): boolean {
|
||||||
|
const t = P(s, r.target);
|
||||||
|
let purpose: CheckPurpose;
|
||||||
|
if (!r.barrelTried && hasEquip(t, 'barrel')) {
|
||||||
|
r.barrelTried = true;
|
||||||
|
purpose = 'barrel';
|
||||||
|
} else if (!r.jourTried && is(t, 'jourdonnais')) {
|
||||||
|
r.jourTried = true;
|
||||||
|
purpose = 'jourdonnais';
|
||||||
|
} else return false;
|
||||||
|
if (drawCheck(s, ctx, t, purpose, isHeart, false)) r.needed -= 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStore(s: BangState, ctx: Ctx, actor: PlayerId): void {
|
||||||
|
const order = clockwiseFrom(s, actor);
|
||||||
|
const revealed: string[] = [];
|
||||||
|
for (let i = 0; i < order.length; i++) {
|
||||||
|
const id = drawTop(s, ctx);
|
||||||
|
if (id) revealed.push(id);
|
||||||
|
}
|
||||||
|
log(s, ctx, { t: 'storeOpen', p: actor, cards: revealed.map(faceOf) });
|
||||||
|
const r: Extract<Reaction, { kind: 'store' }> = { kind: 'store', picker: actor, order, revealed, source: actor, deadline: 0 };
|
||||||
|
s.reactions.push(r);
|
||||||
|
storeAdvance(s, ctx, r);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Next picker in order; the last card goes to the next picker automatically; leftovers are discarded. */
|
||||||
|
export function storeAdvance(s: BangState, ctx: Ctx, r: Extract<Reaction, { kind: 'store' }>): void {
|
||||||
|
for (;;) {
|
||||||
|
if (r.revealed.length === 0) {
|
||||||
|
removeReaction(s, r);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = r.order.shift();
|
||||||
|
if (!next) {
|
||||||
|
s.discard.push(...r.revealed);
|
||||||
|
r.revealed = [];
|
||||||
|
removeReaction(s, r);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = P(s, next);
|
||||||
|
if (!p.alive) continue;
|
||||||
|
if (r.revealed.length === 1) {
|
||||||
|
const id = r.revealed.pop()!;
|
||||||
|
p.hand.push(id);
|
||||||
|
log(s, ctx, { t: 'store', p: p.id, card: faceOf(id) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
r.picker = next;
|
||||||
|
r.deadline = ctx.now + reactionMs(s, p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storePick(s: BangState, ctx: Ctx, r: Extract<Reaction, { kind: 'store' }>, cardId: string): void {
|
||||||
|
const p = P(s, r.picker);
|
||||||
|
r.revealed = r.revealed.filter((x) => x !== cardId);
|
||||||
|
p.hand.push(cardId);
|
||||||
|
log(s, ctx, { t: 'store', p: p.id, card: faceOf(cardId) });
|
||||||
|
storeAdvance(s, ctx, r);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playCard(s: BangState, ctx: Ctx, actor: PlayerId, a: PlayAction): void {
|
||||||
|
const p = P(s, actor);
|
||||||
|
const card = cardById(a.cardId);
|
||||||
|
const kind = a.as ?? card.kind;
|
||||||
|
const face = faceOf(card.id);
|
||||||
|
removeFromHand(s, ctx, p, [card.id]);
|
||||||
|
if (isBlue(kind)) {
|
||||||
|
const owner = kind === 'jail' ? P(s, a.target!) : p;
|
||||||
|
let replaced: CardFace | undefined;
|
||||||
|
if (isWeapon(kind)) {
|
||||||
|
const old = owner.inPlay.find((id) => isWeapon(kindOf(id)));
|
||||||
|
if (old) {
|
||||||
|
owner.inPlay = owner.inPlay.filter((x) => x !== old);
|
||||||
|
s.discard.push(old);
|
||||||
|
replaced = faceOf(old);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
owner.inPlay.push(card.id);
|
||||||
|
log(s, ctx, { t: 'equip', p: actor, card: face, ...(kind === 'jail' ? { target: owner.id } : {}), ...(replaced ? { replaced } : {}) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s.discard.push(card.id);
|
||||||
|
log(s, ctx, { t: 'play', p: actor, card: face, ...(a.as ? { as: a.as } : {}), ...(a.target ? { target: a.target } : {}) });
|
||||||
|
switch (kind) {
|
||||||
|
case 'bang':
|
||||||
|
s.turn.bangsPlayed += 1;
|
||||||
|
openBang(s, ctx, actor, a.target!, 'bang');
|
||||||
|
break;
|
||||||
|
case 'beer':
|
||||||
|
if (beerWorks(s)) heal(s, ctx, p, 1, 'beer');
|
||||||
|
break;
|
||||||
|
case 'saloon':
|
||||||
|
for (const x of living(s)) heal(s, ctx, x, 1, 'saloon');
|
||||||
|
break;
|
||||||
|
case 'stagecoach':
|
||||||
|
drawCards(s, ctx, p, 2, 'card');
|
||||||
|
break;
|
||||||
|
case 'wellsFargo':
|
||||||
|
drawCards(s, ctx, p, 3, 'card');
|
||||||
|
break;
|
||||||
|
case 'generalStore':
|
||||||
|
openStore(s, ctx, actor);
|
||||||
|
break;
|
||||||
|
case 'panic':
|
||||||
|
case 'catBalou': {
|
||||||
|
const t = P(s, a.target!);
|
||||||
|
let taken: string;
|
||||||
|
if (a.targetCard === 'hand') {
|
||||||
|
taken = t.hand[ctx.rng.int(t.hand.length)]!;
|
||||||
|
removeFromHand(s, ctx, t, [taken]);
|
||||||
|
} else {
|
||||||
|
taken = a.targetCard!;
|
||||||
|
t.inPlay = t.inPlay.filter((x) => x !== taken);
|
||||||
|
}
|
||||||
|
const fromHand = a.targetCard === 'hand';
|
||||||
|
if (kind === 'panic') {
|
||||||
|
p.hand.push(taken);
|
||||||
|
log(s, ctx, fromHand ? { t: 'take', p: actor, from: t.id, how: 'panic', card: faceOf(taken), vis: [actor, t.id] } : { t: 'take', p: actor, from: t.id, how: 'panic', equip: faceOf(taken) });
|
||||||
|
} else {
|
||||||
|
s.discard.push(taken);
|
||||||
|
log(s, ctx, { t: 'discarded', p: t.id, by: actor, card: faceOf(taken), fromHand });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'duel': {
|
||||||
|
const t = P(s, a.target!);
|
||||||
|
s.reactions.push({ kind: 'duel', actor: t.id, other: actor, source: actor, deadline: ctx.now + reactionMs(s, t) });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'indians':
|
||||||
|
for (const id of clockwiseFrom(s, actor)) {
|
||||||
|
if (id === actor) continue;
|
||||||
|
s.reactions.push({ kind: 'indians', target: id, source: actor, deadline: ctx.now + reactionMs(s, P(s, id)) });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'gatling':
|
||||||
|
for (const id of clockwiseFrom(s, actor)) if (id !== actor) openBang(s, ctx, actor, id, 'gatling');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`bang: cannot play ${kind}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- reactions
|
||||||
|
|
||||||
|
export function respond(s: BangState, ctx: Ctx, actor: PlayerId, cardIds: string[]): void {
|
||||||
|
const r = reactionOf(s, actor)!;
|
||||||
|
const p = P(s, actor);
|
||||||
|
const faces = cardIds.map(faceOf);
|
||||||
|
removeFromHand(s, ctx, p, cardIds);
|
||||||
|
s.discard.push(...cardIds);
|
||||||
|
switch (r.kind) {
|
||||||
|
case 'bang':
|
||||||
|
removeReaction(s, r);
|
||||||
|
log(s, ctx, { t: 'missed', p: actor, cards: faces });
|
||||||
|
break;
|
||||||
|
case 'indians':
|
||||||
|
removeReaction(s, r);
|
||||||
|
log(s, ctx, { t: 'respond', p: actor, cards: faces, why: 'indians' });
|
||||||
|
break;
|
||||||
|
case 'duel': {
|
||||||
|
log(s, ctx, { t: 'respond', p: actor, cards: faces, why: 'duel' });
|
||||||
|
const next = r.other;
|
||||||
|
r.other = r.actor;
|
||||||
|
r.actor = next;
|
||||||
|
r.deadline = ctx.now + reactionMs(s, P(s, next));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'dying':
|
||||||
|
heal(s, ctx, p, cardIds.length, 'beer', faces);
|
||||||
|
if (p.life >= 1) survived(s, ctx, r);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`bang: cannot respond to ${r.kind}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function takeHit(s: BangState, ctx: Ctx, actor: PlayerId): void {
|
||||||
|
const r = reactionOf(s, actor)!;
|
||||||
|
removeReaction(s, r);
|
||||||
|
switch (r.kind) {
|
||||||
|
case 'bang':
|
||||||
|
case 'indians':
|
||||||
|
damage(s, ctx, actor, 1, r.source);
|
||||||
|
break;
|
||||||
|
case 'duel':
|
||||||
|
damage(s, ctx, actor, 1, r.other);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`bang: cannot take a hit in ${r.kind}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sidHeal(s: BangState, ctx: Ctx, actor: PlayerId, cardIds: string[]): void {
|
||||||
|
const p = P(s, actor);
|
||||||
|
const faces = cardIds.map(faceOf);
|
||||||
|
removeFromHand(s, ctx, p, cardIds);
|
||||||
|
s.discard.push(...cardIds);
|
||||||
|
heal(s, ctx, p, 1, 'sid', faces);
|
||||||
|
const r = reactionOf(s, actor);
|
||||||
|
if (r?.kind === 'dying' && p.life >= 1) survived(s, ctx, r);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dying player recovers with whatever works (timeout / auto, §8.5). */
|
||||||
|
export function autoSave(s: BangState, ctx: Ctx, actor: PlayerId): void {
|
||||||
|
const p = P(s, actor);
|
||||||
|
const r = reactionOf(s, actor) as Extract<Reaction, { kind: 'dying' }>;
|
||||||
|
const needed = 1 - p.life;
|
||||||
|
const beers = beerWorks(s) ? p.hand.filter((id) => kindOf(id) === 'beer') : [];
|
||||||
|
if (beers.length >= needed) {
|
||||||
|
respond(s, ctx, actor, beers.slice(0, needed));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (maxRecovery(s, p) >= needed) {
|
||||||
|
// Sid Ketchum: 2-card heals with non-beer cards first, beers for the rest.
|
||||||
|
while (p.alive && p.life <= 0) {
|
||||||
|
const beer = beerWorks(s) ? p.hand.find((id) => kindOf(id) === 'beer') : undefined;
|
||||||
|
const others = p.hand.filter((id) => kindOf(id) !== 'beer');
|
||||||
|
if (others.length >= 2) sidHeal(s, ctx, actor, others.slice(0, 2));
|
||||||
|
else if (beer) {
|
||||||
|
removeFromHand(s, ctx, p, [beer]);
|
||||||
|
s.discard.push(beer);
|
||||||
|
heal(s, ctx, p, 1, 'beer', [faceOf(beer)]);
|
||||||
|
if (p.life >= 1) survived(s, ctx, r);
|
||||||
|
} else if (p.hand.length >= 2) sidHeal(s, ctx, actor, p.hand.slice(0, 2));
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
if (p.life >= 1) return;
|
||||||
|
}
|
||||||
|
removeReaction(s, r);
|
||||||
|
eliminate(s, ctx, actor, r.killer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kitKeep(s: BangState, ctx: Ctx, actor: PlayerId, keep: string[]): void {
|
||||||
|
const r = reactionOf(s, actor) as Extract<Reaction, { kind: 'kitKeep' }>;
|
||||||
|
const p = P(s, actor);
|
||||||
|
removeReaction(s, r);
|
||||||
|
p.hand.push(...keep);
|
||||||
|
for (const id of r.options) if (!keep.includes(id)) s.deck.push(id);
|
||||||
|
log(s, ctx, { t: 'draw', p: actor, n: keep.length, why: 'kit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickCharacter(s: BangState, ctx: Ctx, actor: PlayerId, c: CharacterId): void {
|
||||||
|
const p = P(s, actor);
|
||||||
|
const r = reactionOf(s, actor);
|
||||||
|
if (r) removeReaction(s, r);
|
||||||
|
p.character = c;
|
||||||
|
p.characterOffer = null;
|
||||||
|
p.maxLife = CHARACTER_LIFE[c] + (p.role === 'sheriff' ? 1 : 0);
|
||||||
|
p.life = p.maxLife;
|
||||||
|
log(s, ctx, { t: 'character', p: actor, character: c });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ends the play phase: the hand limit is the current life (§5.2 step 5). */
|
||||||
|
export function endPlay(s: BangState, ctx: Ctx, actor: PlayerId): void {
|
||||||
|
const p = P(s, actor);
|
||||||
|
if (p.hand.length > p.life) {
|
||||||
|
s.turn.phase = 'discard';
|
||||||
|
s.turn.deadline = Math.max(s.turn.deadline, ctx.now + DISCARD_MIN_MS);
|
||||||
|
} else s.turn.phase = 'ended';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discardDown(s: BangState, ctx: Ctx, actor: PlayerId, cardIds: string[]): void {
|
||||||
|
const p = P(s, actor);
|
||||||
|
removeFromHand(s, ctx, p, cardIds);
|
||||||
|
s.discard.push(...cardIds);
|
||||||
|
log(s, ctx, { t: 'limit', p: actor, cards: cardIds.map(faceOf) });
|
||||||
|
s.turn.phase = 'ended';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomDiscard(s: BangState, ctx: Ctx, actor: PlayerId): void {
|
||||||
|
const p = P(s, actor);
|
||||||
|
const n = p.hand.length - p.life;
|
||||||
|
if (n <= 0) {
|
||||||
|
s.turn.phase = 'ended';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ids = ctx.rng.shuffle(p.hand).slice(0, n);
|
||||||
|
discardDown(s, ctx, actor, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A player leaves the room: they are out of the game (no killer, cards discarded or to Vulture Sam). */
|
||||||
|
export function leave(s: BangState, ctx: Ctx, actor: PlayerId): void {
|
||||||
|
const p = P(s, actor);
|
||||||
|
if (s.turn.phase === 'draft' && p.characterOffer) {
|
||||||
|
const r = reactionOf(s, actor);
|
||||||
|
if (r) removeReaction(s, r);
|
||||||
|
p.character = p.characterOffer[0]!;
|
||||||
|
p.characterOffer = null;
|
||||||
|
}
|
||||||
|
eliminate(s, ctx, actor, null, true);
|
||||||
|
}
|
||||||
439
packages/games/src/bang/index.ts
Normal file
439
packages/games/src/bang/index.ts
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
/** 뱅! (docs/games/bang.md). Pure and deterministic; state is JSON. */
|
||||||
|
import { type GameDefinition, type GameResult, type PlayerId } from '@bg/engine';
|
||||||
|
import { CARDS, CHARACTER_IDS, CHARACTER_LIFE, cardById, kindOf, rolesFor } from './cards';
|
||||||
|
import { distance, rangeOf } from './distance';
|
||||||
|
import {
|
||||||
|
AWAY_AFTER,
|
||||||
|
P,
|
||||||
|
autoSave,
|
||||||
|
discardDown,
|
||||||
|
doDraw,
|
||||||
|
eliminate,
|
||||||
|
log,
|
||||||
|
endPlay,
|
||||||
|
kitKeep,
|
||||||
|
leave,
|
||||||
|
limboCards,
|
||||||
|
living,
|
||||||
|
pickCharacter,
|
||||||
|
playCard,
|
||||||
|
randomDiscard,
|
||||||
|
reactionOf,
|
||||||
|
respond,
|
||||||
|
responder,
|
||||||
|
settle,
|
||||||
|
sidHeal,
|
||||||
|
storePick,
|
||||||
|
takeHit,
|
||||||
|
tryBarrel,
|
||||||
|
type Ctx,
|
||||||
|
} from './effects';
|
||||||
|
import { bangLike, missedLike, playOptions, promptFor, sidHealContext, unlimitedBang, validateAction, waiting } from './rules';
|
||||||
|
import {
|
||||||
|
bangActionSchema,
|
||||||
|
bangOptionsSchema,
|
||||||
|
type BangAction,
|
||||||
|
type BangOptions,
|
||||||
|
type BangState,
|
||||||
|
type BangView,
|
||||||
|
type LogEntry,
|
||||||
|
type PlayerState,
|
||||||
|
type Reaction,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export * from './cards';
|
||||||
|
export * from './types';
|
||||||
|
export { distance, rangeOf, seatDistance, hasEquip, weaponOf } from './distance';
|
||||||
|
export { checkPlay, playOptions, promptFor, waiting } from './rules';
|
||||||
|
|
||||||
|
const LOG_VIEW = 30;
|
||||||
|
|
||||||
|
/** Copy for `apply`: containers are copied; log entries and options are never mutated and are shared. */
|
||||||
|
function cloneState(s: BangState): BangState {
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
players: s.players.map((p) => ({ ...p, hand: [...p.hand], inPlay: [...p.inPlay], characterOffer: p.characterOffer ? [...p.characterOffer] : null })),
|
||||||
|
deck: [...s.deck],
|
||||||
|
discard: [...s.discard],
|
||||||
|
turn: { ...s.turn },
|
||||||
|
reactions: s.reactions.map((r) => {
|
||||||
|
if (r.kind === 'store') return { ...r, order: [...r.order], revealed: [...r.revealed] };
|
||||||
|
if (r.kind === 'kitKeep') return { ...r, options: [...r.options] };
|
||||||
|
return { ...r };
|
||||||
|
}),
|
||||||
|
deathOrder: [...s.deathOrder],
|
||||||
|
log: [...s.log],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every card is in exactly one place (test invariant). */
|
||||||
|
export function allCards(s: BangState): string[] {
|
||||||
|
return [...s.deck, ...s.discard, ...s.players.flatMap((p) => [...p.hand, ...p.inPlay]), ...limboCards(s)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function deadlineOf(s: BangState): number | null {
|
||||||
|
if (s.winner) return null;
|
||||||
|
if (s.reactions.length) return Math.min(...s.reactions.map((r) => r.deadline));
|
||||||
|
return s.turn.deadline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Responders whose own deadline is the earliest (a later reaction must not be cut short). */
|
||||||
|
function timed(s: BangState): PlayerId[] {
|
||||||
|
if (s.winner) return [];
|
||||||
|
if (!s.reactions.length) return [s.turn.player];
|
||||||
|
const d = deadlineOf(s)!;
|
||||||
|
return [...new Set(s.reactions.filter((r) => r.deadline === d).map(responder))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** §8.5 automatic choice for a player who did not answer in time. */
|
||||||
|
function onTimeoutApply(s: BangState, ctx: Ctx, actor: PlayerId): void {
|
||||||
|
const p = P(s, actor);
|
||||||
|
const r = reactionOf(s, actor);
|
||||||
|
if (r) {
|
||||||
|
switch (r.kind) {
|
||||||
|
case 'choose2':
|
||||||
|
pickCharacter(s, ctx, actor, p.characterOffer![0]!);
|
||||||
|
return;
|
||||||
|
case 'kitKeep':
|
||||||
|
kitKeep(s, ctx, actor, r.options.slice(0, 2));
|
||||||
|
return;
|
||||||
|
case 'store':
|
||||||
|
storePick(s, ctx, r, r.revealed[0]!);
|
||||||
|
return;
|
||||||
|
case 'bang': {
|
||||||
|
if (!s.options.autoDefend) return takeHit(s, ctx, actor);
|
||||||
|
while (r.needed > 0 && tryBarrel(s, ctx, r));
|
||||||
|
if (r.needed <= 0) {
|
||||||
|
s.reactions.splice(s.reactions.indexOf(r), 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cards = p.hand.filter((id) => missedLike(p, id));
|
||||||
|
// Calamity Janet keeps 뱅! when a 빗나감! does the job.
|
||||||
|
cards.sort((a, b) => Number(kindOf(a) !== 'missed') - Number(kindOf(b) !== 'missed'));
|
||||||
|
if (cards.length >= r.needed) return respond(s, ctx, actor, cards.slice(0, r.needed));
|
||||||
|
return takeHit(s, ctx, actor);
|
||||||
|
}
|
||||||
|
case 'indians':
|
||||||
|
case 'duel': {
|
||||||
|
const card = s.options.autoDefend ? p.hand.find((id) => bangLike(p, id)) : undefined;
|
||||||
|
if (card) return respond(s, ctx, actor, [card]);
|
||||||
|
return takeHit(s, ctx, actor);
|
||||||
|
}
|
||||||
|
case 'dying':
|
||||||
|
return autoSave(s, ctx, actor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (s.turn.phase) {
|
||||||
|
case 'draw':
|
||||||
|
return doDraw(s, ctx, {});
|
||||||
|
case 'play':
|
||||||
|
endPlay(s, ctx, actor);
|
||||||
|
if ((s.turn.phase as string) === 'discard') randomDiscard(s, ctx, actor);
|
||||||
|
return;
|
||||||
|
case 'discard':
|
||||||
|
return randomDiscard(s, ctx, actor);
|
||||||
|
default:
|
||||||
|
throw new Error(`bang: timeout in ${s.turn.phase}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleVisible(s: BangState, p: PlayerState, viewer: PlayerId | null): boolean {
|
||||||
|
return p.role === 'sheriff' || !p.alive || p.id === viewer || !!s.winner;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripPrivate(e: LogEntry, viewer: PlayerId | null, over: boolean): LogEntry {
|
||||||
|
if (e.t !== 'take' || !e.vis || over || (viewer && e.vis.includes(viewer))) return e;
|
||||||
|
const { card: _card, ...rest } = e;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Def = GameDefinition<BangState, BangAction, BangView, BangOptions>;
|
||||||
|
|
||||||
|
export const bang: Def = {
|
||||||
|
id: 'bang',
|
||||||
|
nameKo: '뱅!',
|
||||||
|
minPlayers: 4,
|
||||||
|
maxPlayers: 7,
|
||||||
|
stateVersion: 1,
|
||||||
|
defaultOptions: bangOptionsSchema.parse({}),
|
||||||
|
optionsSchema: bangOptionsSchema as unknown as Def['optionsSchema'],
|
||||||
|
actionSchema: bangActionSchema as unknown as Def['actionSchema'],
|
||||||
|
|
||||||
|
setup({ players, options, rng, now }) {
|
||||||
|
if (players.length < 4 || players.length > 7) throw new Error(`bang: ${players.length} players not allowed`);
|
||||||
|
// §4: seats, roles, characters, then the deck.
|
||||||
|
const seats = rng.shuffle(players);
|
||||||
|
const roles = rng.shuffle(rolesFor(players.length, options.deputyCount7));
|
||||||
|
const chars = rng.shuffle(CHARACTER_IDS);
|
||||||
|
const draft = options.characterDraft === 'choose2';
|
||||||
|
const ps: PlayerState[] = seats.map((id, i) => {
|
||||||
|
const offer = draft ? [chars[2 * i]!, chars[2 * i + 1]!] : null;
|
||||||
|
const character = draft ? offer![0]! : chars[i]!;
|
||||||
|
const maxLife = CHARACTER_LIFE[character] + (roles[i] === 'sheriff' ? 1 : 0);
|
||||||
|
return { id, seat: i, role: roles[i]!, character, characterOffer: offer, maxLife, life: maxLife, alive: true, hand: [], inPlay: [], timeoutsInRow: 0, away: false };
|
||||||
|
});
|
||||||
|
const s: BangState = {
|
||||||
|
options,
|
||||||
|
players: ps,
|
||||||
|
deck: rng.shuffle(CARDS.map((c) => c.id)),
|
||||||
|
discard: [],
|
||||||
|
turn: { player: ps.find((p) => p.role === 'sheriff')!.id, phase: 'draft', bangsPlayed: 0, deadline: now, pausedAt: null, dynamiteChecked: false, jailChecked: false },
|
||||||
|
reactions: draft ? ps.map((p) => ({ kind: 'choose2' as const, player: p.id, deadline: now + 30_000 })) : [],
|
||||||
|
lastCheck: null,
|
||||||
|
winner: null,
|
||||||
|
deathOrder: [],
|
||||||
|
round: 0,
|
||||||
|
seq: 0,
|
||||||
|
log: [],
|
||||||
|
final: null,
|
||||||
|
};
|
||||||
|
settle(s, { rng, now, events: [] });
|
||||||
|
return s;
|
||||||
|
},
|
||||||
|
|
||||||
|
validate: validateAction,
|
||||||
|
|
||||||
|
apply(prev, actor, a, { rng, now }) {
|
||||||
|
const s = cloneState(prev);
|
||||||
|
const ctx: Ctx = { rng, now, events: [] };
|
||||||
|
s.seq += 1;
|
||||||
|
const p = P(s, actor);
|
||||||
|
if (a.type !== 'timeout' && a.type !== 'leave') {
|
||||||
|
p.timeoutsInRow = 0;
|
||||||
|
p.away = false;
|
||||||
|
}
|
||||||
|
switch (a.type) {
|
||||||
|
case 'timeout':
|
||||||
|
p.timeoutsInRow += 1;
|
||||||
|
if (p.timeoutsInRow >= AWAY_AFTER) p.away = true;
|
||||||
|
log(s, ctx, { t: 'timeout', p: actor });
|
||||||
|
onTimeoutApply(s, ctx, actor);
|
||||||
|
break;
|
||||||
|
case 'leave':
|
||||||
|
leave(s, ctx, actor);
|
||||||
|
break;
|
||||||
|
case 'pickCharacter':
|
||||||
|
pickCharacter(s, ctx, actor, a.character);
|
||||||
|
break;
|
||||||
|
case 'draw':
|
||||||
|
doDraw(s, ctx, a);
|
||||||
|
break;
|
||||||
|
case 'kitKeep':
|
||||||
|
kitKeep(s, ctx, actor, a.keep);
|
||||||
|
break;
|
||||||
|
case 'play':
|
||||||
|
playCard(s, ctx, actor, a);
|
||||||
|
break;
|
||||||
|
case 'respond':
|
||||||
|
respond(s, ctx, actor, a.cardIds);
|
||||||
|
break;
|
||||||
|
case 'useBarrel': {
|
||||||
|
const r = reactionOf(s, actor) as Extract<Reaction, { kind: 'bang' }>;
|
||||||
|
tryBarrel(s, ctx, r);
|
||||||
|
if (r.needed <= 0) s.reactions.splice(s.reactions.indexOf(r), 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'takeHit':
|
||||||
|
takeHit(s, ctx, actor);
|
||||||
|
break;
|
||||||
|
case 'giveUp': {
|
||||||
|
const r = reactionOf(s, actor) as Extract<Reaction, { kind: 'dying' }>;
|
||||||
|
s.reactions.splice(s.reactions.indexOf(r), 1);
|
||||||
|
eliminate(s, ctx, actor, r.killer);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'storePick':
|
||||||
|
storePick(s, ctx, reactionOf(s, actor) as Extract<Reaction, { kind: 'store' }>, a.cardId);
|
||||||
|
break;
|
||||||
|
case 'sidHeal':
|
||||||
|
sidHeal(s, ctx, actor, a.cardIds);
|
||||||
|
break;
|
||||||
|
case 'endPlay':
|
||||||
|
endPlay(s, ctx, actor);
|
||||||
|
break;
|
||||||
|
case 'discard':
|
||||||
|
discardDown(s, ctx, actor, a.cardIds);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
settle(s, ctx);
|
||||||
|
return { state: s, events: ctx.events };
|
||||||
|
},
|
||||||
|
|
||||||
|
view(s, viewer) {
|
||||||
|
const me = viewer && s.players.some((p) => p.id === viewer) ? viewer : null;
|
||||||
|
const meP = me ? P(s, me) : null;
|
||||||
|
const over = !!s.winner;
|
||||||
|
const reactingOf = new Map(s.reactions.map((r) => [responder(r), r.kind]));
|
||||||
|
const players = s.players.map((p) => {
|
||||||
|
const hiddenChar = !!p.characterOffer && p.id !== me && !over;
|
||||||
|
const dist = meP && meP.alive && p.alive && p.id !== me && !p.characterOffer && !meP.characterOffer ? distance(s, me!, p.id) : null;
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
seat: p.seat,
|
||||||
|
character: hiddenChar || p.characterOffer ? null : p.character,
|
||||||
|
role: roleVisible(s, p, me) ? p.role : null,
|
||||||
|
maxLife: p.maxLife,
|
||||||
|
life: p.life,
|
||||||
|
alive: p.alive,
|
||||||
|
handCount: p.hand.length,
|
||||||
|
inPlay: p.inPlay.map(cardById),
|
||||||
|
isTurn: !over && s.turn.player === p.id && s.turn.phase !== 'draft',
|
||||||
|
reacting: reactingOf.get(p.id) ?? null,
|
||||||
|
away: p.away,
|
||||||
|
distance: dist,
|
||||||
|
inRange: dist !== null && dist <= rangeOf(meP!),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
options: s.options,
|
||||||
|
me,
|
||||||
|
players,
|
||||||
|
current: s.turn.player,
|
||||||
|
phase: s.turn.phase,
|
||||||
|
round: s.round,
|
||||||
|
bangsPlayed: s.turn.bangsPlayed,
|
||||||
|
deckCount: s.deck.length,
|
||||||
|
discardCount: s.discard.length,
|
||||||
|
discardTop: s.discard.slice(-4).map(cardById),
|
||||||
|
reactions: s.reactions.map((r) => ({
|
||||||
|
kind: r.kind,
|
||||||
|
who: responder(r),
|
||||||
|
...('source' in r && r.source ? { source: r.source } : {}),
|
||||||
|
...(r.kind === 'bang' ? { via: r.via, needed: r.needed } : {}),
|
||||||
|
...(r.kind === 'store' ? { revealed: r.revealed.map(cardById) } : {}),
|
||||||
|
deadline: r.deadline,
|
||||||
|
})),
|
||||||
|
lastCheck: s.lastCheck,
|
||||||
|
my: meP
|
||||||
|
? {
|
||||||
|
role: meP.role,
|
||||||
|
character: meP.characterOffer ? null : meP.character,
|
||||||
|
hand: meP.hand.map(cardById),
|
||||||
|
range: rangeOf(meP),
|
||||||
|
unlimitedBang: unlimitedBang(meP),
|
||||||
|
plays: playOptions(s, me!),
|
||||||
|
canEndPlay: !over && s.turn.player === me && s.turn.phase === 'play' && s.reactions.length === 0 && meP.alive,
|
||||||
|
canSidHeal: sidHealContext(s, me!) && meP.life < meP.maxLife && meP.hand.length >= 2,
|
||||||
|
prompt: promptFor(s, me!),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
turnDeadline: s.turn.deadline,
|
||||||
|
deadlineTotalMs: s.options.turnSeconds * 1000,
|
||||||
|
reactionTotalMs: s.options.reactionSeconds * 1000,
|
||||||
|
log: s.log.slice(-LOG_VIEW).map((e) => stripPrivate(e, me, over)),
|
||||||
|
winner: s.winner,
|
||||||
|
result: s.final,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
activePlayers: waiting,
|
||||||
|
|
||||||
|
deadline: deadlineOf,
|
||||||
|
|
||||||
|
timeoutPlayers: timed,
|
||||||
|
|
||||||
|
onTimeout() {
|
||||||
|
return { type: 'timeout' };
|
||||||
|
},
|
||||||
|
|
||||||
|
onLeave(s, player) {
|
||||||
|
const p = s.players.find((x) => x.id === player);
|
||||||
|
if (!p || !p.alive || s.winner) return null;
|
||||||
|
return { type: 'leave' };
|
||||||
|
},
|
||||||
|
|
||||||
|
result(s): GameResult | null {
|
||||||
|
const f = s.final;
|
||||||
|
if (!f) return null;
|
||||||
|
const winners = f.ranking.filter((id) => f.players.find((p) => p.id === id)!.won);
|
||||||
|
const losers = f.ranking.filter((id) => !winners.includes(id));
|
||||||
|
return {
|
||||||
|
ranking: [winners, ...losers.map((id) => [id])],
|
||||||
|
scores: Object.fromEntries(f.players.map((p) => [p.id, p.won ? 1 : 0])),
|
||||||
|
summary: f.summary,
|
||||||
|
reason: 'normal',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
legalActions(s, player) {
|
||||||
|
const p = s.players.find((x) => x.id === player);
|
||||||
|
if (!p || !p.alive || s.winner) return [];
|
||||||
|
const out: BangAction[] = [];
|
||||||
|
const r = reactionOf(s, player);
|
||||||
|
const sidPairs = (): BangAction[] => (p.hand.length >= 2 ? [{ type: 'sidHeal', cardIds: [p.hand[0]!, p.hand[1]!] }] : []);
|
||||||
|
if (r) {
|
||||||
|
switch (r.kind) {
|
||||||
|
case 'choose2':
|
||||||
|
for (const c of p.characterOffer ?? []) out.push({ type: 'pickCharacter', character: c });
|
||||||
|
break;
|
||||||
|
case 'kitKeep': {
|
||||||
|
const o = r.options;
|
||||||
|
for (let i = 0; i < o.length; i++) for (let j = i + 1; j < o.length; j++) out.push({ type: 'kitKeep', keep: [o[i]!, o[j]!] });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'store':
|
||||||
|
for (const id of r.revealed) out.push({ type: 'storePick', cardId: id });
|
||||||
|
break;
|
||||||
|
case 'bang': {
|
||||||
|
const cards = p.hand.filter((id) => missedLike(p, id));
|
||||||
|
if (cards.length >= r.needed) out.push({ type: 'respond', cardIds: cards.slice(0, r.needed) });
|
||||||
|
out.push({ type: 'takeHit' }, { type: 'useBarrel' }, ...sidPairs());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'indians':
|
||||||
|
case 'duel': {
|
||||||
|
const card = p.hand.find((id) => bangLike(p, id));
|
||||||
|
if (card) out.push({ type: 'respond', cardIds: [card] });
|
||||||
|
out.push({ type: 'takeHit' }, ...sidPairs());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'dying': {
|
||||||
|
const beers = p.hand.filter((id) => kindOf(id) === 'beer');
|
||||||
|
const need = 1 - p.life;
|
||||||
|
if (beers.length >= need) out.push({ type: 'respond', cardIds: beers.slice(0, need) });
|
||||||
|
out.push({ type: 'giveUp' }, ...sidPairs());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.filter((a) => validateAction(s, player, a).ok);
|
||||||
|
}
|
||||||
|
if (s.reactions.length || s.turn.player !== player) return [];
|
||||||
|
switch (s.turn.phase) {
|
||||||
|
case 'draw':
|
||||||
|
out.push({ type: 'draw' }, { type: 'draw', pedroDiscard: true });
|
||||||
|
for (const q of living(s)) out.push({ type: 'draw', jesseFrom: q.id });
|
||||||
|
break;
|
||||||
|
case 'play':
|
||||||
|
for (const o of playOptions(s, player)) {
|
||||||
|
if (!o.ok) continue;
|
||||||
|
const base = { type: 'play' as const, cardId: o.cardId, ...(o.as ? { as: o.as } : {}) };
|
||||||
|
if (!o.targets) out.push(base);
|
||||||
|
else
|
||||||
|
for (const t of o.targets) {
|
||||||
|
if (!t.ok) continue;
|
||||||
|
if (t.cards) for (const tc of t.cards) out.push({ ...base, target: t.id, targetCard: tc });
|
||||||
|
else out.push({ ...base, target: t.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push({ type: 'endPlay' }, ...sidPairs());
|
||||||
|
break;
|
||||||
|
case 'discard': {
|
||||||
|
const n = p.hand.length - p.life;
|
||||||
|
if (n > 0) out.push({ type: 'discard', cardIds: p.hand.slice(0, n) }, { type: 'discard', cardIds: p.hand.slice(-n) });
|
||||||
|
out.push(...sidPairs());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.filter((a) => validateAction(s, player, a).ok);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Own hand, own Kit Carlson / choose2 offers, and the deck — ids exactly as they would appear in a view. */
|
||||||
|
secretsOf(s, player) {
|
||||||
|
const p = s.players.find((x) => x.id === player);
|
||||||
|
if (!p) return [];
|
||||||
|
const kit = s.reactions.flatMap((r) => (r.kind === 'kitKeep' && r.player === player ? r.options : []));
|
||||||
|
return [...p.hand, ...kit, ...s.deck, ...(p.characterOffer ?? [])];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const _internal = { cloneState, timed, deadlineOf };
|
||||||
274
packages/games/src/bang/rules.ts
Normal file
274
packages/games/src/bang/rules.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
/** 뱅! legality checks (docs/games/bang.md §5.2–§5.4, §8.2) and what the view needs from them. */
|
||||||
|
import { fail, ok, type PlayerId, type Validation } from '@bg/engine';
|
||||||
|
import { cardById, isBlue, kindOf, type CardKind } from './cards';
|
||||||
|
import { distance, hasEquip, rangeOf } from './distance';
|
||||||
|
import { barrelLeft, beerWorks, drawChoice, findPlayer, is, living, P, reactionOf, responder } from './effects';
|
||||||
|
import type { BangAction, BangState, PlayAction, PlayOption, PlayerState, Prompt, Reaction, TargetOption } from './types';
|
||||||
|
|
||||||
|
const TARGETED: CardKind[] = ['bang', 'panic', 'catBalou', 'duel', 'jail'];
|
||||||
|
export const needsTarget = (k: CardKind): boolean => TARGETED.includes(k);
|
||||||
|
|
||||||
|
export const unlimitedBang = (p: PlayerState): boolean => is(p, 'willy') || hasEquip(p, 'volcanic');
|
||||||
|
|
||||||
|
/** Cards that answer a 뱅! (빗나감!, or 뱅! for Calamity Janet). */
|
||||||
|
export const missedLike = (p: PlayerState, id: string): boolean => kindOf(id) === 'missed' || (is(p, 'calamity') && kindOf(id) === 'bang');
|
||||||
|
/** Cards that answer 인디언! / 결투 (뱅!, or 빗나감! for Calamity Janet). */
|
||||||
|
export const bangLike = (p: PlayerState, id: string): boolean => kindOf(id) === 'bang' || (is(p, 'calamity') && kindOf(id) === 'missed');
|
||||||
|
|
||||||
|
export const myPlayTime = (s: BangState, p: PlayerId): boolean => !s.winner && s.turn.player === p && s.turn.phase === 'play' && s.reactions.length === 0;
|
||||||
|
|
||||||
|
/** null = the play is legal; otherwise the Korean reason. */
|
||||||
|
export function checkPlay(s: BangState, actor: PlayerId, a: PlayAction): string | null {
|
||||||
|
const p = P(s, actor);
|
||||||
|
if (s.turn.player !== actor || s.turn.phase !== 'play') return '지금은 카드를 낼 수 없어요.';
|
||||||
|
if (s.reactions.length > 0) return '다른 사람의 반응을 기다리고 있어요.';
|
||||||
|
if (!p.hand.includes(a.cardId)) return '손에 없는 카드예요.';
|
||||||
|
const card = cardById(a.cardId);
|
||||||
|
if (a.as !== undefined) {
|
||||||
|
if (!is(p, 'calamity')) return '캘러미티 자넷만 카드를 바꿔 낼 수 있어요.';
|
||||||
|
if (a.as !== 'bang' || card.kind !== 'missed') return '내 차례에는 빗나감!을 뱅!으로만 바꿔 낼 수 있어요.';
|
||||||
|
}
|
||||||
|
const kind: CardKind = a.as ?? card.kind;
|
||||||
|
if (kind === 'missed') return '빗나감!은 공격을 받았을 때만 낼 수 있어요.';
|
||||||
|
if (a.targetCard !== undefined && kind !== 'panic' && kind !== 'catBalou') return '이 카드는 카드를 고르지 않아요.';
|
||||||
|
if (!needsTarget(kind)) {
|
||||||
|
if (a.target !== undefined) return '이 카드는 대상을 고르지 않아요.';
|
||||||
|
} else {
|
||||||
|
if (!a.target) return '대상을 골라 주세요.';
|
||||||
|
const t = findPlayer(s, a.target);
|
||||||
|
if (!t) return '없는 플레이어예요.';
|
||||||
|
if (!t.alive) return '이미 쓰러진 사람이에요.';
|
||||||
|
}
|
||||||
|
const t = a.target ? P(s, a.target) : null;
|
||||||
|
switch (kind) {
|
||||||
|
case 'bang': {
|
||||||
|
if (t!.id === actor) return '자기 자신은 쏠 수 없어요.';
|
||||||
|
if (s.turn.bangsPlayed >= 1 && !unlimitedBang(p)) return '이번 턴에는 뱅!을 이미 썼어요.';
|
||||||
|
const d = distance(s, actor, t!.id);
|
||||||
|
const r = rangeOf(p);
|
||||||
|
if (d > r) return `사거리가 닿지 않아요(거리 ${d}, 사거리 ${r}).`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
case 'beer':
|
||||||
|
if (s.options.beerAtFullHealth) return null;
|
||||||
|
if (!beerWorks(s)) return '두 명만 남으면 맥주는 효과가 없어요.';
|
||||||
|
if (p.life >= p.maxLife) return '생명이 가득 차서 맥주를 쓸 수 없어요.';
|
||||||
|
return null;
|
||||||
|
case 'panic':
|
||||||
|
case 'catBalou': {
|
||||||
|
const self = t!.id === actor;
|
||||||
|
if (self && !s.options.selfTargetCatBalou) return '자기 자신에게는 쓸 수 없어요.';
|
||||||
|
const tc = a.targetCard;
|
||||||
|
if (!tc) return '가져올 카드를 골라 주세요.';
|
||||||
|
if (tc === 'hand') {
|
||||||
|
if (self) return '내 손패에는 쓸 수 없어요.';
|
||||||
|
if (t!.hand.length === 0) return '손패가 없는 사람이에요.';
|
||||||
|
} else if (!t!.inPlay.includes(tc)) return '그 사람 앞에 없는 카드예요.';
|
||||||
|
if (kind === 'panic' && !self) {
|
||||||
|
const d = distance(s, actor, t!.id);
|
||||||
|
if (d > 1) return `패닉!은 거리 1인 사람에게만 쓸 수 있어요(거리 ${d}).`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
case 'duel':
|
||||||
|
return t!.id === actor ? '자기 자신과는 결투할 수 없어요.' : null;
|
||||||
|
case 'jail':
|
||||||
|
if (t!.id === actor) return '자기 자신은 감옥에 넣을 수 없어요.';
|
||||||
|
if (t!.role === 'sheriff') return '보안관은 감옥에 넣을 수 없어요.';
|
||||||
|
if (hasEquip(t!, 'jail')) return '이미 감옥에 있는 사람이에요.';
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
if (isBlue(kind) && hasEquip(p, kind)) return '같은 카드를 이미 앞에 놓았어요.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sid Ketchum may heal on his own turn or while answering a reaction aimed at him. */
|
||||||
|
export function sidHealContext(s: BangState, actor: PlayerId): boolean {
|
||||||
|
const p = P(s, actor);
|
||||||
|
if (!is(p, 'sid') || !p.alive || s.winner) return false;
|
||||||
|
const r = reactionOf(s, actor);
|
||||||
|
if (r) return r.kind === 'bang' || r.kind === 'indians' || r.kind === 'duel' || r.kind === 'dying';
|
||||||
|
return s.turn.player === actor && s.reactions.length === 0 && (s.turn.phase === 'play' || s.turn.phase === 'discard');
|
||||||
|
}
|
||||||
|
|
||||||
|
function distinctInHand(p: PlayerState, ids: string[]): string | null {
|
||||||
|
if (new Set(ids).size !== ids.length) return '같은 카드를 두 번 고를 수 없어요.';
|
||||||
|
if (ids.some((id) => !p.hand.includes(id))) return '손에 없는 카드예요.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRespond(s: BangState, p: PlayerState, r: Reaction, ids: string[]): string | null {
|
||||||
|
const bad = distinctInHand(p, ids);
|
||||||
|
if (bad) return bad;
|
||||||
|
switch (r.kind) {
|
||||||
|
case 'bang':
|
||||||
|
if (ids.length !== r.needed) return r.needed > 1 ? `빗나감! ${r.needed}장을 한꺼번에 내야 막을 수 있어요.` : '빗나감! 1장을 골라 주세요.';
|
||||||
|
if (!ids.every((id) => missedLike(p, id))) return is(p, 'calamity') ? '빗나감!이나 뱅!만 낼 수 있어요.' : '빗나감!만 낼 수 있어요.';
|
||||||
|
return null;
|
||||||
|
case 'indians':
|
||||||
|
case 'duel':
|
||||||
|
if (ids.length !== 1) return '뱅! 1장을 골라 주세요.';
|
||||||
|
if (!bangLike(p, ids[0]!)) return is(p, 'calamity') ? '뱅!이나 빗나감!만 낼 수 있어요.' : '뱅!만 낼 수 있어요.';
|
||||||
|
return null;
|
||||||
|
case 'dying': {
|
||||||
|
if (!beerWorks(s)) return '두 명만 남으면 맥주로 살아날 수 없어요.';
|
||||||
|
const need = 1 - p.life;
|
||||||
|
if (ids.length !== need) return `맥주 ${need}장이 필요해요.`;
|
||||||
|
if (!ids.every((id) => kindOf(id) === 'beer')) return '맥주만 낼 수 있어요.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return '지금은 카드를 낼 수 없어요.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everyone the game is waiting for (reactions first; otherwise the current player). */
|
||||||
|
export function waiting(s: BangState): PlayerId[] {
|
||||||
|
if (s.winner) return [];
|
||||||
|
if (s.reactions.length > 0) return [...new Set(s.reactions.map(responder))];
|
||||||
|
return [s.turn.player];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAction(s: BangState, actor: PlayerId, a: BangAction): Validation {
|
||||||
|
const p = findPlayer(s, actor);
|
||||||
|
if (!p) return fail('이 게임의 플레이어가 아니에요.');
|
||||||
|
if (s.winner) return fail('게임이 이미 끝났어요.');
|
||||||
|
if (a.type === 'leave') return p.alive ? ok : fail('이미 게임에서 빠졌어요.');
|
||||||
|
if (!p.alive) return fail('쓰러진 사람은 행동할 수 없어요.');
|
||||||
|
const r = reactionOf(s, actor);
|
||||||
|
const why = (reason: string | null): Validation => (reason ? fail(reason) : ok);
|
||||||
|
switch (a.type) {
|
||||||
|
case 'timeout':
|
||||||
|
return waiting(s).includes(actor) ? ok : fail('시간 초과 대상이 아니에요.');
|
||||||
|
case 'pickCharacter':
|
||||||
|
if (r?.kind !== 'choose2' || !p.characterOffer) return fail('지금은 캐릭터를 고를 때가 아니에요.');
|
||||||
|
return p.characterOffer.includes(a.character) ? ok : fail('받은 캐릭터 중에서 골라 주세요.');
|
||||||
|
case 'draw': {
|
||||||
|
if (s.turn.player !== actor || s.turn.phase !== 'draw' || s.reactions.length) return fail('지금은 카드를 받을 때가 아니에요.');
|
||||||
|
if (a.jesseFrom !== undefined && a.pedroDiscard) return fail('한 가지 방법만 고를 수 있어요.');
|
||||||
|
const choice = drawChoice(s, p);
|
||||||
|
if (a.jesseFrom !== undefined) {
|
||||||
|
if (!is(p, 'jesse')) return fail('제시 존스만 다른 사람 손패에서 받을 수 있어요.');
|
||||||
|
if (!choice?.jesse.includes(a.jesseFrom)) return fail('손패가 있는 다른 사람을 골라 주세요.');
|
||||||
|
}
|
||||||
|
if (a.pedroDiscard) {
|
||||||
|
if (!is(p, 'pedro')) return fail('페드로 라미레스만 버린 카드 더미에서 받을 수 있어요.');
|
||||||
|
if (!choice?.pedro) return fail('버린 카드 더미가 비어 있어요.');
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
case 'kitKeep': {
|
||||||
|
if (r?.kind !== 'kitKeep') return fail('지금은 카드를 고를 때가 아니에요.');
|
||||||
|
const [x, y] = a.keep;
|
||||||
|
if (x === y) return fail('서로 다른 카드 2장을 골라 주세요.');
|
||||||
|
return r.options.includes(x) && r.options.includes(y) ? ok : fail('보이는 카드 중에서 골라 주세요.');
|
||||||
|
}
|
||||||
|
case 'play':
|
||||||
|
return why(checkPlay(s, actor, a));
|
||||||
|
case 'respond':
|
||||||
|
if (!r || !['bang', 'indians', 'duel', 'dying'].includes(r.kind)) return fail('지금은 낼 수 있는 반응이 없어요.');
|
||||||
|
return why(checkRespond(s, p, r, a.cardIds));
|
||||||
|
case 'useBarrel':
|
||||||
|
if (r?.kind !== 'bang') return fail('뱅!을 받았을 때만 술통 판정을 해요.');
|
||||||
|
if (s.options.autoBarrel) return fail('술통 판정은 자동으로 해요.');
|
||||||
|
return barrelLeft(s, r) ? ok : fail('더 할 수 있는 술통 판정이 없어요.');
|
||||||
|
case 'takeHit':
|
||||||
|
return r && (r.kind === 'bang' || r.kind === 'indians' || r.kind === 'duel') ? ok : fail('지금은 맞을 공격이 없어요.');
|
||||||
|
case 'giveUp':
|
||||||
|
return r?.kind === 'dying' ? ok : fail('지금은 쓰러질 상황이 아니에요.');
|
||||||
|
case 'storePick':
|
||||||
|
if (r?.kind !== 'store') return fail('지금은 잡화점 카드를 고를 차례가 아니에요.');
|
||||||
|
return r.revealed.includes(a.cardId) ? ok : fail('펼쳐진 카드 중에서 골라 주세요.');
|
||||||
|
case 'sidHeal': {
|
||||||
|
if (!is(p, 'sid')) return fail('시드 케첨만 쓸 수 있는 능력이에요.');
|
||||||
|
if (!sidHealContext(s, actor)) return fail('지금은 능력을 쓸 수 없어요.');
|
||||||
|
if (p.life >= p.maxLife) return fail('생명이 이미 가득 찼어요.');
|
||||||
|
return why(distinctInHand(p, a.cardIds));
|
||||||
|
}
|
||||||
|
case 'endPlay':
|
||||||
|
return myPlayTime(s, actor) ? ok : fail('지금은 턴을 마칠 수 없어요.');
|
||||||
|
case 'discard': {
|
||||||
|
if (s.turn.player !== actor || s.turn.phase !== 'discard' || s.reactions.length) return fail('지금은 카드를 버릴 때가 아니에요.');
|
||||||
|
const need = p.hand.length - p.life;
|
||||||
|
if (a.cardIds.length !== need) return fail(`카드 ${need}장을 골라 버려 주세요.`);
|
||||||
|
return why(distinctInHand(p, a.cardIds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- precomputed options for the view
|
||||||
|
|
||||||
|
export function playOptions(s: BangState, me: PlayerId): PlayOption[] {
|
||||||
|
if (!myPlayTime(s, me)) return [];
|
||||||
|
const p = P(s, me);
|
||||||
|
const out: PlayOption[] = [];
|
||||||
|
const others = living(s).filter((x) => x.id !== me);
|
||||||
|
for (const cardId of p.hand) {
|
||||||
|
const card = cardById(cardId);
|
||||||
|
const as: 'bang' | undefined = is(p, 'calamity') && card.kind === 'missed' ? 'bang' : undefined;
|
||||||
|
const kind = as ?? card.kind;
|
||||||
|
const base: PlayAction = { type: 'play', cardId, ...(as ? { as } : {}) };
|
||||||
|
if (!needsTarget(kind)) {
|
||||||
|
const reason = checkPlay(s, me, base);
|
||||||
|
out.push({ cardId, ...(as ? { as } : {}), ok: !reason, ...(reason ? { reason } : {}) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const cands = [...others];
|
||||||
|
if ((kind === 'panic' || kind === 'catBalou') && s.options.selfTargetCatBalou && p.inPlay.length > 0) cands.push(p);
|
||||||
|
const targets: TargetOption[] = cands.map((t) => {
|
||||||
|
if (kind === 'panic' || kind === 'catBalou') {
|
||||||
|
const choices = ['hand', ...t.inPlay];
|
||||||
|
const reasons = choices.map((tc) => checkPlay(s, me, { ...base, target: t.id, targetCard: tc }));
|
||||||
|
const cards = choices.filter((_, i) => !reasons[i]);
|
||||||
|
return cards.length ? { id: t.id, ok: true, cards } : { id: t.id, ok: false, reason: reasons[0] ?? reasons.find(Boolean)! };
|
||||||
|
}
|
||||||
|
const reason = checkPlay(s, me, { ...base, target: t.id });
|
||||||
|
return reason ? { id: t.id, ok: false, reason } : { id: t.id, ok: true };
|
||||||
|
});
|
||||||
|
const any = targets.some((t) => t.ok);
|
||||||
|
const reason = any ? undefined : (targets[0]?.reason ?? '쓸 수 있는 대상이 없어요.');
|
||||||
|
out.push({ cardId, ...(as ? { as } : {}), ok: any, ...(reason ? { reason } : {}), targets });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promptFor(s: BangState, me: PlayerId): Prompt | null {
|
||||||
|
if (s.winner) return null;
|
||||||
|
const p = P(s, me);
|
||||||
|
if (!p.alive) return null;
|
||||||
|
const r = reactionOf(s, me);
|
||||||
|
if (r) {
|
||||||
|
switch (r.kind) {
|
||||||
|
case 'bang':
|
||||||
|
return {
|
||||||
|
kind: 'bang',
|
||||||
|
source: r.source,
|
||||||
|
via: r.via,
|
||||||
|
needed: r.needed,
|
||||||
|
options: p.hand.filter((id) => missedLike(p, id)),
|
||||||
|
canBarrel: !s.options.autoBarrel && barrelLeft(s, r),
|
||||||
|
};
|
||||||
|
case 'indians':
|
||||||
|
return { kind: 'indians', source: r.source, options: p.hand.filter((id) => bangLike(p, id)) };
|
||||||
|
case 'duel':
|
||||||
|
return { kind: 'duel', opponent: r.other, source: r.source, options: p.hand.filter((id) => bangLike(p, id)) };
|
||||||
|
case 'dying':
|
||||||
|
return { kind: 'dying', needed: 1 - p.life, beers: p.hand.filter((id) => kindOf(id) === 'beer'), beerAllowed: beerWorks(s), killer: r.killer };
|
||||||
|
case 'store':
|
||||||
|
return { kind: 'store', revealed: r.revealed.map(cardById) };
|
||||||
|
case 'kitKeep':
|
||||||
|
return { kind: 'kitKeep', options: r.options.map(cardById) };
|
||||||
|
case 'choose2':
|
||||||
|
return { kind: 'choose2', options: p.characterOffer ?? [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (s.reactions.length || s.turn.player !== me) return null;
|
||||||
|
if (s.turn.phase === 'draw') {
|
||||||
|
const c = drawChoice(s, p);
|
||||||
|
const top = s.discard[s.discard.length - 1];
|
||||||
|
return { kind: 'draw', jesseTargets: c?.jesse ?? [], pedro: c?.pedro && top ? (({ id: _id, ...f }) => f)(cardById(top)) : null };
|
||||||
|
}
|
||||||
|
if (s.turn.phase === 'discard') return { kind: 'discard', count: Math.max(0, p.hand.length - p.life) };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
255
packages/games/src/bang/types.ts
Normal file
255
packages/games/src/bang/types.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
/** 뱅! options, actions, state and view types (docs/games/bang.md §2, §7, §8). */
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { PlayerId } from '@bg/engine';
|
||||||
|
import { CHARACTER_IDS, type Card, type CardFace, type CardKind, type CharacterId, type Role } from './cards';
|
||||||
|
|
||||||
|
const lit = <T extends number>(...vals: T[]) =>
|
||||||
|
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
|
||||||
|
|
||||||
|
export const bangOptionsSchema = z.object({
|
||||||
|
characterDraft: z
|
||||||
|
.enum(['random', 'choose2'])
|
||||||
|
.default('random')
|
||||||
|
.meta({ title: '캐릭터 정하기', labels: { random: '무작위 1장', choose2: '2장 중 1장 고르기' } }),
|
||||||
|
turnSeconds: lit(30, 60, 90, 120)
|
||||||
|
.default(60)
|
||||||
|
.meta({ title: '내 턴 제한 시간', labels: { 30: '30초', 60: '60초', 90: '90초', 120: '120초' } }),
|
||||||
|
reactionSeconds: lit(8, 15, 25)
|
||||||
|
.default(15)
|
||||||
|
.meta({ title: '반응 제한 시간(빗나감!·맥주·결투·잡화점)', labels: { 8: '8초', 15: '15초', 25: '25초' } }),
|
||||||
|
autoDefend: z.boolean().default(true).meta({ title: '반응 시간이 지나면 막을 카드를 자동으로 내기' }),
|
||||||
|
autoBarrel: z.boolean().default(true).meta({ title: '술통·주르도네 판정을 묻지 않고 자동으로 하기' }),
|
||||||
|
showDistanceHelper: z.boolean().default(true).meta({ title: '다른 사람까지 거리와 사거리 표시' }),
|
||||||
|
beginnerHints: z.boolean().default(true).meta({ title: '카드를 쓸 수 있는 이유·없는 이유 알려 주기' }),
|
||||||
|
drawBeforeDeath: z.boolean().default(false).meta({ title: '(하우스 룰) 바트 캐시디·엘 그링고가 빈사 판정 전에 카드 받기' }),
|
||||||
|
beerAtFullHealth: z.boolean().default(false).meta({ title: '(하우스 룰) 효과가 없어도 맥주 내기(버리기 용도)' }),
|
||||||
|
selfTargetCatBalou: z.boolean().default(false).meta({ title: '(하우스 룰) 캣 벌루·패닉!을 내 장착 카드에 쓰기' }),
|
||||||
|
deputyCount7: z.boolean().default(false).meta({ title: '(하우스 룰) 7명일 때 부관 1 + 무법자 4' }),
|
||||||
|
});
|
||||||
|
export type BangOptions = z.infer<typeof bangOptionsSchema>;
|
||||||
|
|
||||||
|
const id = z.string().max(40);
|
||||||
|
export const bangActionSchema = z.discriminatedUnion('type', [
|
||||||
|
z.object({ type: z.literal('pickCharacter'), character: z.enum(CHARACTER_IDS as [CharacterId, ...CharacterId[]]) }),
|
||||||
|
z.object({ type: z.literal('draw'), jesseFrom: id.optional(), pedroDiscard: z.boolean().optional() }),
|
||||||
|
z.object({ type: z.literal('kitKeep'), keep: z.tuple([id, id]) }),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('play'),
|
||||||
|
cardId: id,
|
||||||
|
as: z.enum(['bang', 'missed']).optional(),
|
||||||
|
target: id.optional(),
|
||||||
|
targetCard: id.optional(),
|
||||||
|
}),
|
||||||
|
z.object({ type: z.literal('respond'), cardIds: z.array(id).min(1).max(4) }),
|
||||||
|
z.object({ type: z.literal('useBarrel') }),
|
||||||
|
z.object({ type: z.literal('takeHit') }),
|
||||||
|
z.object({ type: z.literal('giveUp') }),
|
||||||
|
z.object({ type: z.literal('storePick'), cardId: id }),
|
||||||
|
z.object({ type: z.literal('sidHeal'), cardIds: z.tuple([id, id]) }),
|
||||||
|
z.object({ type: z.literal('endPlay') }),
|
||||||
|
z.object({ type: z.literal('discard'), cardIds: z.array(id).min(1).max(40) }),
|
||||||
|
]);
|
||||||
|
/** Client actions plus the system-only ones (timer, leaving) that are not in the schema. */
|
||||||
|
export type BangAction = z.infer<typeof bangActionSchema> | { type: 'timeout' } | { type: 'leave' };
|
||||||
|
export type PlayAction = Extract<BangAction, { type: 'play' }>;
|
||||||
|
|
||||||
|
export interface PlayerState {
|
||||||
|
id: PlayerId;
|
||||||
|
seat: number;
|
||||||
|
role: Role;
|
||||||
|
character: CharacterId;
|
||||||
|
/** choose2: the two offers until one is picked (secret to everyone else). */
|
||||||
|
characterOffer: CharacterId[] | null;
|
||||||
|
maxLife: number;
|
||||||
|
life: number;
|
||||||
|
alive: boolean;
|
||||||
|
hand: string[];
|
||||||
|
/** Equipment in front of the player (weapons included). */
|
||||||
|
inPlay: string[];
|
||||||
|
timeoutsInRow: number;
|
||||||
|
away: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Reaction =
|
||||||
|
| { kind: 'bang'; target: PlayerId; source: PlayerId; via: 'bang' | 'gatling'; needed: number; barrelTried: boolean; jourTried: boolean; deadline: number }
|
||||||
|
| { kind: 'indians'; target: PlayerId; source: PlayerId; deadline: number }
|
||||||
|
| { kind: 'duel'; actor: PlayerId; other: PlayerId; source: PlayerId; deadline: number }
|
||||||
|
| { kind: 'store'; picker: PlayerId; order: PlayerId[]; revealed: string[]; source: PlayerId; deadline: number }
|
||||||
|
/** `lost` / `source`: the life loss that caused it (Bart / El Gringo trigger after surviving, §5.5). */
|
||||||
|
| { kind: 'dying'; player: PlayerId; killer: PlayerId | null; lost: number; source: PlayerId | null; deadline: number }
|
||||||
|
| { kind: 'kitKeep'; player: PlayerId; options: string[]; deadline: number }
|
||||||
|
| { kind: 'choose2'; player: PlayerId; deadline: number };
|
||||||
|
export type ReactionKind = Reaction['kind'];
|
||||||
|
|
||||||
|
export type TurnPhase = 'draft' | 'start' | 'draw' | 'play' | 'discard' | 'ended';
|
||||||
|
|
||||||
|
export interface TurnState {
|
||||||
|
player: PlayerId;
|
||||||
|
phase: TurnPhase;
|
||||||
|
bangsPlayed: number;
|
||||||
|
deadline: number;
|
||||||
|
/** Turn timer is paused while reactions run during the play phase. */
|
||||||
|
pausedAt: number | null;
|
||||||
|
dynamiteChecked: boolean;
|
||||||
|
jailChecked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CheckPurpose = 'dynamite' | 'jail' | 'barrel' | 'jourdonnais';
|
||||||
|
|
||||||
|
/** Public log line (structured; the screen turns it into Korean with nicknames). `card` with `vis` is private to those players. */
|
||||||
|
export type LogEntry = { seq: number } & (
|
||||||
|
| { t: 'turn'; p: PlayerId; round: number }
|
||||||
|
| { t: 'play'; p: PlayerId; card: CardFace; as?: CardKind; target?: PlayerId }
|
||||||
|
| { t: 'equip'; p: PlayerId; card: CardFace; target?: PlayerId; replaced?: CardFace }
|
||||||
|
| { t: 'check'; p: PlayerId; purpose: CheckPurpose; cards: CardFace[]; success: boolean }
|
||||||
|
| { t: 'dynamite'; p: PlayerId; exploded: boolean; to?: PlayerId }
|
||||||
|
| { t: 'jail'; p: PlayerId; escaped: boolean }
|
||||||
|
| { t: 'missed'; p: PlayerId; cards: CardFace[] }
|
||||||
|
| { t: 'respond'; p: PlayerId; cards: CardFace[]; why: 'indians' | 'duel' }
|
||||||
|
| { t: 'barrelSave'; p: PlayerId }
|
||||||
|
| { t: 'hit'; p: PlayerId; n: number; by: PlayerId | null; life: number }
|
||||||
|
| { t: 'heal'; p: PlayerId; n: number; why: 'beer' | 'saloon' | 'sid'; cards?: CardFace[] }
|
||||||
|
| { t: 'draw'; p: PlayerId; n: number; why: 'phase' | 'card' | 'bart' | 'reward' | 'suzy' | 'kit' }
|
||||||
|
| { t: 'blackJack'; p: PlayerId; card: CardFace; extra: boolean }
|
||||||
|
| { t: 'pedro'; p: PlayerId; card: CardFace }
|
||||||
|
| { t: 'take'; p: PlayerId; from: PlayerId; how: 'panic' | 'gringo' | 'jesse'; card?: CardFace; vis?: PlayerId[]; equip?: CardFace }
|
||||||
|
| { t: 'discarded'; p: PlayerId; by: PlayerId; card: CardFace; fromHand: boolean }
|
||||||
|
| { t: 'limit'; p: PlayerId; cards: CardFace[] }
|
||||||
|
| { t: 'store'; p: PlayerId; card: CardFace }
|
||||||
|
| { t: 'storeOpen'; p: PlayerId; cards: CardFace[] }
|
||||||
|
| { t: 'dead'; p: PlayerId; role: Role; by: PlayerId | null; left?: boolean }
|
||||||
|
| { t: 'reward'; p: PlayerId; n: number }
|
||||||
|
| { t: 'penalty'; p: PlayerId }
|
||||||
|
| { t: 'vulture'; p: PlayerId; from: PlayerId; n: number }
|
||||||
|
| { t: 'character'; p: PlayerId; character: CharacterId }
|
||||||
|
| { t: 'timeout'; p: PlayerId }
|
||||||
|
| { t: 'reshuffle'; n: number }
|
||||||
|
| { t: 'end'; winner: Team }
|
||||||
|
);
|
||||||
|
export type LogKind = LogEntry['t'];
|
||||||
|
|
||||||
|
export type Team = 'law' | 'outlaws' | 'renegade';
|
||||||
|
|
||||||
|
export interface BangFinal {
|
||||||
|
winnerTeam: Team;
|
||||||
|
players: { id: PlayerId; role: Role; character: CharacterId; alive: boolean; won: boolean; deathOrder: number | null }[];
|
||||||
|
/** Winners (alive first) → losers (alive first, then the latest death first). */
|
||||||
|
ranking: PlayerId[];
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BangState {
|
||||||
|
options: BangOptions;
|
||||||
|
players: PlayerState[];
|
||||||
|
/** Top = last element. Secret. */
|
||||||
|
deck: string[];
|
||||||
|
/** Top = last element. Public. */
|
||||||
|
discard: string[];
|
||||||
|
turn: TurnState;
|
||||||
|
reactions: Reaction[];
|
||||||
|
lastCheck: { player: PlayerId; purpose: CheckPurpose; cards: CardFace[]; success: boolean; seq: number } | null;
|
||||||
|
winner: Team | null;
|
||||||
|
deathOrder: PlayerId[];
|
||||||
|
round: number;
|
||||||
|
/** Applied actions. */
|
||||||
|
seq: number;
|
||||||
|
log: LogEntry[];
|
||||||
|
final: BangFinal | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- view
|
||||||
|
|
||||||
|
export interface TargetOption {
|
||||||
|
id: PlayerId;
|
||||||
|
ok: boolean;
|
||||||
|
reason?: string;
|
||||||
|
/** Panic! / Cat Balou: "hand" and/or equipment card ids that may be chosen. */
|
||||||
|
cards?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a hand card can do now (my turn, play phase). */
|
||||||
|
export interface PlayOption {
|
||||||
|
cardId: string;
|
||||||
|
as?: 'bang';
|
||||||
|
ok: boolean;
|
||||||
|
reason?: string;
|
||||||
|
/** Targeted cards list every other living player (ok or not, with the reason). */
|
||||||
|
targets?: TargetOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Prompt =
|
||||||
|
| { kind: 'bang'; source: PlayerId; via: 'bang' | 'gatling'; needed: number; options: string[]; canBarrel: boolean }
|
||||||
|
| { kind: 'indians'; source: PlayerId; options: string[] }
|
||||||
|
| { kind: 'duel'; opponent: PlayerId; source: PlayerId; options: string[] }
|
||||||
|
| { kind: 'dying'; needed: number; beers: string[]; beerAllowed: boolean; killer: PlayerId | null }
|
||||||
|
| { kind: 'store'; revealed: Card[] }
|
||||||
|
| { kind: 'kitKeep'; options: Card[] }
|
||||||
|
| { kind: 'choose2'; options: CharacterId[] }
|
||||||
|
| { kind: 'draw'; jesseTargets: PlayerId[]; pedro: CardFace | null }
|
||||||
|
| { kind: 'discard'; count: number };
|
||||||
|
|
||||||
|
export interface PlayerView {
|
||||||
|
id: PlayerId;
|
||||||
|
seat: number;
|
||||||
|
/** null while a choose2 pick is still hidden. */
|
||||||
|
character: CharacterId | null;
|
||||||
|
/** Sheriff, dead players, myself, or everyone after the game. */
|
||||||
|
role: Role | null;
|
||||||
|
maxLife: number;
|
||||||
|
life: number;
|
||||||
|
alive: boolean;
|
||||||
|
handCount: number;
|
||||||
|
inPlay: Card[];
|
||||||
|
isTurn: boolean;
|
||||||
|
/** Waiting for this player's reaction. */
|
||||||
|
reacting: ReactionKind | null;
|
||||||
|
away: boolean;
|
||||||
|
/** From me (null for me, dead players or spectators). */
|
||||||
|
distance: number | null;
|
||||||
|
/** My 뱅! reaches this player. */
|
||||||
|
inRange: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReactionView {
|
||||||
|
kind: ReactionKind;
|
||||||
|
who: PlayerId;
|
||||||
|
source?: PlayerId;
|
||||||
|
via?: 'bang' | 'gatling';
|
||||||
|
needed?: number;
|
||||||
|
revealed?: Card[];
|
||||||
|
deadline: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BangView {
|
||||||
|
options: BangOptions;
|
||||||
|
me: PlayerId | null;
|
||||||
|
players: PlayerView[];
|
||||||
|
current: PlayerId;
|
||||||
|
phase: TurnPhase;
|
||||||
|
round: number;
|
||||||
|
bangsPlayed: number;
|
||||||
|
deckCount: number;
|
||||||
|
discardCount: number;
|
||||||
|
/** Last few discards, top last. */
|
||||||
|
discardTop: Card[];
|
||||||
|
reactions: ReactionView[];
|
||||||
|
lastCheck: BangState['lastCheck'];
|
||||||
|
my: {
|
||||||
|
role: Role;
|
||||||
|
character: CharacterId | null;
|
||||||
|
hand: Card[];
|
||||||
|
range: number;
|
||||||
|
/** Unlimited 뱅! (볼캐닉 / 윌리 더 키드). */
|
||||||
|
unlimitedBang: boolean;
|
||||||
|
plays: PlayOption[];
|
||||||
|
canEndPlay: boolean;
|
||||||
|
canSidHeal: boolean;
|
||||||
|
prompt: Prompt | null;
|
||||||
|
} | null;
|
||||||
|
turnDeadline: number;
|
||||||
|
deadlineTotalMs: number;
|
||||||
|
reactionTotalMs: number;
|
||||||
|
log: LogEntry[];
|
||||||
|
winner: Team | null;
|
||||||
|
result: BangFinal | null;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { rummikub } from './rummikub';
|
|||||||
import { davinci } from './davinci-code';
|
import { davinci } from './davinci-code';
|
||||||
import { mahjong } from './mahjong';
|
import { mahjong } from './mahjong';
|
||||||
import { gostop } from './gostop';
|
import { gostop } from './gostop';
|
||||||
|
import { bang } from './bang';
|
||||||
|
|
||||||
export const GAMES: Record<string, AnyGameDefinition> = {
|
export const GAMES: Record<string, AnyGameDefinition> = {
|
||||||
[omok.id]: omok,
|
[omok.id]: omok,
|
||||||
@@ -29,4 +30,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
|
|||||||
[davinci.id]: davinci,
|
[davinci.id]: davinci,
|
||||||
[mahjong.id]: mahjong,
|
[mahjong.id]: mahjong,
|
||||||
[gostop.id]: gostop,
|
[gostop.id]: gostop,
|
||||||
|
[bang.id]: bang,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const CATALOG: CatalogEntry[] = [
|
|||||||
{ id: 'one-card', nameKo: '원카드', minPlayers: 2, maxPlayers: 10, minutes: '10~15', level: '쉬움', blurb: '같은 무늬·숫자를 내고 공격 카드로 먹이기! · 색깔 카드 모드', available: true },
|
{ id: 'one-card', nameKo: '원카드', minPlayers: 2, maxPlayers: 10, minutes: '10~15', level: '쉬움', blurb: '같은 무늬·숫자를 내고 공격 카드로 먹이기! · 색깔 카드 모드', available: true },
|
||||||
{ id: 'rummikub', nameKo: '숫자 타일', minPlayers: 2, maxPlayers: 4, minutes: '30~60', level: '보통', blurb: '같은 숫자, 이어지는 숫자로 묶어 먼저 다 내려놓기!', available: true },
|
{ id: 'rummikub', nameKo: '숫자 타일', minPlayers: 2, maxPlayers: 4, minutes: '30~60', level: '보통', blurb: '같은 숫자, 이어지는 숫자로 묶어 먼저 다 내려놓기!', available: true },
|
||||||
{ id: 'gostop', nameKo: '고스톱', minPlayers: 2, maxPlayers: 5, minutes: '20~40', level: '보통', blurb: '같은 월을 맞춰 먹고 고·스톱! 2인 맞고·3인·광팔기 · 놀이용 칩', available: true },
|
{ id: 'gostop', nameKo: '고스톱', minPlayers: 2, maxPlayers: 5, minutes: '20~40', level: '보통', blurb: '같은 월을 맞춰 먹고 고·스톱! 2인 맞고·3인·광팔기 · 놀이용 칩', available: true },
|
||||||
|
{ id: 'bang', nameKo: '뱅!', minPlayers: 4, maxPlayers: 7, minutes: '20~40', level: '어려움', blurb: '보안관과 무법자, 숨은 정체를 추리하며 쏘고 피하기!', available: true },
|
||||||
{ id: 'davinci-code', nameKo: '다빈치 코드', minPlayers: 2, maxPlayers: 4, minutes: '10~15', level: '쉬움', blurb: '검은·흰 숫자 타일의 숫자를 추리해 맞히기', available: true },
|
{ id: 'davinci-code', nameKo: '다빈치 코드', minPlayers: 2, maxPlayers: 4, minutes: '10~15', level: '쉬움', blurb: '검은·흰 숫자 타일의 숫자를 추리해 맞히기', available: true },
|
||||||
{ id: 'mahjong', nameKo: '마작', minPlayers: 3, maxPlayers: 4, minutes: '25~50', level: '어려움', blurb: '리치 마작 4인 · 3인 산마 · 초보 모드로 대기패 힌트', available: true },
|
{ id: 'mahjong', nameKo: '마작', minPlayers: 3, maxPlayers: 4, minutes: '25~50', level: '어려움', blurb: '리치 마작 4인 · 3인 산마 · 초보 모드로 대기패 힌트', available: true },
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user