aptx-match-engine
v1.0.7
Published
A tournament engine for competitive esports brackets (Swiss, Round Robin, Single/Double Elimination, GSL)
Maintainers
Readme
aptx-match-engine
Pure Stage Tournament Engine - 专注于单赛制阶段状态管理的竞技赛事引擎。
v2.0 Breaking Change: 从 v1 的多阶段 TournamentState 架构迁移到 Pure Stage 架构。引擎现在只负责单个赛制的状态管理,多阶段关系由业务层管理。
安装
npm install aptx-match-engine
# or
pnpm add aptx-match-engine核心概念
Pure Stage Engine 架构
┌─────────────────────────────────────────────────────────┐
│ 业务层 (Backend) │
│ - 多阶段管理 (Tournament → Stages) │
│ - 阶段间晋级关系 │
│ - duration、赛程时间等纯业务数据 │
│ - Slot ↔ Team 映射 (通过 seedOrder) │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Pure Stage Engine (本包) │
│ - 单赛制阶段结构管理 │
│ - 比赛结果录入 (games[].winnerSlot) │
│ - 派生状态计算 (比分、晋级、排名) │
│ - 输出 effects 供业务层处理 │
└─────────────────────────────────────────────────────────┘数据分离原则
| 数据类型 | 存储位置 | 说明 |
|---------|---------|------|
| games[].winnerSlot | Engine State | 引擎管理的原始比赛结果 |
| duration | SQL (业务层) | 纯业务字段,引擎无关 |
| scheduledAt | SQL (业务层) | 纯业务字段,引擎无关 |
| 多阶段晋级关系 | SQL (业务层) | 业务层管理阶段间关联 |
支持的赛制
| 赛制 | 类型标识 | 说明 |
|-----|---------|------|
| 循环赛 | ROUND_ROBIN | 小组内循环对战,支持多分组 |
| 单败淘汰 | SINGLE_ELIM | 经典淘汰赛,输一场即出局 |
| 双败淘汰 | DOUBLE_ELIM | 胜者组/败者组双轨制 |
| GSL 小组赛 | GSL_GROUP | 双败小组赛,每组4队 |
| 瑞士轮 | SWISS | 动态配对积分制 |
快速开始
1. 创建阶段结构
import { createStage, type StageConfig } from 'aptx-match-engine';
// 定义赛制配置
const config: StageConfig = {
type: 'SINGLE_ELIM',
elimination: {
seedOrder: [1, 2, 3, 4, 5, 6, 7, 8], // 8队单败淘汰
},
format: 'BO3', // 默认 BO3
};
// 创建阶段(仅结构,无比赛结果)
const stage = createStage('SINGLE_ELIM', config);
console.log(stage.series); // 查看生成的系列赛结构
console.log(stage.slots); // [1, 2, 3, 4, 5, 6, 7, 8]2. 录入比赛结果
import { recordGameResult, getAllSeriesViews } from 'aptx-match-engine';
// 记录系列赛的第一局比赛,胜者为 slotId=1 的队伍
const { stage: newStage, effects } = recordGameResult(stage, {
seriesId: 'series-001',
gameNumber: 1,
winnerSlot: 1,
});
console.log(effects); // [{ type: 'game_recorded', ... }]
// 获取派生状态(解析后的比分、晋级等)
const views = getAllSeriesViews(newStage);
console.log(views[0].homeScore); // 1
console.log(views[0].isCompleted); // false (BO3 需要 2 胜)3. 录入弃权
import { recordWalkover } from 'aptx-match-engine';
// 记录 slotId=2 的队伍弃权
const { stage: newStage, effects } = recordWalkover(stage, {
seriesId: 'series-001',
forfeitSlot: 2,
});
console.log(effects); // [{ type: 'series_completed', winnerSlot: 1 }, ...]4. 计算排名
import { calculateStandings } from 'aptx-match-engine';
// 计算循环赛/瑞士轮排名
const standings = calculateStandings(stage, { win: 3, draw: 1, loss: 0 });
console.log(standings);
// [{
// groupId: undefined,
// entries: [
// { slotId: 1, wins: 3, losses: 0, draws: 0, points: 9, ... },
// { slotId: 2, wins: 2, losses: 1, draws: 0, points: 6, ... },
// ...
// ]
// }]5. 瑞士轮特殊处理
import { generateSwissRound } from 'aptx-match-engine';
// 当前轮次所有比赛完成后,生成下一轮对阵
const { updatedStage, isCompleted, effects } = generateSwissRound(stage);
if (isCompleted) {
console.log('瑞士轮全部结束');
} else {
console.log('新生成的对阵:', updatedStage.series.filter(s => s.round === currentRound + 1));
}API 文档
核心类型
// 基础类型
type SlotId = number;
type StageType = 'ROUND_ROBIN' | 'SINGLE_ELIM' | 'DOUBLE_ELIM' | 'GSL_GROUP' | 'SWISS';
type SeriesFormat = 'BO1' | 'BO2' | 'BO3' | 'BO5';
// Slot 引用
type SeedSlot = { type: 'seed'; slotId: SlotId };
type AdvanceSlot = {
type: 'advance';
sourceSeriesId: string;
position: 'winner' | 'loser';
};
type SlotRef = SeedSlot | AdvanceSlot;
// Stage 结构(引擎核心状态)
interface Stage {
id: string;
type: StageType;
format: SeriesFormat;
config: StageConfig;
slots: SlotId[];
series: Series[];
}
// 系列赛
interface Series {
id: string;
round: number;
sortOrder: number;
homeSlot: SlotRef;
awaySlot: SlotRef;
format: SeriesFormat;
games: { gameNumber: number; winnerSlot: SlotId | null }[];
byeSlot?: SlotId; // 轮空标记
}
// 录入输入
interface GameResultInput {
seriesId: string;
gameNumber: number;
winnerSlot: SlotId;
}
interface WalkoverInput {
seriesId: string;
forfeitSlot: SlotId;
}
// 录入结果
interface RecordResult {
stage: Stage; // 更新后的阶段状态
effects: Effect[]; // 触发的效果(供业务层处理)
}
type Effect =
| { type: 'game_recorded'; seriesId: string; gameNumber: number; winnerSlot: SlotId }
| { type: 'series_completed'; seriesId: string; winnerSlot: SlotId; loserSlot?: SlotId }
| { type: 'bye_advanced'; seriesId: string; advancedSlot: SlotId }
| { type: 'stage_completed' };
// 系列赛派生视图
interface SeriesView {
series: Series;
resolvedHome: SlotId | null; // 解析后的主队 slot
resolvedAway: SlotId | null; // 解析后的客队 slot
homeScore: number;
awayScore: number;
isCompleted: boolean;
winnerSlot: SlotId | null;
isDraw: boolean;
isReady: boolean;
}
// 排名
interface StandingEntry {
slotId: SlotId;
wins: number;
losses: number;
draws: number;
gamesWon: number;
gamesLost: number;
points: number;
}
interface StageStandings {
groupId?: string;
entries: StandingEntry[];
}主要函数
创建阶段
function createStage(
type: StageType,
config: StageConfig
): Stage创建新的阶段结构。config 根据 type 不同而变化:
循环赛配置:
{
type: 'ROUND_ROBIN',
roundRobin: {
groups: [
{ name: 'A组', slots: [1, 2, 3, 4] },
{ name: 'B组', slots: [5, 6, 7, 8] },
],
},
format: 'BO3',
}单败淘汰配置:
{
type: 'SINGLE_ELIM',
elimination: {
seedOrder: [1, 2, 3, 4, 5, 6, 7, 8],
roundFormats: { 1: 'BO1', 2: 'BO3', 3: 'BO5' }, // 各轮赛制覆盖
},
format: 'BO3', // 默认赛制
}双败淘汰配置:
{
type: 'DOUBLE_ELIM',
elimination: {
seedOrder: [1, 2, 3, 4, 5, 6, 7, 8],
winnersRoundFormats: { 1: 'BO1', 2: 'BO3', 3: 'BO5' },
losersRoundFormats: { 1: 'BO1', 2: 'BO3' },
grandFinalFormat: 'BO5',
},
format: 'BO3',
}GSL 小组赛配置:
{
type: 'GSL_GROUP',
groups: [
{ name: 'A组', slots: [1, 2, 3, 4] as [SlotId, SlotId, SlotId, SlotId] },
],
format: 'BO3',
}瑞士轮配置:
{
type: 'SWISS',
swiss: {
rounds: 5,
advanceThreshold: 3,
eliminateThreshold: 0, // 0 表示不自动淘汰
},
format: 'BO3',
}录入比赛结果
function recordGameResult(
stage: Stage,
input: GameResultInput
): RecordResult
function recordGameResults(
stage: Stage,
inputs: GameResultInput[]
): RecordResult
function recordWalkover(
stage: Stage,
input: WalkoverInput
): RecordResult查询派生状态
function getAllSeriesViews(stage: Stage): SeriesView[]
function getSeriesView(
stage: Stage,
seriesId: string,
resolvedSlots?: Map<string, { home: SlotId | null; away: SlotId | null }>
): SeriesView | null
function resolveSlot(
slotRef: SlotRef,
getSeriesResult: (seriesId: string) => { winnerSlot: SlotId | null } | null
): SlotId | null排名计算
function calculateStandings(
stage: Stage,
pointRules?: { win: number; draw: number; loss: number }
): StageStandings[]
function getStageRanking(stage: Stage): SlotId[]瑞士轮
function generateSwissRound(stage: Stage): {
updatedStage: Stage;
isCompleted: boolean;
effects: Effect[];
}
function isSwissCompleted(stage: Stage): boolean
function initSwissStage(
teamCount: number,
format: SeriesFormat,
rounds?: number
): Stage调整操作
function overrideGameResult(
stage: Stage,
input: GameResultInput
): RecordResult
function swapSeeds(
stage: Stage,
slotA: SlotId,
slotB: SlotId
): Stage
function cascadeReset(
stage: Stage,
seriesId: string
): { stage: Stage; resetIds: string[] }辅助函数
function getTotalGames(format: SeriesFormat): number
function generateSeriesLabel(stage: Stage, series: Series): string
function isValidSlot(stage: Stage, slotId: SlotId): boolean从 v1 迁移到 v2
主要变更
| v1 (旧) | v2 (新) | 说明 |
|--------|--------|------|
| TournamentState | Stage | 引擎只管理单阶段 |
| createTournament() | createStage() | 创建单阶段结构 |
| recordGameResult(state, seriesId, gameNum, winnerSlot) | recordGameResult(stage, { seriesId, gameNumber, winnerSlot }) | 参数改为对象 |
| state.stages[0].series | stage.series | 直接访问 |
| series.homeScore | getAllSeriesViews(stage)[0].homeScore | 派生状态需通过 view 获取 |
| series.duration | 移除 | 纯业务字段,引擎无关 |
| series.status | 移除 | 派生状态,通过 view.isCompleted 获取 |
迁移示例
v1 代码:
import { createTournament, recordGameResult } from 'aptx-match-engine';
const { state } = createTournament({ teamCount: 8, stages: [config] });
const { state: newState } = recordGameResult(state, seriesId, 1, 2);
console.log(newState.stages[0].series[0].homeScore);v2 代码:
import { createStage, recordGameResult, getAllSeriesViews } from 'aptx-match-engine';
const stage = createStage('SINGLE_ELIM', config);
const { stage: newStage } = recordGameResult(stage, { seriesId, gameNumber: 1, winnerSlot: 2 });
const views = getAllSeriesViews(newStage);
console.log(views[0].homeScore);本地开发
构建
cd packages/tournament-engine
pnpm install
pnpm build测试
pnpm test可视化调试
pnpm preview:dev访问 http://localhost:3333 查看交互式对阵图。
License
MIT
