doubletwelve 0.1.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai/candidate-generator.d.ts +29 -0
- package/dist/ai/create-ai-player.d.ts +14 -0
- package/dist/ai/heuristics.d.ts +13 -0
- package/dist/ai/index.d.ts +12 -0
- package/dist/ai/policy.d.ts +65 -0
- package/dist/ai/search.d.ts +43 -0
- package/dist/ai/skill-profiles.d.ts +16 -0
- package/dist/ai/test-fixtures.d.ts +9 -0
- package/dist/ai/types.d.ts +84 -0
- package/dist/app/DefaultPip.d.ts +9 -0
- package/dist/app/DominoHub.d.ts +8 -0
- package/dist/app/DominoThemeContext.d.ts +8 -0
- package/dist/app/DoubleTwelve.d.ts +3 -0
- package/dist/app/Pip.d.ts +4 -11
- package/dist/app/Viewport.d.ts +27 -0
- package/dist/app/dominoTheme.d.ts +39 -0
- package/dist/app/trainBends.d.ts +77 -0
- package/dist/app/trainLayout.d.ts +42 -3
- package/dist/app/viewportMath.d.ts +28 -0
- package/dist/game/TrainData.d.ts +23 -0
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +17 -3
- package/dist/index.js +1414 -697
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { TrainData } from '../game/TrainData';
|
|
2
|
+
import { AiAction, AiObservation, AiPlayAction, CandidateGenerator } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Indices (into `obs.trains`) the player may legally build on under standard
|
|
5
|
+
* Mexican Train access: your own train, plus any train flagged public. Games
|
|
6
|
+
* with richer access rules (Warp12's Distress Beacon, locked fractures, …)
|
|
7
|
+
* supply their own {@link CandidateGenerator}.
|
|
8
|
+
*/
|
|
9
|
+
export declare function getAccessibleTrainIndices(obs: AiObservation): number[];
|
|
10
|
+
/** Union of every played tile's key across all trains (uniqueness is global). */
|
|
11
|
+
export declare function collectAllPlayedKeys(trains: readonly TrainData[]): Set<string>;
|
|
12
|
+
/** Every legal placement of a hand tile onto an accessible train. */
|
|
13
|
+
export declare function generatePlayActions(obs: AiObservation): AiPlayAction[];
|
|
14
|
+
export interface CandidateGeneratorOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Offer `draw` even when legal plays exist. Off by default (canonical "must
|
|
17
|
+
* play if you can"). Turn on for variants where drawing is always optional —
|
|
18
|
+
* combined with a high blunder rate this is what makes a beginner draw when
|
|
19
|
+
* they didn't have to.
|
|
20
|
+
*/
|
|
21
|
+
allowOptionalDraw?: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Builds the standard candidate set: all legal plays, plus `draw` when the pile
|
|
25
|
+
* isn't empty (and either there are no plays, or optional drawing is enabled),
|
|
26
|
+
* falling back to `pass` only when nothing else is possible.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createCandidateGenerator(options?: CandidateGeneratorOptions): CandidateGenerator<AiAction>;
|
|
29
|
+
export declare const defaultCandidateGenerator: CandidateGenerator<AiAction>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { AiAction, AiActionBase, AiPlayer, CreateAiPlayerOptions } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Builds an offline, heuristic-driven domino player over the standard double-N
|
|
4
|
+
* model. The decision flow per turn:
|
|
5
|
+
*
|
|
6
|
+
* observation → candidate generator → weighted heuristics → policy → action
|
|
7
|
+
*
|
|
8
|
+
* Every stage is injectable: swap the generator to change rules access, append
|
|
9
|
+
* heuristics (including ones that read custom `kind`s or `obs.meta`) to teach it
|
|
10
|
+
* variant-specific tactics, and pick/clone a {@link SkillProfile} to set strength.
|
|
11
|
+
* Pass a seeded {@link Rng} for fully reproducible games. Under the hood this is
|
|
12
|
+
* a thin adapter over the model-agnostic {@link createPolicyPlayer}.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createAiPlayer<TAction extends AiActionBase = AiAction>(options: CreateAiPlayerOptions<TAction>): AiPlayer<TAction>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Heuristic } from './types';
|
|
2
|
+
/** Stable ids so skill profiles and overrides can reference heuristics by name. */
|
|
3
|
+
export declare const HEURISTIC_IDS: {
|
|
4
|
+
readonly preferPlay: "prefer-play";
|
|
5
|
+
readonly dumpPips: "dump-pips";
|
|
6
|
+
readonly doublesEarly: "play-doubles-early";
|
|
7
|
+
readonly ownTrain: "own-train";
|
|
8
|
+
readonly obligationRelief: "obligation-relief";
|
|
9
|
+
readonly handFlexibility: "hand-flexibility";
|
|
10
|
+
readonly defensivePublic: "defensive-public";
|
|
11
|
+
};
|
|
12
|
+
/** The stock, game-agnostic heuristic set. Append/replace by `id` to customize. */
|
|
13
|
+
export declare const DEFAULT_HEURISTICS: Heuristic[];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type { Rng, AiActionBase, AiPlayAction, AiDrawAction, AiPassAction, AiAction, AiObservation, EvalContext, Heuristic, SkillProfile, CandidateGenerator, AiPlayer, CreateAiPlayerOptions, } from './types';
|
|
2
|
+
export { isPlayAction } from './types';
|
|
3
|
+
export type { CandidateGeneratorOptions } from './candidate-generator';
|
|
4
|
+
export { getAccessibleTrainIndices, collectAllPlayedKeys, generatePlayActions, createCandidateGenerator, defaultCandidateGenerator, } from './candidate-generator';
|
|
5
|
+
export { HEURISTIC_IDS, DEFAULT_HEURISTICS } from './heuristics';
|
|
6
|
+
export type { SkillLevel } from './skill-profiles';
|
|
7
|
+
export { SKILL_PRESETS, getSkillProfile } from './skill-profiles';
|
|
8
|
+
export { createAiPlayer } from './create-ai-player';
|
|
9
|
+
export type { GenericHeuristic, PolicyPlayer, PolicyPlayerConfig, } from './policy';
|
|
10
|
+
export { scoreWithHeuristics, argmaxIndex, softmaxIndex, chooseActionIndex, createPolicyPlayer, } from './policy';
|
|
11
|
+
export type { PlayerRef, SearchModel, SearchOptions, ScoredAction, } from './search';
|
|
12
|
+
export { searchActionValues } from './search';
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-agnostic decision core shared by every game built on this library.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here knows about dominoes, trains, or any specific rule set: it only
|
|
5
|
+
* knows how to turn a set of candidate actions into one chosen action, given a
|
|
6
|
+
* skill profile and a way to score actions. The domino-specific player
|
|
7
|
+
* (`createAiPlayer`) and downstream variants (e.g. Warp12) are thin adapters
|
|
8
|
+
* over {@link createPolicyPlayer}.
|
|
9
|
+
*/
|
|
10
|
+
/** Pseudo-random source in [0, 1). Inject a seeded one for deterministic play. */
|
|
11
|
+
export type Rng = () => number;
|
|
12
|
+
/**
|
|
13
|
+
* The dials that define "skill". The same engine spans beginner→advanced purely
|
|
14
|
+
* by changing which heuristics are active, their weights, and how sharply (or
|
|
15
|
+
* randomly) the policy commits to the highest-scoring action.
|
|
16
|
+
*/
|
|
17
|
+
export interface SkillProfile {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
/** Softmax temperature over candidate scores. 0 = argmax; higher = noisier. */
|
|
20
|
+
readonly temperature: number;
|
|
21
|
+
/** Probability of ignoring the policy and picking a uniformly random action. */
|
|
22
|
+
readonly blunderRate: number;
|
|
23
|
+
/** Plies of simulation (0 = greedy). Reserved; greedy-only in this release. */
|
|
24
|
+
readonly lookaheadDepth: number;
|
|
25
|
+
readonly weights: Readonly<Record<string, number>>;
|
|
26
|
+
readonly enabled: ReadonlySet<string>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A single, pure rule-of-thumb over actions of type `TAction`, given a turn
|
|
30
|
+
* context of type `TCtx`. Higher score = more attractive; return 0 when the
|
|
31
|
+
* heuristic doesn't apply so it stays weight-neutral.
|
|
32
|
+
*/
|
|
33
|
+
export interface GenericHeuristic<TAction, TCtx> {
|
|
34
|
+
readonly id: string;
|
|
35
|
+
score(action: TAction, ctx: TCtx): number;
|
|
36
|
+
}
|
|
37
|
+
/** Weighted sum of the enabled heuristics for one action. */
|
|
38
|
+
export declare function scoreWithHeuristics<TAction, TCtx>(action: TAction, ctx: TCtx, byId: ReadonlyMap<string, GenericHeuristic<TAction, TCtx>>, skill: SkillProfile): number;
|
|
39
|
+
/** Index of the max score, breaking ties uniformly at random. */
|
|
40
|
+
export declare function argmaxIndex(scores: readonly number[], rng: Rng): number;
|
|
41
|
+
/** Sample an index proportional to exp(score / temperature). */
|
|
42
|
+
export declare function softmaxIndex(scores: readonly number[], temperature: number, rng: Rng): number;
|
|
43
|
+
/** Temperature-controlled choice: argmax at 0, softmax sampling above it. */
|
|
44
|
+
export declare function chooseActionIndex(scores: readonly number[], skill: SkillProfile, rng: Rng): number;
|
|
45
|
+
export interface PolicyPlayerConfig<TObs, TAction, TCtx> {
|
|
46
|
+
skill: SkillProfile;
|
|
47
|
+
heuristics: ReadonlyArray<GenericHeuristic<TAction, TCtx>>;
|
|
48
|
+
generateCandidates: (obs: TObs) => TAction[];
|
|
49
|
+
buildContext: (obs: TObs, candidates: readonly TAction[]) => TCtx;
|
|
50
|
+
/** Returned when the generator yields no candidates at all. */
|
|
51
|
+
fallback: (obs: TObs) => TAction;
|
|
52
|
+
rng?: Rng;
|
|
53
|
+
}
|
|
54
|
+
export interface PolicyPlayer<TObs, TAction> {
|
|
55
|
+
decide(obs: TObs): TAction;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The reusable decision engine. Per turn:
|
|
59
|
+
*
|
|
60
|
+
* observation → candidates → (blunder?) → weighted heuristics → policy → action
|
|
61
|
+
*
|
|
62
|
+
* Context is built once per decision and shared across heuristics. A single
|
|
63
|
+
* candidate short-circuits scoring; an empty set returns `fallback`.
|
|
64
|
+
*/
|
|
65
|
+
export declare function createPolicyPlayer<TObs, TAction, TCtx>(config: PolicyPlayerConfig<TObs, TAction, TCtx>): PolicyPlayer<TObs, TAction>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Rng } from './policy';
|
|
2
|
+
export type PlayerRef = number | string;
|
|
3
|
+
/**
|
|
4
|
+
* The forward model the search drives. Implement these over your engine:
|
|
5
|
+
* `applyAction` is the transition function, `evaluate` is the leaf heuristic
|
|
6
|
+
* (higher = better for `perspective`), and `determinize` samples the hidden
|
|
7
|
+
* state so the search isn't allowed to peek at information a player shouldn't
|
|
8
|
+
* have. `orderActions` is an optional breadth control (good move ordering lets
|
|
9
|
+
* `maxBranch` prune to the promising moves).
|
|
10
|
+
*/
|
|
11
|
+
export interface SearchModel<TState, TAction> {
|
|
12
|
+
legalActions(state: TState): TAction[];
|
|
13
|
+
applyAction(state: TState, action: TAction): TState;
|
|
14
|
+
isTerminal(state: TState): boolean;
|
|
15
|
+
currentPlayer(state: TState): PlayerRef;
|
|
16
|
+
/** Position value from `perspective`'s point of view (higher is better). */
|
|
17
|
+
evaluate(state: TState, perspective: PlayerRef): number;
|
|
18
|
+
/** Sample a concrete world consistent with `perspective`'s knowledge. */
|
|
19
|
+
determinize?(state: TState, perspective: PlayerRef, rng: Rng): TState;
|
|
20
|
+
/** Reorder actions best-first; the search expands only the first `maxBranch`. */
|
|
21
|
+
orderActions?(state: TState, actions: TAction[]): TAction[];
|
|
22
|
+
}
|
|
23
|
+
export interface SearchOptions {
|
|
24
|
+
/** Plies to look ahead, including the root action itself (>= 1). */
|
|
25
|
+
depth: number;
|
|
26
|
+
perspective: PlayerRef;
|
|
27
|
+
rng?: Rng;
|
|
28
|
+
/** Worlds to sample for imperfect-information averaging (default 1). */
|
|
29
|
+
determinizations?: number;
|
|
30
|
+
/** Cap candidates expanded per node (default unlimited). */
|
|
31
|
+
maxBranch?: number;
|
|
32
|
+
}
|
|
33
|
+
export interface ScoredAction<TAction> {
|
|
34
|
+
readonly action: TAction;
|
|
35
|
+
readonly value: number;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Value every root action by simulating it forward. For each action we average
|
|
39
|
+
* its minimax value across `determinizations` sampled worlds. Returns one entry
|
|
40
|
+
* per (ordered, breadth-capped) root action; the caller turns these values into
|
|
41
|
+
* a choice (e.g. skill-scaled softmax via {@link chooseActionIndex}).
|
|
42
|
+
*/
|
|
43
|
+
export declare function searchActionValues<TState, TAction>(rootState: TState, model: SearchModel<TState, TAction>, options: SearchOptions): ScoredAction<TAction>[];
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { SkillProfile } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Stock skill tiers. Each is just a configuration of the same engine:
|
|
4
|
+
*
|
|
5
|
+
* - **beginner** — only cares about playing and lightly about dumping pips, with
|
|
6
|
+
* high temperature and a real blunder rate: erratic, often suboptimal plays.
|
|
7
|
+
* - **intermediate** — adds doubles-early and own-train sense, low noise.
|
|
8
|
+
* - **advanced** — full heuristic suite (obligations, flexibility, defense),
|
|
9
|
+
* near-deterministic, no blunders.
|
|
10
|
+
*
|
|
11
|
+
* Clone and tweak (`{ ...SKILL_PRESETS.advanced, temperature: 0.3 }`) for any
|
|
12
|
+
* point on the spectrum.
|
|
13
|
+
*/
|
|
14
|
+
export declare const SKILL_PRESETS: Record<'beginner' | 'intermediate' | 'advanced', SkillProfile>;
|
|
15
|
+
export type SkillLevel = keyof typeof SKILL_PRESETS;
|
|
16
|
+
export declare function getSkillProfile(level: SkillLevel): SkillProfile;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { DominoValue } from '../game/DominoValue';
|
|
2
|
+
import { TrainData } from '../game/TrainData';
|
|
3
|
+
import { RulesConfig } from '../rules/rulesConfig';
|
|
4
|
+
import { AiObservation } from './types';
|
|
5
|
+
/** Deterministic mulberry32 RNG for reproducible AI tests. */
|
|
6
|
+
export declare function mulberry32(seed: number): () => number;
|
|
7
|
+
export declare const DEFAULT_TEST_RULES: RulesConfig;
|
|
8
|
+
export declare function train(playerId: number, dominoes: DominoValue[], isPublic?: boolean): TrainData;
|
|
9
|
+
export declare function makeObs(overrides?: Partial<AiObservation>): AiObservation;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { DominoValue } from '../game/DominoValue';
|
|
2
|
+
import { TrainData } from '../game/TrainData';
|
|
3
|
+
import { RulesConfig } from '../rules/rulesConfig';
|
|
4
|
+
import { Move } from '../rules/placement';
|
|
5
|
+
import { GenericHeuristic, Rng, SkillProfile } from './policy';
|
|
6
|
+
export type { Rng, SkillProfile } from './policy';
|
|
7
|
+
/**
|
|
8
|
+
* Base shape every action shares. Games extend the action space by declaring
|
|
9
|
+
* their own `kind` literals (e.g. Warp12's `'deploy-beacon'`) and unioning them
|
|
10
|
+
* with {@link AiAction}; the scoring pipeline treats unknown kinds opaquely.
|
|
11
|
+
*/
|
|
12
|
+
export interface AiActionBase {
|
|
13
|
+
readonly kind: string;
|
|
14
|
+
}
|
|
15
|
+
/** Attach `tile` at `move.end` of the train at `trainIndex` in the observation. */
|
|
16
|
+
export interface AiPlayAction extends AiActionBase {
|
|
17
|
+
readonly kind: 'play';
|
|
18
|
+
readonly trainIndex: number;
|
|
19
|
+
readonly move: Move;
|
|
20
|
+
}
|
|
21
|
+
export interface AiDrawAction extends AiActionBase {
|
|
22
|
+
readonly kind: 'draw';
|
|
23
|
+
}
|
|
24
|
+
export interface AiPassAction extends AiActionBase {
|
|
25
|
+
readonly kind: 'pass';
|
|
26
|
+
}
|
|
27
|
+
/** The base action space shared by all double-N variants. */
|
|
28
|
+
export type AiAction = AiPlayAction | AiDrawAction | AiPassAction;
|
|
29
|
+
/** Narrows any action to a play action (kind discriminant on the base is widened). */
|
|
30
|
+
export declare function isPlayAction(action: AiActionBase): action is AiPlayAction;
|
|
31
|
+
/**
|
|
32
|
+
* Everything the bot is allowed to see this turn. Game-specific extras (beacon
|
|
33
|
+
* flags, fracture state, scores, turn order…) ride along in {@link meta} so
|
|
34
|
+
* custom heuristics can read them without changing this interface.
|
|
35
|
+
*/
|
|
36
|
+
export interface AiObservation {
|
|
37
|
+
readonly selfPlayerId: number;
|
|
38
|
+
readonly hand: readonly DominoValue[];
|
|
39
|
+
readonly rules: RulesConfig;
|
|
40
|
+
readonly trains: readonly TrainData[];
|
|
41
|
+
readonly engineValue: number;
|
|
42
|
+
/** Tiles left to draw; omit for "unlimited/unknown". 0 forbids drawing. */
|
|
43
|
+
readonly drawPileSize?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Set by the host once this player has already taken their single draw this
|
|
46
|
+
* turn. Standard Mexican Train allows exactly one draw when you can't play;
|
|
47
|
+
* if the drawn tile still can't be played you must pass (which marks your
|
|
48
|
+
* train public). When true the generator stops offering `draw`, so the bot
|
|
49
|
+
* falls through to `pass` instead of draining the pile.
|
|
50
|
+
*/
|
|
51
|
+
readonly turnDrawUsed?: boolean;
|
|
52
|
+
readonly meta?: Readonly<Record<string, unknown>>;
|
|
53
|
+
}
|
|
54
|
+
/** Shared, pre-computed turn data handed to every heuristic (built once per decision). */
|
|
55
|
+
export interface EvalContext {
|
|
56
|
+
readonly obs: AiObservation;
|
|
57
|
+
/** Canonical keys of every tile already on the table (global uniqueness). */
|
|
58
|
+
readonly playedKeys: ReadonlySet<string>;
|
|
59
|
+
readonly candidates: readonly AiActionBase[];
|
|
60
|
+
readonly playCandidates: readonly AiPlayAction[];
|
|
61
|
+
/** Tiles neither played nor in hand — the basis for tile-counting heuristics. */
|
|
62
|
+
readonly unseen: readonly DominoValue[];
|
|
63
|
+
readonly rng: Rng;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A single, pure rule-of-thumb over the domino action space. Higher score =
|
|
67
|
+
* more attractive; return 0 when it doesn't apply so it stays weight-neutral.
|
|
68
|
+
* This is the domino specialization of the generic {@link GenericHeuristic}.
|
|
69
|
+
*/
|
|
70
|
+
export type Heuristic = GenericHeuristic<AiActionBase, EvalContext>;
|
|
71
|
+
/** Produces the legal/considered actions for a turn. Override to change rules access. */
|
|
72
|
+
export type CandidateGenerator<TAction extends AiActionBase = AiAction> = (obs: AiObservation) => TAction[];
|
|
73
|
+
export interface AiPlayer<TAction extends AiActionBase = AiAction> {
|
|
74
|
+
decide(obs: AiObservation): TAction;
|
|
75
|
+
}
|
|
76
|
+
export interface CreateAiPlayerOptions<TAction extends AiActionBase = AiAction> {
|
|
77
|
+
skill: SkillProfile;
|
|
78
|
+
/** Defaults to {@link DEFAULT_HEURISTICS}. Append your own to extend behavior. */
|
|
79
|
+
heuristics?: Heuristic[];
|
|
80
|
+
/** Defaults to {@link defaultCandidateGenerator}. */
|
|
81
|
+
generateCandidates?: CandidateGenerator<TAction>;
|
|
82
|
+
/** Defaults to `Math.random`. Inject a seeded RNG for reproducible games/tests. */
|
|
83
|
+
rng?: Rng;
|
|
84
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { FC } from 'react';
|
|
2
|
+
import { PipRenderContext, DominoTheme } from './dominoTheme';
|
|
3
|
+
export interface DefaultPipProps {
|
|
4
|
+
ctx: PipRenderContext;
|
|
5
|
+
theme?: DominoTheme;
|
|
6
|
+
}
|
|
7
|
+
/** Stock domino pip — solid or hollow circle using the resolved pip color. */
|
|
8
|
+
export declare const DefaultPip: FC<DefaultPipProps>;
|
|
9
|
+
export default DefaultPip;
|
package/dist/app/DominoHub.d.ts
CHANGED
|
@@ -13,5 +13,13 @@ interface DominoHubProps {
|
|
|
13
13
|
tableHeight: number;
|
|
14
14
|
pipColors?: PipColorMap;
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Distance from the hub center at which a train should start so its first tile
|
|
18
|
+
* clears its neighbours. Trains fan out 360/slots° apart, so the neighbour gap
|
|
19
|
+
* at distance d is ~2πd/slots; it must exceed a tile's footprint. Offset trains
|
|
20
|
+
* zigzag wider (a perpendicular half-tile seed) and need a bigger ring; linear
|
|
21
|
+
* trains are skinny and stay near the hub. Never smaller than `radius + 20`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function hubTrainStartDistance(slots: number, radius: number, dominoWidth: number, layoutStyle: 'offset' | 'linear'): number;
|
|
16
24
|
export declare const DominoHub: FC<DominoHubProps>;
|
|
17
25
|
export default DominoHub;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { FC, ReactNode } from 'react';
|
|
2
|
+
import { DominoTheme } from './dominoTheme';
|
|
3
|
+
export interface DominoThemeProviderProps {
|
|
4
|
+
theme?: DominoTheme;
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
export declare const DominoThemeProvider: FC<DominoThemeProviderProps>;
|
|
8
|
+
export declare function useDominoTheme(override?: DominoTheme): DominoTheme;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FC } from 'react';
|
|
2
|
+
import { DominoTheme } from './dominoTheme';
|
|
2
3
|
import { PipColorMap } from './pipColors';
|
|
3
4
|
export interface DoubleTwelveProps {
|
|
4
5
|
/** Pip count on the top half (0–12). Defaults to 0 (blank). */
|
|
@@ -14,6 +15,8 @@ export interface DoubleTwelveProps {
|
|
|
14
15
|
pipColors?: PipColorMap;
|
|
15
16
|
borderColor?: string;
|
|
16
17
|
rotation?: number;
|
|
18
|
+
/** Presentation overrides — also available via DominoThemeProvider. */
|
|
19
|
+
theme?: DominoTheme;
|
|
17
20
|
}
|
|
18
21
|
export declare const DoubleTwelve: FC<DoubleTwelveProps>;
|
|
19
22
|
export default DoubleTwelve;
|
package/dist/app/Pip.d.ts
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
import { FC } from 'react';
|
|
2
|
-
import {
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
gridSize: PipGridSize;
|
|
7
|
-
color: string;
|
|
8
|
-
hollow?: boolean;
|
|
9
|
-
top?: string;
|
|
10
|
-
left?: string;
|
|
11
|
-
}
|
|
12
|
-
export declare const Pip: FC<PipProps>;
|
|
2
|
+
import { PipRenderContext } from './dominoTheme';
|
|
3
|
+
export type PipProps = PipRenderContext;
|
|
4
|
+
/** @deprecated Prefer theme.renderPip or DefaultPip via DominoTheme. */
|
|
5
|
+
export declare const Pip: FC<PipRenderContext>;
|
|
13
6
|
export default Pip;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { FC, ReactNode } from 'react';
|
|
2
|
+
export interface ViewportProps {
|
|
3
|
+
/** Visible viewport size in pixels. */
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
/** Content (world) size, used by the fit/reset control. */
|
|
7
|
+
contentWidth: number;
|
|
8
|
+
contentHeight: number;
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
minScale?: number;
|
|
11
|
+
maxScale?: number;
|
|
12
|
+
/** Multiplier applied per wheel notch / zoom-button press. */
|
|
13
|
+
zoomStep?: number;
|
|
14
|
+
padding?: number;
|
|
15
|
+
background?: string;
|
|
16
|
+
/** Show the built-in zoom/reset control overlay. Default true. */
|
|
17
|
+
showControls?: boolean;
|
|
18
|
+
testId?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A pan/zoom canvas for content larger than the screen. Drag to slide, wheel or
|
|
22
|
+
* the on-screen buttons to zoom (zoom centers on the cursor for the wheel).
|
|
23
|
+
* Clicks pass through to children unless the pointer actually dragged, so
|
|
24
|
+
* interactive content (e.g. click-to-bend tiles) keeps working.
|
|
25
|
+
*/
|
|
26
|
+
export declare const Viewport: FC<ViewportProps>;
|
|
27
|
+
export default Viewport;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { CSSProperties, ReactNode } from 'react';
|
|
2
|
+
import { PipGridSize } from './pipGrid';
|
|
3
|
+
export interface PipRenderContext {
|
|
4
|
+
value: number;
|
|
5
|
+
row: number;
|
|
6
|
+
col: number;
|
|
7
|
+
gridSize: PipGridSize;
|
|
8
|
+
color: string;
|
|
9
|
+
hollow?: boolean;
|
|
10
|
+
top?: string;
|
|
11
|
+
left?: string;
|
|
12
|
+
positionStyle: CSSProperties;
|
|
13
|
+
}
|
|
14
|
+
export interface TileRenderContext {
|
|
15
|
+
value1: number;
|
|
16
|
+
value2: number;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
backgroundColor: string;
|
|
20
|
+
borderColor: string;
|
|
21
|
+
rotation: number;
|
|
22
|
+
}
|
|
23
|
+
/** Optional presentation hooks for domino tiles and pips. */
|
|
24
|
+
export interface DominoTheme {
|
|
25
|
+
/** Root class on each tile — use for app-specific CSS modules. */
|
|
26
|
+
tileClassName?: string;
|
|
27
|
+
/** Extra data attributes for CSS selectors, e.g. holographic toggles. */
|
|
28
|
+
tileDataAttributes?: Record<string, string | boolean | number | undefined>;
|
|
29
|
+
tileStyle?: (ctx: TileRenderContext) => CSSProperties;
|
|
30
|
+
halfDividerStyle?: (ctx: TileRenderContext) => CSSProperties;
|
|
31
|
+
/** Merged onto each pip after layout positioning. */
|
|
32
|
+
pipStyle?: (ctx: PipRenderContext) => CSSProperties;
|
|
33
|
+
/** Replace the default pip element entirely. */
|
|
34
|
+
renderPip?: (ctx: PipRenderContext) => ReactNode;
|
|
35
|
+
}
|
|
36
|
+
export declare const DEFAULT_DOMINO_THEME: DominoTheme;
|
|
37
|
+
export declare function mergeDominoTheme(base: DominoTheme, patch?: DominoTheme): DominoTheme;
|
|
38
|
+
/** Convert theme tileDataAttributes to React data-* props. */
|
|
39
|
+
export declare function themeDataAttributes(attrs?: DominoTheme['tileDataAttributes']): Record<string, string>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { TrainBend, TrainBranch } from '../game/TrainData';
|
|
2
|
+
import { TrainLayoutEntry, TrainLayoutStyle } from './trainLayout';
|
|
3
|
+
export type TurnSide = 'left' | 'right';
|
|
4
|
+
/** Default pivot magnitude. The interactive UI only produces square corners. */
|
|
5
|
+
export declare const TURN_DEGREES = 90;
|
|
6
|
+
/**
|
|
7
|
+
* Signed turn (degrees) for a side. Headings use the screen convention
|
|
8
|
+
* (0° = +x, +90° = +y / downward), so a `+90` turn rotates +x toward +y, which
|
|
9
|
+
* reads as a clockwise/"right" turn on screen.
|
|
10
|
+
*/
|
|
11
|
+
export declare function sideToTurn(side: TurnSide, degrees?: number): number;
|
|
12
|
+
export declare function oppositeSide(side: TurnSide): TurnSide;
|
|
13
|
+
/**
|
|
14
|
+
* Default turn side in offset mode: fold toward the empty side — the one
|
|
15
|
+
* opposite the lane the zigzag biases into (`outwardSign`). A `+90` turn heads
|
|
16
|
+
* toward the heading's `+perp`; outwardSign is measured on that same perp axis,
|
|
17
|
+
* so the empty side is `-outwardSign`, i.e. side = outwardSign >= 0 ? 'left' : 'right'.
|
|
18
|
+
*/
|
|
19
|
+
export declare function offsetDefaultSide(angle: number, outwardSign?: number): TurnSide;
|
|
20
|
+
export interface TableBounds {
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Default turn side in linear mode: fold toward whichever perpendicular side has
|
|
26
|
+
* more open table from the bend point. Distance is measured from `point` along
|
|
27
|
+
* each perpendicular until it exits the table rectangle; the roomier side wins.
|
|
28
|
+
* Ties (e.g. dead-center) fall back to 'right'.
|
|
29
|
+
*/
|
|
30
|
+
export declare function linearDefaultSide(point: {
|
|
31
|
+
x: number;
|
|
32
|
+
y: number;
|
|
33
|
+
}, angle: number, bounds: TableBounds): TurnSide;
|
|
34
|
+
export interface BuildTrainTilesInput {
|
|
35
|
+
startX: number;
|
|
36
|
+
startY: number;
|
|
37
|
+
angle: number;
|
|
38
|
+
layoutStyle: TrainLayoutStyle;
|
|
39
|
+
}
|
|
40
|
+
/** Flattens a branch (with feet and bends) to its world-space tiles. */
|
|
41
|
+
export declare function buildBranchTiles(branch: TrainBranch, input: BuildTrainTilesInput): TrainLayoutEntry[];
|
|
42
|
+
/** Replaces (or removes) the bend at `index`, returning a new bends array. */
|
|
43
|
+
export declare function withBendAt(bends: readonly TrainBend[] | undefined, index: number, turn: number | null): TrainBend[];
|
|
44
|
+
export interface ResolveBendResult {
|
|
45
|
+
/** The legal turn to apply, or null when no side is collision-free. */
|
|
46
|
+
turn: number | null;
|
|
47
|
+
/** Why null: 'blocked' = both sides collide; never set on success. */
|
|
48
|
+
reason?: 'blocked';
|
|
49
|
+
}
|
|
50
|
+
export interface ResolveBendInput {
|
|
51
|
+
branch: TrainBranch;
|
|
52
|
+
index: number;
|
|
53
|
+
build: BuildTrainTilesInput;
|
|
54
|
+
/** Tiles belonging to every OTHER path; a bend may not intersect these. */
|
|
55
|
+
obstacles: readonly TrainLayoutEntry[];
|
|
56
|
+
/** Preferred side to try first (from the mode's heuristic). */
|
|
57
|
+
preferredSide: TurnSide;
|
|
58
|
+
/** Turn magnitude in degrees (default 90). */
|
|
59
|
+
degrees?: number;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Picks a collision-free turn for a new bend at `index`. Tries the preferred
|
|
63
|
+
* side first, then the opposite; a candidate is rejected if the resulting path
|
|
64
|
+
* crosses itself or any obstacle path. Returns `{ turn: null, reason: 'blocked' }`
|
|
65
|
+
* when neither side is legal, so the caller can refuse the bend.
|
|
66
|
+
*/
|
|
67
|
+
export declare function resolveBend({ branch, index, build, obstacles, preferredSide, degrees, }: ResolveBendInput): ResolveBendResult;
|
|
68
|
+
/**
|
|
69
|
+
* Cycles a tile's bend on repeated clicks: none → preferred legal side →
|
|
70
|
+
* opposite legal side → none. Skips sides that collide. Returns the next bends
|
|
71
|
+
* array, or the unchanged input when no legal bend exists.
|
|
72
|
+
*/
|
|
73
|
+
export declare function cycleBendAt(branch: TrainBranch, index: number, build: BuildTrainTilesInput, obstacles: readonly TrainLayoutEntry[], preferredSide: TurnSide, degrees?: number): {
|
|
74
|
+
bends: TrainBend[];
|
|
75
|
+
changed: boolean;
|
|
76
|
+
blocked: boolean;
|
|
77
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DominoValue } from '../game/DominoValue';
|
|
2
|
-
import { TrainBranch } from '../game/TrainData';
|
|
2
|
+
import { TrainBend, TrainBranch } from '../game/TrainData';
|
|
3
3
|
export declare const DOMINO_WIDTH = 60;
|
|
4
4
|
export declare const DOMINO_HEIGHT = 120;
|
|
5
5
|
/**
|
|
@@ -45,6 +45,12 @@ export interface ComputeTrainLayoutInput {
|
|
|
45
45
|
* toes sit at equal, close distances on either side.
|
|
46
46
|
*/
|
|
47
47
|
hubIndex?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Pivots that fold this run's path into Ls, Us, or snakes. When present, the
|
|
50
|
+
* run is split into straight sub-runs at each bend index and chained corner to
|
|
51
|
+
* corner. Hub-centering is skipped (corners relax centering by design).
|
|
52
|
+
*/
|
|
53
|
+
bends?: readonly TrainBend[];
|
|
48
54
|
}
|
|
49
55
|
export declare function halfExtentAlongTrain(isDouble: boolean, dominoWidth?: number, dominoHeight?: number): number;
|
|
50
56
|
export declare function stepAlongTrain(fromIsDouble: boolean, toIsDouble: boolean, dominoWidth?: number, dominoHeight?: number): number;
|
|
@@ -56,10 +62,31 @@ export declare function trainPerpendicular(angle: number): {
|
|
|
56
62
|
perpX: number;
|
|
57
63
|
perpY: number;
|
|
58
64
|
};
|
|
59
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Orients a value chain for rendering so each tile's connecting value (`value1`,
|
|
67
|
+
* the near end) faces the previous tile. A tile is flipped only when it is
|
|
68
|
+
* stored reversed (its `value2`, not `value1`, is the one that matches the
|
|
69
|
+
* previous tile's open end). A correctly-stored chain is left untouched, and
|
|
70
|
+
* doubles are never flipped. This is identical for linear and offset layouts —
|
|
71
|
+
* the connection rule doesn't depend on spacing.
|
|
72
|
+
*/
|
|
73
|
+
export declare function orientDominoValues(dominoes: DominoValue[]): DominoValue[];
|
|
60
74
|
export declare function outwardPerpSign(angle: number): number;
|
|
61
75
|
export declare function nextPerpOffset(current: number, outwardSign: number): number;
|
|
62
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Normalizes a run's bends: integer indices strictly inside the run, one per
|
|
78
|
+
* index (last wins), sorted. Index 0 is dropped — a run can't bend before its
|
|
79
|
+
* first tile. Returns the cleaned, sorted list.
|
|
80
|
+
*/
|
|
81
|
+
export declare function normalizeBends(bends: readonly TrainBend[] | undefined, tileCount: number): TrainBend[];
|
|
82
|
+
/**
|
|
83
|
+
* Local heading (degrees) of the tile at `index` in a (possibly bent) run: the
|
|
84
|
+
* base `angle` plus every bend turn at or before that index. With no bends this
|
|
85
|
+
* is just `angle`. Used to anchor chicken-foot toes off a double's *actual*
|
|
86
|
+
* heading when the double sits in a turned section of the path.
|
|
87
|
+
*/
|
|
88
|
+
export declare function headingAtIndex(angle: number, bends: readonly TrainBend[] | undefined, index: number, tileCount?: number): number;
|
|
89
|
+
export declare function computeTrainLayout({ startX, startY, angle, dominoes, layoutStyle, dominoWidth, dominoHeight, leadGap, outwardSign: outwardSignInput, hubIndex, bends, }: ComputeTrainLayoutInput): TrainLayoutEntry[];
|
|
63
90
|
/** The four world-space corners of a tile (its rotated rectangle). */
|
|
64
91
|
export declare function tileCorners(entry: TrainLayoutEntry, dominoWidth?: number, dominoHeight?: number): Array<{
|
|
65
92
|
x: number;
|
|
@@ -72,6 +99,18 @@ export declare function tileCorners(entry: TrainLayoutEntry, dominoWidth?: numbe
|
|
|
72
99
|
* double — pass cleanly while real collisions are caught.
|
|
73
100
|
*/
|
|
74
101
|
export declare function tilesOverlap(a: TrainLayoutEntry, b: TrainLayoutEntry, epsilon?: number, dominoWidth?: number, dominoHeight?: number): boolean;
|
|
102
|
+
/**
|
|
103
|
+
* True when any tile of `layout` overlaps any tile in `obstacles` — i.e. this
|
|
104
|
+
* path would physically intersect another path. Used to forbid a bend that
|
|
105
|
+
* would cross another train.
|
|
106
|
+
*/
|
|
107
|
+
export declare function layoutsCollide(layout: readonly TrainLayoutEntry[], obstacles: readonly TrainLayoutEntry[], epsilon?: number, dominoWidth?: number, dominoHeight?: number): boolean;
|
|
108
|
+
/**
|
|
109
|
+
* True when a path crosses itself — any two of its own tiles overlap. Adjacent
|
|
110
|
+
* tiles that merely touch are fine (tilesOverlap ignores contact), so this only
|
|
111
|
+
* fires when a fold (e.g. a too-tight U-turn) makes the path collide with itself.
|
|
112
|
+
*/
|
|
113
|
+
export declare function layoutSelfIntersects(layout: readonly TrainLayoutEntry[], epsilon?: number, dominoWidth?: number, dominoHeight?: number): boolean;
|
|
75
114
|
/**
|
|
76
115
|
* A single straight run of dominoes within a chicken-foot tree: the main line
|
|
77
116
|
* or one toe. `depth` is 0 for the main line, 1 for its toes, and so on.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Pan/zoom transform: content is scaled by `scale` then translated by (x, y). */
|
|
2
|
+
export interface ViewportTransform {
|
|
3
|
+
scale: number;
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
}
|
|
7
|
+
export interface Size {
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
}
|
|
11
|
+
export interface Point {
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function clampScale(scale: number, min: number, max: number): number;
|
|
16
|
+
/**
|
|
17
|
+
* Zooms by `factor` about a fixed screen `pivot` so the content point under the
|
|
18
|
+
* pivot stays put. Scale is clamped to [min, max]; the translation is adjusted
|
|
19
|
+
* by the *effective* factor after clamping so panning can't drift at the limits.
|
|
20
|
+
*/
|
|
21
|
+
export declare function zoomAt(view: ViewportTransform, factor: number, pivot: Point, min: number, max: number): ViewportTransform;
|
|
22
|
+
/**
|
|
23
|
+
* Centers `content` within `viewport` at the largest scale that fits inside the
|
|
24
|
+
* given padding (clamped to [min, max]). Use this for a "fit / reset" control.
|
|
25
|
+
*/
|
|
26
|
+
export declare function fitToBounds(content: Size, viewport: Size, padding: number, min: number, max: number): ViewportTransform;
|
|
27
|
+
/** Converts a screen point inside the viewport to content coordinates. */
|
|
28
|
+
export declare function screenToContent(view: ViewportTransform, screen: Point): Point;
|
package/dist/game/TrainData.d.ts
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
import { DominoValue } from './DominoValue';
|
|
2
|
+
/**
|
|
3
|
+
* A pivot in a run's path. At `index` (a tile position within `dominoes`) the run
|
|
4
|
+
* stops going straight and turns by `turn` degrees, so a player can fold a train
|
|
5
|
+
* into Ls, Us, and meandering snakes to avoid other trains. Bends are layout
|
|
6
|
+
* hints only — they never change which tiles connect (the value chain is
|
|
7
|
+
* untouched), so like-values keep touching across every corner.
|
|
8
|
+
*
|
|
9
|
+
* `turn` is signed degrees relative to the current heading, in the same
|
|
10
|
+
* convention as a branch's `angle` (0° = +x, 90° = +y / downward on screen).
|
|
11
|
+
* The interactive default is ±90°; the sign is chosen by a heuristic when the
|
|
12
|
+
* player first bends, then persisted here so the path is stable across renders.
|
|
13
|
+
*/
|
|
14
|
+
export interface TrainBend {
|
|
15
|
+
/** Index in `dominoes` of the first tile that continues in the new heading. */
|
|
16
|
+
index: number;
|
|
17
|
+
/** Signed turn in degrees applied to the heading at this point. */
|
|
18
|
+
turn: number;
|
|
19
|
+
}
|
|
2
20
|
/**
|
|
3
21
|
* A run of dominoes that can sprout chicken-foot side toes at its doubles.
|
|
4
22
|
*
|
|
@@ -15,6 +33,11 @@ export interface TrainBranch {
|
|
|
15
33
|
* entries: index 0 → -45°, index 1 → +45°).
|
|
16
34
|
*/
|
|
17
35
|
feet?: Record<number, TrainBranch[]>;
|
|
36
|
+
/**
|
|
37
|
+
* Optional pivots that fold this run's path. Order-independent (the engine
|
|
38
|
+
* sorts by index); at most one bend per index. Absent/empty = a straight run.
|
|
39
|
+
*/
|
|
40
|
+
bends?: TrainBend[];
|
|
18
41
|
}
|
|
19
42
|
export interface TrainData extends TrainBranch {
|
|
20
43
|
playerId: number;
|