okey-game-engine-ts 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 okey-game-engine-ts contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,453 @@
1
- # Temporary Holding Version
1
+ # okey-game-engine-ts
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A rules engine for the Turkish tile games **Okey** and **Okey 101**, written in TypeScript.
4
+
5
+ - **Both games, with the common table variants as options:** partnership or individual play (eşli / eşsiz), the rising opening barrier (katlamalı / katlamasız), pairs, okey finishes, penalties and more.
6
+ - **A pure reducer over plain JSON state.** `apply(state, action)` returns a new state and a list of events. No classes, no I/O, no timers.
7
+ - **Deterministic.** The same seed and actions give the same game on every platform.
8
+ - **Hidden information handled for you.** `getPlayerView` and `redactEvents` produce what one player may see.
9
+ - **Rack helpers.** Exact solvers for the best runs arrangement (seri dizim) and pairs arrangement (çift dizim), a two-row rack layout, and finders for winning discards, openings and lay-offs.
10
+ - **Zero runtime dependencies.** Runs in Node 20+, browsers, React Native and edge runtimes. Ships ESM, CommonJS and type declarations.
11
+
12
+ It is a rules engine only: no UI, networking, bots or chip economy.
13
+
14
+ ```sh
15
+ npm install okey-game-engine-ts
16
+ ```
17
+
18
+ ## Contents
19
+
20
+ - [Quick start](#quick-start)
21
+ - [Concepts](#concepts)
22
+ - [Classic Okey](#classic-okey): [rules](#classic-okey-rules), [actions](#classic-actions), [options](#classic-options)
23
+ - [Okey 101](#okey-101): [rules](#okey-101-rules), [actions](#okey-101-actions), [options](#okey-101-options)
24
+ - [Running a table](#running-a-table)
25
+ - [Rack helpers](#rack-helpers)
26
+ - [Errors](#errors)
27
+ - [Rule sources and limits](#rule-sources-and-limits)
28
+
29
+ ## Quick start
30
+
31
+ ```ts
32
+ import { classic } from 'okey-game-engine-ts'
33
+
34
+ let state = classic.createGame({ seed: 'table-42' })
35
+
36
+ // The starter holds 15 tiles and begins by discarding.
37
+ const starter = state.turn
38
+ const result = classic.apply(state, {
39
+ type: 'discard',
40
+ player: starter,
41
+ tile: state.racks[starter][0],
42
+ })
43
+
44
+ if (result.ok) {
45
+ state = result.state
46
+ console.log(result.events) // [{ type: 'tileDiscarded', player, tile }]
47
+ } else {
48
+ console.log(result.error.code) // e.g. 'NOT_YOUR_TURN'
49
+ }
50
+ ```
51
+
52
+ The package has four entry points. The root exports the two games as namespaces plus every helper; the others export the same things flat.
53
+
54
+ ```ts
55
+ import { classic, okey101, arrangeRuns } from 'okey-game-engine-ts'
56
+ import { createGame, apply } from 'okey-game-engine-ts/classic'
57
+ import { createGame, apply } from 'okey-game-engine-ts/okey101'
58
+ import { arrangeRuns, parseTiles } from 'okey-game-engine-ts/helpers'
59
+ ```
60
+
61
+ ## Concepts
62
+
63
+ ### Tiles
64
+
65
+ There are 106 tiles: the numbers 1 to 13 in four colours, two copies of each, plus two false jokers.
66
+
67
+ A tile is an integer from 0 to 105, so state stays plain JSON and the two copies of a face stay distinct. `tileFace(id)` returns `{ kind: 'number', color, number }` or `{ kind: 'falseJoker' }`.
68
+
69
+ For tests and debugging there is a text notation: a colour letter and a number, with `jk` for a false joker.
70
+
71
+ | Letter | Colour |
72
+ |---|---|
73
+ | `r` | red |
74
+ | `y` | yellow |
75
+ | `b` | blue |
76
+ | `k` | black |
77
+
78
+ ```ts
79
+ import { formatTiles, parseTiles } from 'okey-game-engine-ts'
80
+
81
+ const tiles = parseTiles('r5 r5 y13 jk') // four distinct tile ids
82
+ formatTiles(tiles) // 'r5 r5 y13 jk'
83
+ ```
84
+
85
+ ### Indicator, okey and false joker
86
+
87
+ - **Indicator (gösterge):** one tile is turned face up at the start of each hand. It is never a false joker.
88
+ - **Okey:** the tile one above the indicator in the same colour; an indicator of 13 makes the 1 the okey. Both copies are wild and may stand for any tile.
89
+ - **False joker (sahte okey):** not wild. It stands for the okey's own face and nothing else. With a red 4 as indicator, the red 5s are okeys and a false joker plays as a red 5.
90
+
91
+ ```ts
92
+ import { okeyFromIndicator, resolveTile } from 'okey-game-engine-ts'
93
+
94
+ const okey = okeyFromIndicator(state.indicator) // { color: 'red', number: 5 }; also at state.okey
95
+ resolveTile(tile, okey) // { wild: true } or { wild: false, color, number }
96
+ ```
97
+
98
+ ### Melds
99
+
100
+ | Meld | Turkish | Definition |
101
+ |---|---|---|
102
+ | Run | seri | Three or more consecutive numbers of one colour |
103
+ | Group | grup | Three or four tiles of one number, all different colours |
104
+ | Pair | çift | Two tiles with the same colour and number |
105
+
106
+ An okey can replace any tile in any meld, including one half of a pair.
107
+
108
+ The 1 is low (1-2-3). In **classic Okey** it may also follow 13 (12-13-1), but never both at once: 13-1-2 is not a run. In **Okey 101** a 1 never follows 13.
109
+
110
+ ### Seats, sides, hands and matches
111
+
112
+ - **Seat:** a player's position, numbered from 0. Play passes from seat `n` to seat `n + 1`, and a player may take the discard of the seat before them.
113
+ - **Side:** who a seat scores for. In individual play (eşsiz) each seat is its own side. In partnership play (eşli) seats 0 and 2 play against seats 1 and 3.
114
+ - **Hand (el):** one deal. The dealer's next seat is the starter and holds one extra tile.
115
+ - **Match:** a series of hands with a running score. The deal passes to the next seat each hand.
116
+
117
+ Games can be played by 2, 3 or 4 players. Partnership play needs 4.
118
+
119
+ ## Classic Okey
120
+
121
+ ### Classic Okey rules
122
+
123
+ **Deal.** Every player gets 14 tiles and the starter gets 15. With four players that leaves 48 tiles in the draw pile.
124
+
125
+ **Turns.** The starter begins by discarding one tile without drawing. After that each turn is:
126
+
127
+ 1. Draw the top tile of the pile, or take the tile the previous player just discarded.
128
+ 2. Discard one tile.
129
+
130
+ No melds are laid on the table during play. A hand stays hidden until its owner goes out.
131
+
132
+ **Going out.** On your turn, with 15 tiles in hand, you go out by discarding one tile so that the other 14 form a winning hand:
133
+
134
+ - **Runs and groups:** all 14 tiles split into runs and groups, for example 3 + 3 + 4 + 4.
135
+ - **Seven pairs (çift):** all 14 tiles form seven pairs.
136
+
137
+ **Scoring.** Every side starts with 20 points. When a player goes out, every other side loses points:
138
+
139
+ | Finish | Points lost by each other side |
140
+ |---|---|
141
+ | Ordinary win | 2 |
142
+ | Okey finish (okey atarak bitme): the final discard is an okey | 4 |
143
+ | Seven pairs | 4 |
144
+ | Seven pairs with an okey as the final discard | 8 |
145
+
146
+ Discarding a false joker to go out is an ordinary win.
147
+
148
+ **Showing the indicator (gösterge göstermek).** A player who holds the twin of the indicator may show it before making their first move of the hand. Every other side loses 1 point at once.
149
+
150
+ **When the pile runs out.** The player to move may take the last discard only if it lets them go out. Otherwise they pass and the hand ends with no winner and no points.
151
+
152
+ **End of the match.** The match ends when a side reaches 0 points or less. The side with the most points wins.
153
+
154
+ **Partnership play.** The winner's partner loses nothing; only the opposing team does. A team shares one score.
155
+
156
+ ### Classic actions
157
+
158
+ | Action | Fields | Effect |
159
+ |---|---|---|
160
+ | `draw` | `player`, `from: 'pile' \| 'discard'` | Draw from the pile or take the previous player's discard |
161
+ | `discard` | `player`, `tile` | Discard and end the turn |
162
+ | `finish` | `player`, `tile` | Go out: discard `tile` and show the other fourteen |
163
+ | `showIndicator` | `player` | Show the indicator's twin; allowed out of turn, before your first move |
164
+ | `pass` | `player` | Only when the pile is empty: decline the last discard, ending the hand |
165
+ | `nextHand` | | Deal the next hand after one has ended |
166
+
167
+ You never send an arrangement with `finish`. The engine works out whether the fourteen tiles form a winning hand, and counts it as pairs when both readings are possible, since pairs score more.
168
+
169
+ ### Classic options
170
+
171
+ | Option | Default | Meaning |
172
+ |---|---|---|
173
+ | `players` | `4` | 2, 3 or 4 |
174
+ | `teams` | `false` | Partnership play |
175
+ | `seed` | `0` | Number or string; drives the shuffle and the first dealer |
176
+ | `scoring` | `'deduct'` | `'deduct'`: losers lose points and the highest score wins. `'countdown'` (düşme): the winner subtracts the points instead, and the first side to reach 0 wins |
177
+ | `startingScore` | `20` | |
178
+ | `winPoints` | `2` | Points for an ordinary win |
179
+ | `okeyFinishMultiplier` | `2` | Applied for an okey finish |
180
+ | `pairsFinishMultiplier` | `2` | Applied for seven pairs; stacks with the okey finish |
181
+ | `singleColorMultiplier` | `1` | Set to 2 to double a winning hand of a single colour |
182
+ | `allowPairs` | `true` | Whether seven pairs is a winning hand |
183
+ | `indicatorPoints` | `1` | Points for showing the indicator; 0 disables showing |
184
+ | `indicatorDrawable` | `false` | Makes the indicator the last tile of the pile |
185
+ | `maxHands` | `null` | End the match after this many hands |
186
+ | `firstDealer` | from seed | Fix the first dealer |
187
+ | `deck` | shuffled | Fix the whole tile order of the first hand (tests, replays) |
188
+
189
+ ## Okey 101
190
+
191
+ ### Okey 101 rules
192
+
193
+ **Deal.** Every player gets 21 tiles and the starter gets 22. With four players that leaves 20 tiles in the draw pile.
194
+
195
+ **Turns.** The starter begins without drawing. Each turn is:
196
+
197
+ 1. Draw from the pile, or take the previous player's discard.
198
+ 2. Optionally lay tiles on the table: open, lay further melds, lay off, or swap an okey.
199
+ 3. Discard one tile.
200
+
201
+ **Opening (el açmak).** A player's first melds must meet the opening barrier in a single turn:
202
+
203
+ - **Runs and groups** worth at least **101 points**. Each tile counts its number, and an okey counts as the tile it stands for.
204
+ - **Pairs:** at least **five pairs**.
205
+
206
+ A player is either a runs player or a pairs player for the whole hand. After opening:
207
+
208
+ | | Runs opener | Pairs opener |
209
+ |---|---|---|
210
+ | Lay new runs and groups | yes | no |
211
+ | Lay new pairs | only once some player has opened with pairs | yes |
212
+ | Lay off on runs and groups | yes | yes |
213
+
214
+ If every player opens with pairs, nobody can go out; the hand is void and dealt again by the same dealer.
215
+
216
+ **Taking the discard (yandan taş almak).** A taken discard cannot be kept in the rack. It must reach the table in the same turn, and for a player who has not opened it must be part of their opening. Holding an identical tile counts: either copy may be laid. A player who cannot use the tile puts it back and draws from the pile instead.
217
+
218
+ **Laying off (işlemek).** A player who has opened may add tiles to any run or group on the table, their own or anyone else's: to either end of a run, or as the fourth colour of a group. Pairs cannot be extended, and melds cannot be split or rearranged. Laying off is allowed in the same turn as opening.
219
+
220
+ **Taking an okey from the table (yerden okey almak).** A player who has opened may replace an okey on the table with the real tile it stands for, and take the okey. This works only where the okey's identity is certain: in any run, and in a group once it has four tiles. An okey in a group of three could be either missing colour, so it cannot be taken, and neither can an okey in a pair.
221
+
222
+ **Penalties (ceza).** Each costs 101 points, added to the player's score for the hand:
223
+
224
+ - Discarding a tile that fits a meld on the table (işlek taş). This applies even to a player who has not opened.
225
+ - Discarding an okey.
226
+
227
+ The discard that goes out is never penalised.
228
+
229
+ **Going out.** Discard your last tile after laying all the others. You must always keep one tile to discard.
230
+
231
+ **From hand (elden bitme).** A player who has not opened and lays every tile but one in a single turn goes out "from hand". The 101 barrier does not apply, and the win is doubled. By default this needs that nobody else has opened yet.
232
+
233
+ **Scoring a hand.** Lower is better.
234
+
235
+ | Player | Score for the hand |
236
+ |---|---|
237
+ | Winner | −101 |
238
+ | Opened with runs and groups | Sum of the tiles left in hand |
239
+ | Opened with pairs | Twice the sum of the tiles left in hand |
240
+ | Never opened | 202 |
241
+
242
+ An okey left in an opened hand counts 101 instead of its number. A false joker counts as the okey's number. Penalties are added on top.
243
+
244
+ **Multipliers.** Each of these doubles every score in the table above, and they stack:
245
+
246
+ | Finish | Winner | Unopened player |
247
+ |---|---|---|
248
+ | Ordinary | −101 | 202 |
249
+ | Final discard is an okey | −202 | 404 |
250
+ | Winner had opened with pairs | −202 | 404 |
251
+ | From hand | −202 | 404 |
252
+ | Any two of the above | −404 | 808 |
253
+
254
+ Penalties are not multiplied.
255
+
256
+ **When the pile runs out.** The player to move may take the last discard if they can use it, or pass. On a pass the hand ends with no winner: opened players count their tiles, unopened players get 202.
257
+
258
+ **Katlamalı (rising barrier).** In this variant each opening in a hand must beat the previous one: after an opening of 110 the next player needs 111, and after five pairs the next pairs opener needs six. The points barrier and the pairs barrier rise separately and both reset every hand. Scores are not multiplied by this variant. A finish from hand ignores the barrier and does not raise it.
259
+
260
+ **Partnership play.** Partners open separately and may lay off on each other's melds like anyone else's. Scores are kept per player and the partners' totals are added. The winner's partner scores 0 for the hand, apart from any penalties.
261
+
262
+ **End of the match.** A match lasts a fixed number of hands. The side with the lowest total wins.
263
+
264
+ ### Okey 101 actions
265
+
266
+ | Action | Fields | Effect |
267
+ |---|---|---|
268
+ | `draw` | `player`, `from: 'pile' \| 'discard'` | Draw from the pile or take the previous player's discard |
269
+ | `returnDiscard` | `player` | Put a taken discard back and draw from the pile instead |
270
+ | `meld` | `player`, `melds: TileId[][]` | Lay melds from the rack. For a player who has not opened, this is the opening |
271
+ | `layOff` | `player`, `tile`, `meld` (id) | Add a tile to a run or group on the table |
272
+ | `swapOkey` | `player`, `tile`, `meld` (id) | Replace an okey on the table with `tile` and take the okey |
273
+ | `discard` | `player`, `tile` | Discard and end the turn; discarding the last tile goes out |
274
+ | `pass` | `player` | Only when the pile is empty: decline the last discard, ending the hand |
275
+ | `nextHand` | | Deal the next hand after one has ended |
276
+
277
+ An unopened player's `meld` is read as a pairs opening when every meld has two tiles, and as a runs opening otherwise. Melds on the table are in `state.table`, each with an `id`, an `owner`, its `tiles` and the `faces` they stand for.
278
+
279
+ ```ts
280
+ import { okey101 } from 'okey-game-engine-ts'
281
+
282
+ let state = okey101.createGame({ seed: 7, teams: true, escalation: 'all', hands: 7 })
283
+ const me = state.turn
284
+
285
+ // Ask the solver for the best opening and lay it.
286
+ const { points } = okey101.requiredOpening(state, me)
287
+ const best = okey101.findBestOpening(state.racks[me], state.okey, points)
288
+ if (best.opens) {
289
+ const result = okey101.apply(state, {
290
+ type: 'meld',
291
+ player: me,
292
+ melds: best.arrangement.melds.map((meld) => meld.tiles),
293
+ })
294
+ if (result.ok) state = result.state
295
+ }
296
+
297
+ // Lay off whatever fits the table.
298
+ for (const { tile, meld } of okey101.findLayoffs(state.racks[me], state.table, state.okey)) {
299
+ const result = okey101.apply(state, { type: 'layOff', player: me, tile, meld })
300
+ if (result.ok) state = result.state
301
+ }
302
+ ```
303
+
304
+ ### Okey 101 options
305
+
306
+ | Option | Default | Meaning |
307
+ |---|---|---|
308
+ | `players` | `4` | 2, 3 or 4 |
309
+ | `teams` | `false` | Partnership play |
310
+ | `seed` | `0` | Number or string |
311
+ | `hands` | `5` | Scored hands in the match |
312
+ | `escalation` | `'off'` | Katlamalı. `'off'`: fixed barrier (katlamasız). `'all'`: every earlier opening raises your barrier. `'opponents'`: only the other team's openings do |
313
+ | `openingThreshold` | `101` | Points needed to open with runs and groups |
314
+ | `minPairs` | `5` | Pairs needed to open with pairs |
315
+ | `okeySwap` | `true` | Whether okeys may be taken from the table |
316
+ | `pairsAfterPairsOpener` | `true` | Whether runs openers may lay pairs once someone has opened with pairs |
317
+ | `eldenNeedsClosedTable` | `true` | Whether going out from hand needs that nobody else has opened |
318
+ | `penaltyPoints` | `101` | Size of an in-play penalty |
319
+ | `penalizeLayableDiscard` | `true` | Penalise discarding a tile that fits the table |
320
+ | `penalizeOkeyDiscard` | `true` | Penalise discarding an okey |
321
+ | `penalizeInvalidOpening` | `false` | `false`: an invalid opening is refused with an error. `true`: it is accepted as a move that costs a penalty and lays nothing |
322
+ | `penalizeReturnedDiscard` | `false` | Penalise taking the discard and putting it back |
323
+ | `takenDiscardFactor` | `0` | Live-table rule: when your discard is taken and used, you pay the tile's number times this factor. Tables that play it use 10 |
324
+ | `winBonus` | `101` | What the winner scores, negated |
325
+ | `unopenedPenalty` | `202` | Hand score of a player who never opened |
326
+ | `okeyInHandPoints` | `101` | What an okey left in an opened hand counts |
327
+ | `pileExhausted` | `'score'` | `'score'`: hands are scored with no winner. `'void'`: the hand is replayed and only okeys in hand and in-play penalties count |
328
+ | `firstDealer` | from seed | Fix the first dealer |
329
+ | `deck` | shuffled | Fix the whole tile order of the first hand |
330
+
331
+ ## Running a table
332
+
333
+ Both games expose the same functions, shown here for classic Okey.
334
+
335
+ ```ts
336
+ import { classic } from 'okey-game-engine-ts'
337
+
338
+ // Server: keep the full state, send each player only what they may see.
339
+ const result = classic.apply(state, action)
340
+ if (result.ok) {
341
+ state = result.state
342
+ for (const seat of [0, 1, 2, 3]) {
343
+ send(seat, {
344
+ view: classic.getPlayerView(state, seat),
345
+ events: classic.redactEvents(result.events, seat),
346
+ })
347
+ }
348
+ }
349
+ ```
350
+
351
+ | Function | Purpose |
352
+ |---|---|
353
+ | `createGame(options)` | Create a match and deal the first hand |
354
+ | `apply(state, action)` | Apply an action; returns the new state and events, or an error |
355
+ | `getPlayerView(state, seat)` | The state as one player may see it: other racks and the pile become counts |
356
+ | `redactEvents(events, seat)` | Hide the tiles other players drew from the pile |
357
+ | `getAllowedActions(state, seat)` | What the player may do right now, as booleans |
358
+ | `getTimeoutAction(state)` | A safe default move for the player whose turn it is |
359
+ | `replay(options, actions)` | Rebuild a state from an action log |
360
+ | `sideOf(state, seat)` | The side a seat scores for |
361
+
362
+ **State.** `state.phase` is `'playing'`, `'handOver'` or `'matchOver'`. The state is plain JSON: store it with `JSON.stringify` and load it with `JSON.parse`. `state.result` describes the last finished hand and `state.winners` lists the winning sides once the match is over; a tie lists more than one.
363
+
364
+ **Starting the next hand.** The engine does not decide when the next hand begins. Send `{ type: 'nextHand' }` when your table is ready.
365
+
366
+ **Time limits and absent players.** The engine has no clock. Keep the timer yourself, and when it runs out apply `getTimeoutAction(state)`: it draws from the pile and discards without giving away an okey or, in Okey 101, a penalised tile.
367
+
368
+ **Events.** Each successful action returns events for animation and for broadcasting: `handStarted`, `tileDrawn`, `tileDiscarded`, `handEnded`, `matchEnded`, plus `indicatorShown` in classic Okey and `opened`, `meldsLaid`, `tileLaidOff`, `okeySwapped`, `discardReturned` and `penalty` in Okey 101.
369
+
370
+ **Determinism.** Pass `seed` to reproduce a match, or `deck` to fix the exact tile order of the first hand: the indicator first, then the starter's rack, then each following seat's rack, then the pile in drawing order.
371
+
372
+ ## Rack helpers
373
+
374
+ These need only a list of tiles and the okey, so a client can run them on its own rack without the full state.
375
+
376
+ ```ts
377
+ import {
378
+ arrangePairs,
379
+ arrangeRuns,
380
+ formatTiles,
381
+ layoutRack,
382
+ parseFace,
383
+ parseTiles,
384
+ } from 'okey-game-engine-ts'
385
+
386
+ const okey = parseFace('k9') // black 9
387
+ const rack = parseTiles('r1 r2 r3 y5 b5 k5 b7 b8 k9 y1 r12')
388
+
389
+ const runs = arrangeRuns(rack, okey) // seri dizim: most tiles in runs and groups
390
+ runs.melds.map((m) => formatTiles(m.tiles)) // ['r1 r2 r3', 'y5 b5 k5', 'b7 b8 k9']
391
+ formatTiles(runs.leftover) // 'y1 r12'
392
+
393
+ arrangeRuns(rack, okey, { objective: 'points', highOne: false }) // Okey 101: most points
394
+ arrangePairs(rack, okey) // çift dizim: most pairs
395
+
396
+ layoutRack(runs, { rows: 2, columns: 13 }) // (TileId | null)[][] for a two-row rack
397
+ ```
398
+
399
+ | Helper | Purpose |
400
+ |---|---|
401
+ | `arrangeRuns(tiles, okey, options?)` | Best split into runs and groups. `objective: 'tiles'` (default) or `'points'`; `highOne: false` for Okey 101. The result is optimal, not a heuristic |
402
+ | `arrangePairs(tiles, okey)` | Most pairs; okeys go to the highest unpaired tiles |
403
+ | `layoutRack(arrangement, { rows, columns })` | Places melds as blocks on a rack with gaps between them |
404
+ | `sortByColor(tiles, okey?)`, `sortByNumber(tiles, okey?)` | Simple sorts; with `okey`, okeys go last |
405
+ | `analyzeMeld(tiles, okey, options?)` | Validate one meld and learn what each okey stands for; `null` if invalid |
406
+ | `extendMeld(meld, tile, okey, rules?)` | Add a tile to a run or group; `null` if it does not fit |
407
+ | `classic.checkWinningHand(tiles, okey)` | Whether 14 tiles win, and how |
408
+ | `classic.findWinningDiscards(tiles, okey)` | From 15 tiles, every discard that goes out, best first |
409
+ | `classic.suggestDiscard(tiles, okey)` | A reasonable tile to throw away |
410
+ | `okey101.findBestOpening(tiles, okey, required?)` | Highest-scoring runs and groups, and whether they open |
411
+ | `okey101.findPairsOpening(tiles, okey, required?)` | Most pairs, and whether they open |
412
+ | `okey101.requiredOpening(state, seat)` | The barrier right now, given the rising-barrier rule |
413
+ | `okey101.findLayoffs(tiles, table, okey)` | Tiles that fit melds on the table |
414
+ | `okey101.findOkeySwaps(tiles, table, okey)` | Okeys on the table that a tile in hand can replace |
415
+ | `okey101.isLayable(tile, table, okey)` | Whether discarding this tile would be penalised |
416
+ | `okey101.handPenalty(tiles, okey)` | What the tiles left in hand count |
417
+ | `okey101.sideTotals(state)` | Running totals per side |
418
+
419
+ ## Errors
420
+
421
+ Breaking a rule never throws. `apply` returns `{ ok: false, error: { code, message } }` and leaves the state untouched. Switch on `code`; `message` is English text for debugging.
422
+
423
+ | Code | Meaning |
424
+ |---|---|
425
+ | `NOT_YOUR_TURN`, `WRONG_STEP` | Out of turn, or drawing and discarding in the wrong order |
426
+ | `INVALID_PLAYER`, `UNKNOWN_ACTION` | No such seat or action |
427
+ | `HAND_OVER`, `HAND_IN_PROGRESS`, `MATCH_OVER` | The action does not fit the current phase |
428
+ | `TILE_NOT_IN_RACK`, `DUPLICATE_TILE` | A tile is not held, or is used twice |
429
+ | `PILE_EMPTY`, `PILE_NOT_EMPTY`, `NO_DISCARD` | Nothing to draw, a pass while tiles remain, or nothing to take |
430
+ | `NOT_A_WINNING_HAND` | Classic: the tiles do not go out |
431
+ | `INDICATOR_NOT_HELD`, `INDICATOR_TOO_LATE`, `INDICATOR_DISABLED` | Classic: showing the indicator is not possible |
432
+ | `INVALID_MELD`, `OPENING_TOO_LOW`, `TOO_FEW_PAIRS` | 101: the melds are invalid or do not meet the barrier |
433
+ | `NOT_OPENED` | 101: laying off or swapping before opening |
434
+ | `MUST_USE_TAKEN_TILE` | 101: the taken discard has not reached the table |
435
+ | `INVALID_LAYOFF`, `INVALID_SWAP`, `UNKNOWN_MELD` | 101: the tile does not fit, or there is no such meld |
436
+ | `NOT_ALLOWED` | The move is ruled out here, for example laying your last tile |
437
+
438
+ Only programming mistakes throw: invalid options, a malformed `deck`, an invalid tile id, or an illegal action inside `replay`.
439
+
440
+ ## Rule sources and limits
441
+
442
+ The rules follow pagat.com, Wikipedia and the published rule pages of online okey platforms. Where sources disagree, the most common behaviour is the default and the alternative is an option. The in-app rules of the largest mobile titles are not published, so check the options against the platform you want to match.
443
+
444
+ Not supported:
445
+
446
+ - Bots and strategy. The engine offers solvers and a safe timeout move only.
447
+ - Chips, pots (çanak) and rankings.
448
+ - Elimination play, where players at zero leave the table.
449
+ - Rarely documented house rules: the double-okey finish, pairing with the indicator, coloured scoring (renkli okey), riziko, and a per-turn limit on lay-offs.
450
+
451
+ ## License
452
+
453
+ MIT
@@ -0,0 +1,67 @@
1
+ import { resolveTile, COLORS, tileFace } from './chunk-HO2MPJ4Q.js';
2
+
3
+ // src/core/layout.ts
4
+ function sortKey(id, okey, byNumber) {
5
+ let color = 4;
6
+ let number = 14;
7
+ if (okey) {
8
+ const r = resolveTile(id, okey);
9
+ if (r.wild) return 1e4 + id;
10
+ color = COLORS.indexOf(r.color);
11
+ number = r.number;
12
+ } else {
13
+ const face = tileFace(id);
14
+ if (face.kind === "number") {
15
+ color = COLORS.indexOf(face.color);
16
+ number = face.number;
17
+ }
18
+ }
19
+ return (byNumber ? number * 10 + color : color * 100 + number) * 10 + id % 2;
20
+ }
21
+ function sortByColor(tiles, okey) {
22
+ return tiles.slice().sort((a, b) => sortKey(a, okey, false) - sortKey(b, okey, false));
23
+ }
24
+ function sortByNumber(tiles, okey) {
25
+ return tiles.slice().sort((a, b) => sortKey(a, okey, true) - sortKey(b, okey, true));
26
+ }
27
+ function layoutRack(arrangement, options = {}) {
28
+ const rows = options.rows ?? 2;
29
+ const columns = options.columns ?? 16;
30
+ const blocks = Array.isArray(arrangement) ? arrangement.filter((b) => b.length > 0) : [
31
+ ...arrangement.melds.map((m) => m.tiles),
32
+ ...arrangement.leftover.length > 0 ? [arrangement.leftover] : []
33
+ ];
34
+ const total = blocks.reduce((sum, b) => sum + b.length, 0);
35
+ if (total > rows * columns) {
36
+ throw new RangeError(`${total} tiles do not fit on a ${rows}x${columns} rack`);
37
+ }
38
+ const spaced = placeBlocks(blocks, rows, columns);
39
+ if (spaced) return spaced;
40
+ const flat = blocks.flat();
41
+ return Array.from(
42
+ { length: rows },
43
+ (_, row) => Array.from({ length: columns }, (_2, column) => flat[row * columns + column] ?? null)
44
+ );
45
+ }
46
+ function placeBlocks(blocks, rows, columns) {
47
+ const layout = Array.from(
48
+ { length: rows },
49
+ () => Array.from({ length: columns }, () => null)
50
+ );
51
+ let row = 0;
52
+ let column = 0;
53
+ for (const block of blocks) {
54
+ if (block.length > columns) return null;
55
+ if (column > 0) column++;
56
+ if (column + block.length > columns) {
57
+ row++;
58
+ column = 0;
59
+ }
60
+ const slots = layout[row];
61
+ if (!slots) return null;
62
+ for (const id of block) slots[column++] = id;
63
+ }
64
+ return layout;
65
+ }
66
+
67
+ export { layoutRack, sortByColor, sortByNumber };