@alpaca-software/40kdc-data 0.3.0 → 0.3.2
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/codegen-data.js +3 -1
- package/dist/codegen-data.js.map +1 -1
- package/dist/data/bundle.generated.js +1 -1
- package/dist/data/bundle.generated.js.map +1 -1
- package/dist/data/dataset.d.ts +17 -2
- package/dist/data/dataset.d.ts.map +1 -1
- package/dist/data/dataset.js +27 -2
- package/dist/data/dataset.js.map +1 -1
- package/dist/data/index.d.ts +5 -1
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/index.js +5 -1
- package/dist/data/index.js.map +1 -1
- package/dist/data/types.d.ts +6 -2
- package/dist/data/types.d.ts.map +1 -1
- package/dist/data/types.js +3 -1
- package/dist/data/types.js.map +1 -1
- package/dist/gen-conformance.js +163 -2
- package/dist/gen-conformance.js.map +1 -1
- package/dist/generated.d.ts +156 -36
- package/dist/generated.d.ts.map +1 -1
- package/dist/generated.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/migrate-terrain.d.ts +2 -0
- package/dist/migrate-terrain.d.ts.map +1 -0
- package/dist/migrate-terrain.js +297 -0
- package/dist/migrate-terrain.js.map +1 -0
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +27 -1
- package/dist/runner.js.map +1 -1
- package/dist/scoring/index.d.ts +135 -0
- package/dist/scoring/index.d.ts.map +1 -0
- package/dist/scoring/index.js +195 -0
- package/dist/scoring/index.js.map +1 -0
- package/dist/terrain/index.d.ts +11 -0
- package/dist/terrain/index.d.ts.map +1 -0
- package/dist/terrain/index.js +9 -0
- package/dist/terrain/index.js.map +1 -0
- package/dist/terrain/resolve.d.ts +122 -0
- package/dist/terrain/resolve.d.ts.map +1 -0
- package/dist/terrain/resolve.js +221 -0
- package/dist/terrain/resolve.js.map +1 -0
- package/dist/terrain/solve.d.ts +56 -0
- package/dist/terrain/solve.d.ts.map +1 -0
- package/dist/terrain/solve.js +80 -0
- package/dist/terrain/solve.js.map +1 -0
- package/dist/translate/index.d.ts +1 -1
- package/dist/translate/index.d.ts.map +1 -1
- package/dist/translate/index.js.map +1 -1
- package/dist/translate/scoring.d.ts +6 -0
- package/dist/translate/scoring.d.ts.map +1 -1
- package/dist/translate/scoring.js.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +1 -0
- package/dist/validate.js.map +1 -1
- package/package.json +2 -1
- package/schemas/$defs/common.schema.json +43 -0
- package/schemas/core/secondary-card.schema.json +10 -0
- package/schemas/core/terrain-layout.schema.json +42 -56
- package/schemas/core/terrain-template.schema.json +105 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Card-driven secondary-mission scoring, 10th-edition tactical model.
|
|
3
|
+
*
|
|
4
|
+
* Drawn secondaries are *held* in hand across rounds and **scored once**: the
|
|
5
|
+
* player asserts which of a card's awards they achieved, the engine computes the
|
|
6
|
+
* VP (clamped to the card's cap), records it against the current battle round,
|
|
7
|
+
* and the card is then discarded. There is no multi-turn per-card accrual — a
|
|
8
|
+
* card pays out exactly once.
|
|
9
|
+
*
|
|
10
|
+
* Why "asserted" rather than evaluated: there is no board-state model here, so
|
|
11
|
+
* an award's `when` condition is a human-readable label (see
|
|
12
|
+
* `translate/scoring.ts`'s `describeScoringCard`, which this module never
|
|
13
|
+
* modifies), not something the engine checks. The player ticks the awards they
|
|
14
|
+
* made; the engine does the arithmetic, the OR-tier resolution, the cumulative
|
|
15
|
+
* sums, and the cap.
|
|
16
|
+
*
|
|
17
|
+
* Deck-level rules the card schema deliberately omits live here as constants —
|
|
18
|
+
* chiefly the 5 VP-per-card ceiling of the Tactical approach. The Fixed approach
|
|
19
|
+
* instead uses each award's printed `vp_max`.
|
|
20
|
+
*
|
|
21
|
+
* `PlayerGame` is a plain JSON-serializable object so a UI can persist a whole
|
|
22
|
+
* match (two of them) to localStorage and rehydrate without a revival step.
|
|
23
|
+
*
|
|
24
|
+
* CONFORMANCE FOLLOW-UP: this engine is TypeScript-only for now. A Rust port
|
|
25
|
+
* plus a `conformance/scoring` corpus area (and a `SPEC_VERSION` bump) are a
|
|
26
|
+
* separate change; the public shapes below are the surface that port mirrors,
|
|
27
|
+
* so keep them stable.
|
|
28
|
+
*/
|
|
29
|
+
/** The Tactical approach caps a single secondary's score at this many VP. */
|
|
30
|
+
export const TACTICAL_CARD_CAP = 5;
|
|
31
|
+
/** Battle rounds in a game. */
|
|
32
|
+
export const ROUNDS = 5;
|
|
33
|
+
/** Per-player VP ceiling (WTC sheet: grand total out of 100). */
|
|
34
|
+
export const GAME_VP_CAP = 100;
|
|
35
|
+
/** A fresh player game for the given approach (defaults to tactical). */
|
|
36
|
+
export function emptyPlayerGame(approach = "tactical") {
|
|
37
|
+
return {
|
|
38
|
+
approach,
|
|
39
|
+
handIds: [],
|
|
40
|
+
rounds: Array.from({ length: ROUNDS }, () => ({ primary: 0, secondary: 0 })),
|
|
41
|
+
log: [],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Read a card's `awards`, typed (the generated `SecondaryCard` leaves them opaque). */
|
|
45
|
+
export function awardsOf(card) {
|
|
46
|
+
return (card.awards ?? []);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The awards a player scores under `approach`. An award with no `mode` is flat
|
|
50
|
+
* (it scores the same either way); an award tagged `fixed`/`tactical` scores
|
|
51
|
+
* only under the matching approach.
|
|
52
|
+
*/
|
|
53
|
+
export function awardsForApproach(card, approach) {
|
|
54
|
+
return awardsOf(card).filter((a) => a.mode == null || a.mode === approach);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* VP for a single asserted award. A flat `vp` ignores `count`; a `vp_per` award
|
|
58
|
+
* scores `vp_per × count`, with `count` clamped to `per_max` when present.
|
|
59
|
+
*/
|
|
60
|
+
export function scoreAward(award, count = 1) {
|
|
61
|
+
if (award.vp != null)
|
|
62
|
+
return award.vp;
|
|
63
|
+
if (award.vp_per != null) {
|
|
64
|
+
const capped = award.per_max != null ? Math.min(count, award.per_max) : count;
|
|
65
|
+
return award.vp_per * Math.max(0, capped);
|
|
66
|
+
}
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* VP from everything asserted in one scoring, before the card cap. Awards
|
|
71
|
+
* sharing an `exclusive_group` resolve as "only the highest scores" (the card's
|
|
72
|
+
* literal OR between tier rows); everything else, including `cumulative` "+"
|
|
73
|
+
* rows, sums.
|
|
74
|
+
*/
|
|
75
|
+
export function scoreTurn(asserted) {
|
|
76
|
+
const groupBest = new Map();
|
|
77
|
+
let total = 0;
|
|
78
|
+
for (const { award, count } of asserted) {
|
|
79
|
+
const v = scoreAward(award, count ?? 1);
|
|
80
|
+
if (award.exclusive_group != null) {
|
|
81
|
+
const prev = groupBest.get(award.exclusive_group) ?? 0;
|
|
82
|
+
if (v > prev)
|
|
83
|
+
groupBest.set(award.exclusive_group, v);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
total += v;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const v of groupBest.values())
|
|
90
|
+
total += v;
|
|
91
|
+
return total;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* A card's per-score VP ceiling under `approach`. Tactical is the universal
|
|
95
|
+
* {@link TACTICAL_CARD_CAP}. Fixed uses the largest `vp_max` printed on the
|
|
96
|
+
* card's scorable awards, or `Infinity` when none is printed (uncapped).
|
|
97
|
+
*/
|
|
98
|
+
export function scoreCap(card, approach) {
|
|
99
|
+
if (approach === "tactical")
|
|
100
|
+
return TACTICAL_CARD_CAP;
|
|
101
|
+
const caps = awardsForApproach(card, "fixed")
|
|
102
|
+
.map((a) => a.vp_max)
|
|
103
|
+
.filter((x) => x != null);
|
|
104
|
+
return caps.length > 0 ? Math.max(...caps) : Infinity;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The VP a single scoring of `card` grants under `approach`: the asserted awards'
|
|
108
|
+
* total, clamped to the card's cap. This is the amount banked when the card is
|
|
109
|
+
* scored (and then discarded).
|
|
110
|
+
*/
|
|
111
|
+
export function scoreSecondaryEvent(asserted, card, approach) {
|
|
112
|
+
return Math.min(scoreTurn(asserted), scoreCap(card, approach));
|
|
113
|
+
}
|
|
114
|
+
function roundIndex(round) {
|
|
115
|
+
return Math.max(0, Math.min(ROUNDS - 1, Math.trunc(round) - 1));
|
|
116
|
+
}
|
|
117
|
+
/** Add secondary VP to a battle round (1-based). Pure — returns new state. */
|
|
118
|
+
export function recordSecondary(pg, round, vp) {
|
|
119
|
+
const i = roundIndex(round);
|
|
120
|
+
const rounds = pg.rounds.map((c, idx) => idx === i ? { ...c, secondary: c.secondary + Math.max(0, vp) } : c);
|
|
121
|
+
return { ...pg, rounds };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Score a held secondary: add its VP to the round, append it to the log, and
|
|
125
|
+
* discard it from hand. Pure. The caller computes `vp` via
|
|
126
|
+
* {@link scoreSecondaryEvent}.
|
|
127
|
+
*/
|
|
128
|
+
export function scoreSecondary(pg, round, cardId, vp) {
|
|
129
|
+
const banked = Math.max(0, vp);
|
|
130
|
+
const recorded = recordSecondary(pg, round, banked);
|
|
131
|
+
return {
|
|
132
|
+
...removeFromHand(recorded, cardId),
|
|
133
|
+
log: [...pg.log, { cardId, round, vp: banked }],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Undo a logged scoring by index: subtract its VP from its round, drop the log
|
|
138
|
+
* entry, and return the card to hand so it can be re-scored. Pure; a no-op for
|
|
139
|
+
* an out-of-range index.
|
|
140
|
+
*/
|
|
141
|
+
export function removeScore(pg, index) {
|
|
142
|
+
const entry = pg.log[index];
|
|
143
|
+
if (!entry)
|
|
144
|
+
return pg;
|
|
145
|
+
const i = roundIndex(entry.round);
|
|
146
|
+
const rounds = pg.rounds.map((c, idx) => idx === i ? { ...c, secondary: Math.max(0, c.secondary - entry.vp) } : c);
|
|
147
|
+
const log = pg.log.filter((_, idx) => idx !== index);
|
|
148
|
+
const handIds = pg.handIds.includes(entry.cardId)
|
|
149
|
+
? pg.handIds
|
|
150
|
+
: [...pg.handIds, entry.cardId];
|
|
151
|
+
return { ...pg, rounds, log, handIds };
|
|
152
|
+
}
|
|
153
|
+
/** Set primary VP for a battle round (1-based) to a clamped value. Pure. */
|
|
154
|
+
export function setPrimary(pg, round, vp) {
|
|
155
|
+
const i = roundIndex(round);
|
|
156
|
+
const rounds = pg.rounds.map((c, idx) => idx === i ? { ...c, primary: Math.max(0, vp) } : c);
|
|
157
|
+
return { ...pg, rounds };
|
|
158
|
+
}
|
|
159
|
+
/** Put a drawn card in hand (no duplicates). Pure. */
|
|
160
|
+
export function addToHand(pg, cardId) {
|
|
161
|
+
if (pg.handIds.includes(cardId))
|
|
162
|
+
return pg;
|
|
163
|
+
return { ...pg, handIds: [...pg.handIds, cardId] };
|
|
164
|
+
}
|
|
165
|
+
/** Remove a card from hand (e.g. on score or discard). Pure. */
|
|
166
|
+
export function removeFromHand(pg, cardId) {
|
|
167
|
+
return { ...pg, handIds: pg.handIds.filter((id) => id !== cardId) };
|
|
168
|
+
}
|
|
169
|
+
/** Total primary VP across the game. */
|
|
170
|
+
export function playerPrimary(pg) {
|
|
171
|
+
return pg.rounds.reduce((sum, c) => sum + c.primary, 0);
|
|
172
|
+
}
|
|
173
|
+
/** Total secondary VP across the game. */
|
|
174
|
+
export function playerSecondary(pg) {
|
|
175
|
+
return pg.rounds.reduce((sum, c) => sum + c.secondary, 0);
|
|
176
|
+
}
|
|
177
|
+
/** Grand total VP, capped at {@link GAME_VP_CAP}. */
|
|
178
|
+
export function playerTotal(pg) {
|
|
179
|
+
return Math.min(GAME_VP_CAP, playerPrimary(pg) + playerSecondary(pg));
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The WTC 20-point result from two grand totals. The winner's margin maps onto
|
|
183
|
+
* 11 bands (0-5 → 10-10 draw, 6-10 → 11-9, ... 51+ → 20-0); the loser gets the
|
|
184
|
+
* complement. `a`/`b` correspond to the argument order.
|
|
185
|
+
*/
|
|
186
|
+
export function wtcResult(totalA, totalB) {
|
|
187
|
+
const diff = Math.abs(totalA - totalB);
|
|
188
|
+
const band = diff <= 5 ? 0 : Math.min(10, Math.ceil((diff - 5) / 5));
|
|
189
|
+
const winner = 10 + band;
|
|
190
|
+
const loser = 10 - band;
|
|
191
|
+
if (totalA === totalB)
|
|
192
|
+
return { a: 10, b: 10 };
|
|
193
|
+
return totalA > totalB ? { a: winner, b: loser } : { a: loser, b: winner };
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/scoring/index.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Card-driven secondary-mission scoring, 10th-edition tactical model.\n *\n * Drawn secondaries are *held* in hand across rounds and **scored once**: the\n * player asserts which of a card's awards they achieved, the engine computes the\n * VP (clamped to the card's cap), records it against the current battle round,\n * and the card is then discarded. There is no multi-turn per-card accrual — a\n * card pays out exactly once.\n *\n * Why \"asserted\" rather than evaluated: there is no board-state model here, so\n * an award's `when` condition is a human-readable label (see\n * `translate/scoring.ts`'s `describeScoringCard`, which this module never\n * modifies), not something the engine checks. The player ticks the awards they\n * made; the engine does the arithmetic, the OR-tier resolution, the cumulative\n * sums, and the cap.\n *\n * Deck-level rules the card schema deliberately omits live here as constants —\n * chiefly the 5 VP-per-card ceiling of the Tactical approach. The Fixed approach\n * instead uses each award's printed `vp_max`.\n *\n * `PlayerGame` is a plain JSON-serializable object so a UI can persist a whole\n * match (two of them) to localStorage and rehydrate without a revival step.\n *\n * CONFORMANCE FOLLOW-UP: this engine is TypeScript-only for now. A Rust port\n * plus a `conformance/scoring` corpus area (and a `SPEC_VERSION` bump) are a\n * separate change; the public shapes below are the surface that port mirrors,\n * so keep them stable.\n */\n\nimport type { SecondaryCard } from \"../generated.js\";\nimport type { ScoringAward, ScoringMode } from \"../translate/scoring.js\";\n\n/** The Tactical approach caps a single secondary's score at this many VP. */\nexport const TACTICAL_CARD_CAP = 5;\n/** Battle rounds in a game. */\nexport const ROUNDS = 5;\n/** Per-player VP ceiling (WTC sheet: grand total out of 100). */\nexport const GAME_VP_CAP = 100;\n\n/** An award the player ticks when scoring, with a count for per-instance awards. */\nexport interface AssertedAward {\n award: ScoringAward;\n /** Instances achieved (for `vp_per` awards); defaults to 1. */\n count?: number;\n}\n\n/** VP recorded against a single battle round. */\nexport interface RoundCell {\n primary: number;\n secondary: number;\n}\n\n/** A scored secondary, kept so the record can be shown and undone. */\nexport interface ScoreEntry {\n cardId: string;\n /** Battle round (1-based) the card was scored in. */\n round: number;\n vp: number;\n}\n\n/** One player's whole-game scoring state. Plain data — safe to JSON round-trip. */\nexport interface PlayerGame {\n /** Scoring approach: filters `mode` awards and sets the per-score cap. */\n approach: ScoringMode;\n /** Drawn-but-unscored secondaries, by card id. Scoring removes a card from here. */\n handIds: string[];\n /** Per-round VP, index 0 = round 1. Always length {@link ROUNDS}. */\n rounds: RoundCell[];\n /** Log of scored secondaries, in scoring order — the editable record. */\n log: ScoreEntry[];\n}\n\n/** A fresh player game for the given approach (defaults to tactical). */\nexport function emptyPlayerGame(approach: ScoringMode = \"tactical\"): PlayerGame {\n return {\n approach,\n handIds: [],\n rounds: Array.from({ length: ROUNDS }, () => ({ primary: 0, secondary: 0 })),\n log: [],\n };\n}\n\n/** Read a card's `awards`, typed (the generated `SecondaryCard` leaves them opaque). */\nexport function awardsOf(card: SecondaryCard): ScoringAward[] {\n return (card.awards ?? []) as unknown as ScoringAward[];\n}\n\n/**\n * The awards a player scores under `approach`. An award with no `mode` is flat\n * (it scores the same either way); an award tagged `fixed`/`tactical` scores\n * only under the matching approach.\n */\nexport function awardsForApproach(card: SecondaryCard, approach: ScoringMode): ScoringAward[] {\n return awardsOf(card).filter((a) => a.mode == null || a.mode === approach);\n}\n\n/**\n * VP for a single asserted award. A flat `vp` ignores `count`; a `vp_per` award\n * scores `vp_per × count`, with `count` clamped to `per_max` when present.\n */\nexport function scoreAward(award: ScoringAward, count = 1): number {\n if (award.vp != null) return award.vp;\n if (award.vp_per != null) {\n const capped = award.per_max != null ? Math.min(count, award.per_max) : count;\n return award.vp_per * Math.max(0, capped);\n }\n return 0;\n}\n\n/**\n * VP from everything asserted in one scoring, before the card cap. Awards\n * sharing an `exclusive_group` resolve as \"only the highest scores\" (the card's\n * literal OR between tier rows); everything else, including `cumulative` \"+\"\n * rows, sums.\n */\nexport function scoreTurn(asserted: AssertedAward[]): number {\n const groupBest = new Map<string, number>();\n let total = 0;\n for (const { award, count } of asserted) {\n const v = scoreAward(award, count ?? 1);\n if (award.exclusive_group != null) {\n const prev = groupBest.get(award.exclusive_group) ?? 0;\n if (v > prev) groupBest.set(award.exclusive_group, v);\n } else {\n total += v;\n }\n }\n for (const v of groupBest.values()) total += v;\n return total;\n}\n\n/**\n * A card's per-score VP ceiling under `approach`. Tactical is the universal\n * {@link TACTICAL_CARD_CAP}. Fixed uses the largest `vp_max` printed on the\n * card's scorable awards, or `Infinity` when none is printed (uncapped).\n */\nexport function scoreCap(card: SecondaryCard, approach: ScoringMode): number {\n if (approach === \"tactical\") return TACTICAL_CARD_CAP;\n const caps = awardsForApproach(card, \"fixed\")\n .map((a) => a.vp_max)\n .filter((x): x is number => x != null);\n return caps.length > 0 ? Math.max(...caps) : Infinity;\n}\n\n/**\n * The VP a single scoring of `card` grants under `approach`: the asserted awards'\n * total, clamped to the card's cap. This is the amount banked when the card is\n * scored (and then discarded).\n */\nexport function scoreSecondaryEvent(\n asserted: AssertedAward[],\n card: SecondaryCard,\n approach: ScoringMode,\n): number {\n return Math.min(scoreTurn(asserted), scoreCap(card, approach));\n}\n\nfunction roundIndex(round: number): number {\n return Math.max(0, Math.min(ROUNDS - 1, Math.trunc(round) - 1));\n}\n\n/** Add secondary VP to a battle round (1-based). Pure — returns new state. */\nexport function recordSecondary(pg: PlayerGame, round: number, vp: number): PlayerGame {\n const i = roundIndex(round);\n const rounds = pg.rounds.map((c, idx) =>\n idx === i ? { ...c, secondary: c.secondary + Math.max(0, vp) } : c,\n );\n return { ...pg, rounds };\n}\n\n/**\n * Score a held secondary: add its VP to the round, append it to the log, and\n * discard it from hand. Pure. The caller computes `vp` via\n * {@link scoreSecondaryEvent}.\n */\nexport function scoreSecondary(\n pg: PlayerGame,\n round: number,\n cardId: string,\n vp: number,\n): PlayerGame {\n const banked = Math.max(0, vp);\n const recorded = recordSecondary(pg, round, banked);\n return {\n ...removeFromHand(recorded, cardId),\n log: [...pg.log, { cardId, round, vp: banked }],\n };\n}\n\n/**\n * Undo a logged scoring by index: subtract its VP from its round, drop the log\n * entry, and return the card to hand so it can be re-scored. Pure; a no-op for\n * an out-of-range index.\n */\nexport function removeScore(pg: PlayerGame, index: number): PlayerGame {\n const entry = pg.log[index];\n if (!entry) return pg;\n const i = roundIndex(entry.round);\n const rounds = pg.rounds.map((c, idx) =>\n idx === i ? { ...c, secondary: Math.max(0, c.secondary - entry.vp) } : c,\n );\n const log = pg.log.filter((_, idx) => idx !== index);\n const handIds = pg.handIds.includes(entry.cardId)\n ? pg.handIds\n : [...pg.handIds, entry.cardId];\n return { ...pg, rounds, log, handIds };\n}\n\n/** Set primary VP for a battle round (1-based) to a clamped value. Pure. */\nexport function setPrimary(pg: PlayerGame, round: number, vp: number): PlayerGame {\n const i = roundIndex(round);\n const rounds = pg.rounds.map((c, idx) =>\n idx === i ? { ...c, primary: Math.max(0, vp) } : c,\n );\n return { ...pg, rounds };\n}\n\n/** Put a drawn card in hand (no duplicates). Pure. */\nexport function addToHand(pg: PlayerGame, cardId: string): PlayerGame {\n if (pg.handIds.includes(cardId)) return pg;\n return { ...pg, handIds: [...pg.handIds, cardId] };\n}\n\n/** Remove a card from hand (e.g. on score or discard). Pure. */\nexport function removeFromHand(pg: PlayerGame, cardId: string): PlayerGame {\n return { ...pg, handIds: pg.handIds.filter((id) => id !== cardId) };\n}\n\n/** Total primary VP across the game. */\nexport function playerPrimary(pg: PlayerGame): number {\n return pg.rounds.reduce((sum, c) => sum + c.primary, 0);\n}\n\n/** Total secondary VP across the game. */\nexport function playerSecondary(pg: PlayerGame): number {\n return pg.rounds.reduce((sum, c) => sum + c.secondary, 0);\n}\n\n/** Grand total VP, capped at {@link GAME_VP_CAP}. */\nexport function playerTotal(pg: PlayerGame): number {\n return Math.min(GAME_VP_CAP, playerPrimary(pg) + playerSecondary(pg));\n}\n\n/**\n * The WTC 20-point result from two grand totals. The winner's margin maps onto\n * 11 bands (0-5 → 10-10 draw, 6-10 → 11-9, ... 51+ → 20-0); the loser gets the\n * complement. `a`/`b` correspond to the argument order.\n */\nexport function wtcResult(totalA: number, totalB: number): { a: number; b: number } {\n const diff = Math.abs(totalA - totalB);\n const band = diff <= 5 ? 0 : Math.min(10, Math.ceil((diff - 5) / 5));\n const winner = 10 + band;\n const loser = 10 - band;\n if (totalA === totalB) return { a: 10, b: 10 };\n return totalA > totalB ? { a: winner, b: loser } : { a: loser, b: winner };\n}\n"]}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terrain geometry: resolve template-anchored layouts to absolute board-space
|
|
3
|
+
* vertices. See {@link resolveLayout} for the transform contract.
|
|
4
|
+
*
|
|
5
|
+
* @packageDocumentation
|
|
6
|
+
*/
|
|
7
|
+
export { resolveLayout, polygonCentroid, footprintVertices, orientedOffsets, TerrainResolveError, } from "./resolve.js";
|
|
8
|
+
export type { ResolvedPiece, Vec2 as ResolvedVec2 } from "./resolve.js";
|
|
9
|
+
export { solveCentroid, TerrainSolveError } from "./solve.js";
|
|
10
|
+
export type { BoardEdge, FeatureRef, DimensionLine, SolveInput } from "./solve.js";
|
|
11
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/terrain/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACL,aAAa,EACb,eAAe,EACf,iBAAiB,EACjB,eAAe,EACf,mBAAmB,GACpB,MAAM,cAAc,CAAC;AACtB,YAAY,EAAE,aAAa,EAAE,IAAI,IAAI,YAAY,EAAE,MAAM,cAAc,CAAC;AACxE,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAC9D,YAAY,EAAE,SAAS,EAAE,UAAU,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terrain geometry: resolve template-anchored layouts to absolute board-space
|
|
3
|
+
* vertices. See {@link resolveLayout} for the transform contract.
|
|
4
|
+
*
|
|
5
|
+
* @packageDocumentation
|
|
6
|
+
*/
|
|
7
|
+
export { resolveLayout, polygonCentroid, footprintVertices, orientedOffsets, TerrainResolveError, } from "./resolve.js";
|
|
8
|
+
export { solveCentroid, TerrainSolveError } from "./solve.js";
|
|
9
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/terrain/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACL,aAAa,EACb,eAAe,EACf,iBAAiB,EACjB,eAAe,EACf,mBAAmB,GACpB,MAAM,cAAc,CAAC;AAEtB,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC","sourcesContent":["/**\n * Terrain geometry: resolve template-anchored layouts to absolute board-space\n * vertices. See {@link resolveLayout} for the transform contract.\n *\n * @packageDocumentation\n */\nexport {\n resolveLayout,\n polygonCentroid,\n footprintVertices,\n orientedOffsets,\n TerrainResolveError,\n} from \"./resolve.js\";\nexport type { ResolvedPiece, Vec2 as ResolvedVec2 } from \"./resolve.js\";\nexport { solveCentroid, TerrainSolveError } from \"./solve.js\";\nexport type { BoardEdge, FeatureRef, DimensionLine, SolveInput } from \"./solve.js\";\n"]}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terrain layout resolver — turns a {@link TerrainLayout} (template references +
|
|
3
|
+
* centroid-anchored placements + rotation/mirror) into absolute board-space
|
|
4
|
+
* polygon vertices. This is the shared geometry contract pinned by the
|
|
5
|
+
* `conformance/terrain-resolver` corpus; the Rust crate implements the same
|
|
6
|
+
* function and must reproduce these vertices byte-for-byte (4-dp rounded).
|
|
7
|
+
*
|
|
8
|
+
* ## Transform contract
|
|
9
|
+
*
|
|
10
|
+
* Frames are board inches, origin at a board corner, **y-down** (per
|
|
11
|
+
* `common.schema.json#/$defs/vec2`). A footprint is authored in natural local
|
|
12
|
+
* y-down coordinates; the resolver derives its **polygon area centroid** and
|
|
13
|
+
* treats local vertices as `(v - centroid)`, so `position` always denotes the
|
|
14
|
+
* centroid and is invariant under rotation and mirror.
|
|
15
|
+
*
|
|
16
|
+
* Local → board, for an unparented piece, is `mirror → rotate → translate`:
|
|
17
|
+
*
|
|
18
|
+
* board = position + R_cw(rotation) · M(mirror) · (v - centroid)
|
|
19
|
+
*
|
|
20
|
+
* with `M`: horizontal → (-x, y), vertical → (x, -y); and `R_cw(θ)` a clockwise
|
|
21
|
+
* rotation in the y-down frame, `[[cosθ, -sinθ], [sinθ, cosθ]]`.
|
|
22
|
+
*
|
|
23
|
+
* A feature with a `parent_area_id` (or a template's composed feature) is first
|
|
24
|
+
* placed in the parent area's **centroid-local frame** (origin at the area
|
|
25
|
+
* centroid), then carried through the area's own placement:
|
|
26
|
+
*
|
|
27
|
+
* board = T_area ∘ R_area ∘ M_area ( featurePos + R_feat · M_feat · (w - C_feat) )
|
|
28
|
+
*
|
|
29
|
+
* ## Emission order (a pinned invariant)
|
|
30
|
+
*
|
|
31
|
+
* Pieces are emitted in `layout.pieces` order. When a piece instances an area
|
|
32
|
+
* template that carries composed `features`, those features are emitted
|
|
33
|
+
* immediately after their area, in template-declaration order.
|
|
34
|
+
*/
|
|
35
|
+
export interface Vec2 {
|
|
36
|
+
x: number;
|
|
37
|
+
y: number;
|
|
38
|
+
}
|
|
39
|
+
export type Footprint = {
|
|
40
|
+
type: "rectangle";
|
|
41
|
+
width: number;
|
|
42
|
+
height: number;
|
|
43
|
+
} | {
|
|
44
|
+
type: "right-triangle";
|
|
45
|
+
width: number;
|
|
46
|
+
height: number;
|
|
47
|
+
} | {
|
|
48
|
+
type: "polygon";
|
|
49
|
+
points: Vec2[];
|
|
50
|
+
};
|
|
51
|
+
export type Mirror = "none" | "horizontal" | "vertical";
|
|
52
|
+
export interface ComposedFeature {
|
|
53
|
+
id?: string;
|
|
54
|
+
template: string;
|
|
55
|
+
position: Vec2;
|
|
56
|
+
rotation_degrees?: number;
|
|
57
|
+
mirror?: Mirror;
|
|
58
|
+
floor?: number;
|
|
59
|
+
}
|
|
60
|
+
export interface TerrainTemplate {
|
|
61
|
+
id: string;
|
|
62
|
+
name?: string;
|
|
63
|
+
kind: "area" | "feature";
|
|
64
|
+
footprint: Footprint;
|
|
65
|
+
default_height_inches?: number;
|
|
66
|
+
default_blocking?: boolean;
|
|
67
|
+
default_terrain_area_keywords?: string[];
|
|
68
|
+
features?: ComposedFeature[];
|
|
69
|
+
}
|
|
70
|
+
export interface LayoutPiece {
|
|
71
|
+
id?: string;
|
|
72
|
+
name?: string;
|
|
73
|
+
piece_type?: "area" | "feature";
|
|
74
|
+
template?: string;
|
|
75
|
+
footprint?: Footprint;
|
|
76
|
+
position: Vec2;
|
|
77
|
+
rotation_degrees?: number;
|
|
78
|
+
mirror?: Mirror;
|
|
79
|
+
parent_area_id?: string;
|
|
80
|
+
floor?: number;
|
|
81
|
+
height_inches?: number;
|
|
82
|
+
terrain_area_keywords?: string[];
|
|
83
|
+
link_group?: string;
|
|
84
|
+
}
|
|
85
|
+
export interface TerrainLayout {
|
|
86
|
+
id: string;
|
|
87
|
+
name: string;
|
|
88
|
+
pieces?: LayoutPiece[];
|
|
89
|
+
}
|
|
90
|
+
export interface ResolvedPiece {
|
|
91
|
+
/** Layout-local id when present, else the piece name, else null. */
|
|
92
|
+
id: string | null;
|
|
93
|
+
name: string | null;
|
|
94
|
+
piece_type: "area" | "feature";
|
|
95
|
+
floor: number;
|
|
96
|
+
/** Absolute board-space polygon vertices, y-down. */
|
|
97
|
+
vertices: Vec2[];
|
|
98
|
+
}
|
|
99
|
+
/** A footprint's polygon vertices in natural local (y-down) coordinates. */
|
|
100
|
+
export declare function footprintVertices(fp: Footprint): Vec2[];
|
|
101
|
+
/**
|
|
102
|
+
* Polygon area centroid (shoelace). Falls back to the vertex mean when the
|
|
103
|
+
* polygon is degenerate (zero signed area, e.g. collinear points) so the
|
|
104
|
+
* resolver never divides by zero.
|
|
105
|
+
*/
|
|
106
|
+
export declare function polygonCentroid(verts: Vec2[]): Vec2;
|
|
107
|
+
/**
|
|
108
|
+
* The board-space offset of each footprint vertex from the piece centroid,
|
|
109
|
+
* after mirror + rotation but before translation. Adding `position` to each
|
|
110
|
+
* gives the resolved board vertices; this is the orientation-only part a
|
|
111
|
+
* card-measurement solver inverts to recover the centroid. Vertex order matches
|
|
112
|
+
* {@link footprintVertices}.
|
|
113
|
+
*/
|
|
114
|
+
export declare function orientedOffsets(footprint: Footprint, rotation: number, mirror: Mirror): Vec2[];
|
|
115
|
+
export declare class TerrainResolveError extends Error {
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Resolve a layout to absolute board-space vertices per piece. `templates` is
|
|
119
|
+
* the catalog a piece's `template` references resolve against.
|
|
120
|
+
*/
|
|
121
|
+
export declare function resolveLayout(layout: TerrainLayout, templates: TerrainTemplate[]): ResolvedPiece[];
|
|
122
|
+
//# sourceMappingURL=resolve.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve.d.ts","sourceRoot":"","sources":["../../src/terrain/resolve.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terrain layout resolver — turns a {@link TerrainLayout} (template references +
|
|
3
|
+
* centroid-anchored placements + rotation/mirror) into absolute board-space
|
|
4
|
+
* polygon vertices. This is the shared geometry contract pinned by the
|
|
5
|
+
* `conformance/terrain-resolver` corpus; the Rust crate implements the same
|
|
6
|
+
* function and must reproduce these vertices byte-for-byte (4-dp rounded).
|
|
7
|
+
*
|
|
8
|
+
* ## Transform contract
|
|
9
|
+
*
|
|
10
|
+
* Frames are board inches, origin at a board corner, **y-down** (per
|
|
11
|
+
* `common.schema.json#/$defs/vec2`). A footprint is authored in natural local
|
|
12
|
+
* y-down coordinates; the resolver derives its **polygon area centroid** and
|
|
13
|
+
* treats local vertices as `(v - centroid)`, so `position` always denotes the
|
|
14
|
+
* centroid and is invariant under rotation and mirror.
|
|
15
|
+
*
|
|
16
|
+
* Local → board, for an unparented piece, is `mirror → rotate → translate`:
|
|
17
|
+
*
|
|
18
|
+
* board = position + R_cw(rotation) · M(mirror) · (v - centroid)
|
|
19
|
+
*
|
|
20
|
+
* with `M`: horizontal → (-x, y), vertical → (x, -y); and `R_cw(θ)` a clockwise
|
|
21
|
+
* rotation in the y-down frame, `[[cosθ, -sinθ], [sinθ, cosθ]]`.
|
|
22
|
+
*
|
|
23
|
+
* A feature with a `parent_area_id` (or a template's composed feature) is first
|
|
24
|
+
* placed in the parent area's **centroid-local frame** (origin at the area
|
|
25
|
+
* centroid), then carried through the area's own placement:
|
|
26
|
+
*
|
|
27
|
+
* board = T_area ∘ R_area ∘ M_area ( featurePos + R_feat · M_feat · (w - C_feat) )
|
|
28
|
+
*
|
|
29
|
+
* ## Emission order (a pinned invariant)
|
|
30
|
+
*
|
|
31
|
+
* Pieces are emitted in `layout.pieces` order. When a piece instances an area
|
|
32
|
+
* template that carries composed `features`, those features are emitted
|
|
33
|
+
* immediately after their area, in template-declaration order.
|
|
34
|
+
*/
|
|
35
|
+
const DEG = Math.PI / 180;
|
|
36
|
+
/** A footprint's polygon vertices in natural local (y-down) coordinates. */
|
|
37
|
+
export function footprintVertices(fp) {
|
|
38
|
+
switch (fp.type) {
|
|
39
|
+
case "rectangle":
|
|
40
|
+
return [
|
|
41
|
+
{ x: 0, y: 0 },
|
|
42
|
+
{ x: fp.width, y: 0 },
|
|
43
|
+
{ x: fp.width, y: fp.height },
|
|
44
|
+
{ x: 0, y: fp.height },
|
|
45
|
+
];
|
|
46
|
+
case "right-triangle":
|
|
47
|
+
// Right angle at the local origin, legs along +x and +y.
|
|
48
|
+
return [
|
|
49
|
+
{ x: 0, y: 0 },
|
|
50
|
+
{ x: fp.width, y: 0 },
|
|
51
|
+
{ x: 0, y: fp.height },
|
|
52
|
+
];
|
|
53
|
+
case "polygon":
|
|
54
|
+
return fp.points.map((p) => ({ x: p.x, y: p.y }));
|
|
55
|
+
default: {
|
|
56
|
+
const exhaustive = fp;
|
|
57
|
+
throw new Error(`unknown footprint type: ${JSON.stringify(exhaustive)}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Polygon area centroid (shoelace). Falls back to the vertex mean when the
|
|
63
|
+
* polygon is degenerate (zero signed area, e.g. collinear points) so the
|
|
64
|
+
* resolver never divides by zero.
|
|
65
|
+
*/
|
|
66
|
+
export function polygonCentroid(verts) {
|
|
67
|
+
const n = verts.length;
|
|
68
|
+
if (n === 0)
|
|
69
|
+
return { x: 0, y: 0 };
|
|
70
|
+
let twiceArea = 0;
|
|
71
|
+
let cx = 0;
|
|
72
|
+
let cy = 0;
|
|
73
|
+
for (let i = 0; i < n; i++) {
|
|
74
|
+
const a = verts[i];
|
|
75
|
+
const b = verts[(i + 1) % n];
|
|
76
|
+
const cross = a.x * b.y - b.x * a.y;
|
|
77
|
+
twiceArea += cross;
|
|
78
|
+
cx += (a.x + b.x) * cross;
|
|
79
|
+
cy += (a.y + b.y) * cross;
|
|
80
|
+
}
|
|
81
|
+
if (twiceArea === 0) {
|
|
82
|
+
const mean = verts.reduce((acc, v) => ({ x: acc.x + v.x, y: acc.y + v.y }), { x: 0, y: 0 });
|
|
83
|
+
return { x: mean.x / n, y: mean.y / n };
|
|
84
|
+
}
|
|
85
|
+
return { x: cx / (3 * twiceArea), y: cy / (3 * twiceArea) };
|
|
86
|
+
}
|
|
87
|
+
function applyMirror(v, m) {
|
|
88
|
+
switch (m) {
|
|
89
|
+
case "horizontal":
|
|
90
|
+
return { x: -v.x, y: v.y };
|
|
91
|
+
case "vertical":
|
|
92
|
+
return { x: v.x, y: -v.y };
|
|
93
|
+
default:
|
|
94
|
+
return v;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Clockwise rotation by `deg` degrees in the y-down frame. */
|
|
98
|
+
function rotateCw(v, deg) {
|
|
99
|
+
if (deg === 0)
|
|
100
|
+
return { x: v.x, y: v.y };
|
|
101
|
+
const r = deg * DEG;
|
|
102
|
+
const c = Math.cos(r);
|
|
103
|
+
const s = Math.sin(r);
|
|
104
|
+
return { x: c * v.x - s * v.y, y: s * v.x + c * v.y };
|
|
105
|
+
}
|
|
106
|
+
/** mirror → rotate (no translation). The orientation-only part of a placement. */
|
|
107
|
+
function orient(v, rotation, mirror) {
|
|
108
|
+
return rotateCw(applyMirror(v, mirror), rotation);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The board-space offset of each footprint vertex from the piece centroid,
|
|
112
|
+
* after mirror + rotation but before translation. Adding `position` to each
|
|
113
|
+
* gives the resolved board vertices; this is the orientation-only part a
|
|
114
|
+
* card-measurement solver inverts to recover the centroid. Vertex order matches
|
|
115
|
+
* {@link footprintVertices}.
|
|
116
|
+
*/
|
|
117
|
+
export function orientedOffsets(footprint, rotation, mirror) {
|
|
118
|
+
const verts = footprintVertices(footprint);
|
|
119
|
+
const c = polygonCentroid(verts);
|
|
120
|
+
return verts.map((v) => orient({ x: v.x - c.x, y: v.y - c.y }, rotation, mirror));
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Place a footprint's local vertices into a target frame: recenter on the
|
|
124
|
+
* footprint centroid, mirror, rotate, then translate so the centroid lands on
|
|
125
|
+
* `position`. The target frame is board space for an area, or the parent area's
|
|
126
|
+
* centroid-local frame for a composed/parented feature.
|
|
127
|
+
*/
|
|
128
|
+
function placeFootprint(fp, position, rotation, mirror) {
|
|
129
|
+
const verts = footprintVertices(fp);
|
|
130
|
+
const c = polygonCentroid(verts);
|
|
131
|
+
return verts.map((v) => {
|
|
132
|
+
const o = orient({ x: v.x - c.x, y: v.y - c.y }, rotation, mirror);
|
|
133
|
+
return { x: o.x + position.x, y: o.y + position.y };
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const TWO_DP_ROUND = 1e4;
|
|
137
|
+
function round4(v) {
|
|
138
|
+
return { x: Math.round(v.x * TWO_DP_ROUND) / TWO_DP_ROUND, y: Math.round(v.y * TWO_DP_ROUND) / TWO_DP_ROUND };
|
|
139
|
+
}
|
|
140
|
+
function resolvedIdName(piece) {
|
|
141
|
+
return { id: piece.id ?? null, name: piece.name ?? null };
|
|
142
|
+
}
|
|
143
|
+
export class TerrainResolveError extends Error {
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Resolve a layout to absolute board-space vertices per piece. `templates` is
|
|
147
|
+
* the catalog a piece's `template` references resolve against.
|
|
148
|
+
*/
|
|
149
|
+
export function resolveLayout(layout, templates) {
|
|
150
|
+
const byId = new Map();
|
|
151
|
+
for (const t of templates)
|
|
152
|
+
byId.set(t.id, t);
|
|
153
|
+
const pieces = layout.pieces ?? [];
|
|
154
|
+
const areasById = new Map();
|
|
155
|
+
for (const p of pieces)
|
|
156
|
+
if (p.id)
|
|
157
|
+
areasById.set(p.id, p);
|
|
158
|
+
const footprintOf = (piece, where) => {
|
|
159
|
+
if (piece.footprint)
|
|
160
|
+
return piece.footprint;
|
|
161
|
+
if (piece.template) {
|
|
162
|
+
const t = byId.get(piece.template);
|
|
163
|
+
if (!t)
|
|
164
|
+
throw new TerrainResolveError(`${where}: unknown template "${piece.template}"`);
|
|
165
|
+
return t.footprint;
|
|
166
|
+
}
|
|
167
|
+
throw new TerrainResolveError(`${where}: piece has neither footprint nor template`);
|
|
168
|
+
};
|
|
169
|
+
const out = [];
|
|
170
|
+
for (const piece of pieces) {
|
|
171
|
+
const where = piece.id ?? piece.name ?? "<piece>";
|
|
172
|
+
const fp = footprintOf(piece, where);
|
|
173
|
+
const rotation = piece.rotation_degrees ?? 0;
|
|
174
|
+
const mirror = piece.mirror ?? "none";
|
|
175
|
+
const pieceType = piece.piece_type ?? (piece.parent_area_id ? "feature" : "area");
|
|
176
|
+
if (piece.parent_area_id) {
|
|
177
|
+
// Feature placed in its parent area's centroid-local frame.
|
|
178
|
+
const parent = areasById.get(piece.parent_area_id);
|
|
179
|
+
if (!parent) {
|
|
180
|
+
throw new TerrainResolveError(`${where}: unknown parent_area_id "${piece.parent_area_id}"`);
|
|
181
|
+
}
|
|
182
|
+
const areaLocal = placeFootprint(fp, piece.position, rotation, mirror);
|
|
183
|
+
const aRot = parent.rotation_degrees ?? 0;
|
|
184
|
+
const aMirror = parent.mirror ?? "none";
|
|
185
|
+
const vertices = areaLocal.map((p) => {
|
|
186
|
+
const o = orient(p, aRot, aMirror);
|
|
187
|
+
return round4({ x: o.x + parent.position.x, y: o.y + parent.position.y });
|
|
188
|
+
});
|
|
189
|
+
out.push({ ...resolvedIdName(piece), piece_type: pieceType, floor: piece.floor ?? 0, vertices });
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
// Unparented area or feature: place directly in board space.
|
|
193
|
+
const vertices = placeFootprint(fp, piece.position, rotation, mirror).map(round4);
|
|
194
|
+
out.push({ ...resolvedIdName(piece), piece_type: pieceType, floor: piece.floor ?? 0, vertices });
|
|
195
|
+
// Expand an area template's composed features, carried through this area's
|
|
196
|
+
// placement (same composition math as a parented feature).
|
|
197
|
+
if (piece.template) {
|
|
198
|
+
const t = byId.get(piece.template);
|
|
199
|
+
for (const feat of t?.features ?? []) {
|
|
200
|
+
const ft = byId.get(feat.template);
|
|
201
|
+
if (!ft) {
|
|
202
|
+
throw new TerrainResolveError(`${where}: composed feature references unknown template "${feat.template}"`);
|
|
203
|
+
}
|
|
204
|
+
const areaLocal = placeFootprint(ft.footprint, feat.position, feat.rotation_degrees ?? 0, feat.mirror ?? "none");
|
|
205
|
+
const featVerts = areaLocal.map((p) => {
|
|
206
|
+
const o = orient(p, rotation, mirror);
|
|
207
|
+
return round4({ x: o.x + piece.position.x, y: o.y + piece.position.y });
|
|
208
|
+
});
|
|
209
|
+
out.push({
|
|
210
|
+
id: feat.id ?? null,
|
|
211
|
+
name: ft.name ?? null,
|
|
212
|
+
piece_type: "feature",
|
|
213
|
+
floor: feat.floor ?? 0,
|
|
214
|
+
vertices: featVerts,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
//# sourceMappingURL=resolve.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve.js","sourceRoot":"","sources":["../../src/terrain/resolve.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Terrain layout resolver — turns a {@link TerrainLayout} (template references +\n * centroid-anchored placements + rotation/mirror) into absolute board-space\n * polygon vertices. This is the shared geometry contract pinned by the\n * `conformance/terrain-resolver` corpus; the Rust crate implements the same\n * function and must reproduce these vertices byte-for-byte (4-dp rounded).\n *\n * ## Transform contract\n *\n * Frames are board inches, origin at a board corner, **y-down** (per\n * `common.schema.json#/$defs/vec2`). A footprint is authored in natural local\n * y-down coordinates; the resolver derives its **polygon area centroid** and\n * treats local vertices as `(v - centroid)`, so `position` always denotes the\n * centroid and is invariant under rotation and mirror.\n *\n * Local → board, for an unparented piece, is `mirror → rotate → translate`:\n *\n * board = position + R_cw(rotation) · M(mirror) · (v - centroid)\n *\n * with `M`: horizontal → (-x, y), vertical → (x, -y); and `R_cw(θ)` a clockwise\n * rotation in the y-down frame, `[[cosθ, -sinθ], [sinθ, cosθ]]`.\n *\n * A feature with a `parent_area_id` (or a template's composed feature) is first\n * placed in the parent area's **centroid-local frame** (origin at the area\n * centroid), then carried through the area's own placement:\n *\n * board = T_area ∘ R_area ∘ M_area ( featurePos + R_feat · M_feat · (w - C_feat) )\n *\n * ## Emission order (a pinned invariant)\n *\n * Pieces are emitted in `layout.pieces` order. When a piece instances an area\n * template that carries composed `features`, those features are emitted\n * immediately after their area, in template-declaration order.\n */\n\nexport interface Vec2 {\n x: number;\n y: number;\n}\n\nexport type Footprint =\n | { type: \"rectangle\"; width: number; height: number }\n | { type: \"right-triangle\"; width: number; height: number }\n | { type: \"polygon\"; points: Vec2[] };\n\nexport type Mirror = \"none\" | \"horizontal\" | \"vertical\";\n\nexport interface ComposedFeature {\n id?: string;\n template: string;\n position: Vec2;\n rotation_degrees?: number;\n mirror?: Mirror;\n floor?: number;\n}\n\nexport interface TerrainTemplate {\n id: string;\n name?: string;\n kind: \"area\" | \"feature\";\n footprint: Footprint;\n default_height_inches?: number;\n default_blocking?: boolean;\n default_terrain_area_keywords?: string[];\n features?: ComposedFeature[];\n}\n\nexport interface LayoutPiece {\n id?: string;\n name?: string;\n piece_type?: \"area\" | \"feature\";\n template?: string;\n footprint?: Footprint;\n position: Vec2;\n rotation_degrees?: number;\n mirror?: Mirror;\n parent_area_id?: string;\n floor?: number;\n height_inches?: number;\n terrain_area_keywords?: string[];\n link_group?: string;\n}\n\nexport interface TerrainLayout {\n id: string;\n name: string;\n pieces?: LayoutPiece[];\n}\n\nexport interface ResolvedPiece {\n /** Layout-local id when present, else the piece name, else null. */\n id: string | null;\n name: string | null;\n piece_type: \"area\" | \"feature\";\n floor: number;\n /** Absolute board-space polygon vertices, y-down. */\n vertices: Vec2[];\n}\n\nconst DEG = Math.PI / 180;\n\n/** A footprint's polygon vertices in natural local (y-down) coordinates. */\nexport function footprintVertices(fp: Footprint): Vec2[] {\n switch (fp.type) {\n case \"rectangle\":\n return [\n { x: 0, y: 0 },\n { x: fp.width, y: 0 },\n { x: fp.width, y: fp.height },\n { x: 0, y: fp.height },\n ];\n case \"right-triangle\":\n // Right angle at the local origin, legs along +x and +y.\n return [\n { x: 0, y: 0 },\n { x: fp.width, y: 0 },\n { x: 0, y: fp.height },\n ];\n case \"polygon\":\n return fp.points.map((p) => ({ x: p.x, y: p.y }));\n default: {\n const exhaustive: never = fp;\n throw new Error(`unknown footprint type: ${JSON.stringify(exhaustive)}`);\n }\n }\n}\n\n/**\n * Polygon area centroid (shoelace). Falls back to the vertex mean when the\n * polygon is degenerate (zero signed area, e.g. collinear points) so the\n * resolver never divides by zero.\n */\nexport function polygonCentroid(verts: Vec2[]): Vec2 {\n const n = verts.length;\n if (n === 0) return { x: 0, y: 0 };\n let twiceArea = 0;\n let cx = 0;\n let cy = 0;\n for (let i = 0; i < n; i++) {\n const a = verts[i];\n const b = verts[(i + 1) % n];\n const cross = a.x * b.y - b.x * a.y;\n twiceArea += cross;\n cx += (a.x + b.x) * cross;\n cy += (a.y + b.y) * cross;\n }\n if (twiceArea === 0) {\n const mean = verts.reduce((acc, v) => ({ x: acc.x + v.x, y: acc.y + v.y }), { x: 0, y: 0 });\n return { x: mean.x / n, y: mean.y / n };\n }\n return { x: cx / (3 * twiceArea), y: cy / (3 * twiceArea) };\n}\n\nfunction applyMirror(v: Vec2, m: Mirror): Vec2 {\n switch (m) {\n case \"horizontal\":\n return { x: -v.x, y: v.y };\n case \"vertical\":\n return { x: v.x, y: -v.y };\n default:\n return v;\n }\n}\n\n/** Clockwise rotation by `deg` degrees in the y-down frame. */\nfunction rotateCw(v: Vec2, deg: number): Vec2 {\n if (deg === 0) return { x: v.x, y: v.y };\n const r = deg * DEG;\n const c = Math.cos(r);\n const s = Math.sin(r);\n return { x: c * v.x - s * v.y, y: s * v.x + c * v.y };\n}\n\n/** mirror → rotate (no translation). The orientation-only part of a placement. */\nfunction orient(v: Vec2, rotation: number, mirror: Mirror): Vec2 {\n return rotateCw(applyMirror(v, mirror), rotation);\n}\n\n/**\n * The board-space offset of each footprint vertex from the piece centroid,\n * after mirror + rotation but before translation. Adding `position` to each\n * gives the resolved board vertices; this is the orientation-only part a\n * card-measurement solver inverts to recover the centroid. Vertex order matches\n * {@link footprintVertices}.\n */\nexport function orientedOffsets(footprint: Footprint, rotation: number, mirror: Mirror): Vec2[] {\n const verts = footprintVertices(footprint);\n const c = polygonCentroid(verts);\n return verts.map((v) => orient({ x: v.x - c.x, y: v.y - c.y }, rotation, mirror));\n}\n\n/**\n * Place a footprint's local vertices into a target frame: recenter on the\n * footprint centroid, mirror, rotate, then translate so the centroid lands on\n * `position`. The target frame is board space for an area, or the parent area's\n * centroid-local frame for a composed/parented feature.\n */\nfunction placeFootprint(\n fp: Footprint,\n position: Vec2,\n rotation: number,\n mirror: Mirror,\n): Vec2[] {\n const verts = footprintVertices(fp);\n const c = polygonCentroid(verts);\n return verts.map((v) => {\n const o = orient({ x: v.x - c.x, y: v.y - c.y }, rotation, mirror);\n return { x: o.x + position.x, y: o.y + position.y };\n });\n}\n\nconst TWO_DP_ROUND = 1e4;\nfunction round4(v: Vec2): Vec2 {\n return { x: Math.round(v.x * TWO_DP_ROUND) / TWO_DP_ROUND, y: Math.round(v.y * TWO_DP_ROUND) / TWO_DP_ROUND };\n}\n\nfunction resolvedIdName(piece: { id?: string; name?: string }): { id: string | null; name: string | null } {\n return { id: piece.id ?? null, name: piece.name ?? null };\n}\n\nexport class TerrainResolveError extends Error {}\n\n/**\n * Resolve a layout to absolute board-space vertices per piece. `templates` is\n * the catalog a piece's `template` references resolve against.\n */\nexport function resolveLayout(layout: TerrainLayout, templates: TerrainTemplate[]): ResolvedPiece[] {\n const byId = new Map<string, TerrainTemplate>();\n for (const t of templates) byId.set(t.id, t);\n\n const pieces = layout.pieces ?? [];\n const areasById = new Map<string, LayoutPiece>();\n for (const p of pieces) if (p.id) areasById.set(p.id, p);\n\n const footprintOf = (piece: { template?: string; footprint?: Footprint }, where: string): Footprint => {\n if (piece.footprint) return piece.footprint;\n if (piece.template) {\n const t = byId.get(piece.template);\n if (!t) throw new TerrainResolveError(`${where}: unknown template \"${piece.template}\"`);\n return t.footprint;\n }\n throw new TerrainResolveError(`${where}: piece has neither footprint nor template`);\n };\n\n const out: ResolvedPiece[] = [];\n\n for (const piece of pieces) {\n const where = piece.id ?? piece.name ?? \"<piece>\";\n const fp = footprintOf(piece, where);\n const rotation = piece.rotation_degrees ?? 0;\n const mirror = piece.mirror ?? \"none\";\n const pieceType = piece.piece_type ?? (piece.parent_area_id ? \"feature\" : \"area\");\n\n if (piece.parent_area_id) {\n // Feature placed in its parent area's centroid-local frame.\n const parent = areasById.get(piece.parent_area_id);\n if (!parent) {\n throw new TerrainResolveError(`${where}: unknown parent_area_id \"${piece.parent_area_id}\"`);\n }\n const areaLocal = placeFootprint(fp, piece.position, rotation, mirror);\n const aRot = parent.rotation_degrees ?? 0;\n const aMirror = parent.mirror ?? \"none\";\n const vertices = areaLocal.map((p) => {\n const o = orient(p, aRot, aMirror);\n return round4({ x: o.x + parent.position.x, y: o.y + parent.position.y });\n });\n out.push({ ...resolvedIdName(piece), piece_type: pieceType, floor: piece.floor ?? 0, vertices });\n continue;\n }\n\n // Unparented area or feature: place directly in board space.\n const vertices = placeFootprint(fp, piece.position, rotation, mirror).map(round4);\n out.push({ ...resolvedIdName(piece), piece_type: pieceType, floor: piece.floor ?? 0, vertices });\n\n // Expand an area template's composed features, carried through this area's\n // placement (same composition math as a parented feature).\n if (piece.template) {\n const t = byId.get(piece.template);\n for (const feat of t?.features ?? []) {\n const ft = byId.get(feat.template);\n if (!ft) {\n throw new TerrainResolveError(`${where}: composed feature references unknown template \"${feat.template}\"`);\n }\n const areaLocal = placeFootprint(\n ft.footprint,\n feat.position,\n feat.rotation_degrees ?? 0,\n feat.mirror ?? \"none\",\n );\n const featVerts = areaLocal.map((p) => {\n const o = orient(p, rotation, mirror);\n return round4({ x: o.x + piece.position.x, y: o.y + piece.position.y });\n });\n out.push({\n id: feat.id ?? null,\n name: ft.name ?? null,\n piece_type: \"feature\",\n floor: feat.floor ?? 0,\n vertices: featVerts,\n });\n }\n }\n }\n\n return out;\n}\n"]}
|