open-optcg 0.1.0 → 0.2.0-next.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/INTEGRATING.md ADDED
@@ -0,0 +1,245 @@
1
+ # Putting open-optcg behind an existing app
2
+
3
+ This guide is for an app that already has a board UI, a game server and its own
4
+ lobby, and wants `open-optcg` to own the rules. Your UI, accounts, matchmaking,
5
+ clocks and chat stay as they are. The engine replaces the code that decides
6
+ what is legal and what happens next.
7
+
8
+ ## How the engine sees a game
9
+
10
+ - The engine stops at every decision. The seat to act gets a **view**
11
+ (`GameObservation`): its own hand, both boards, Life counts, and
12
+ `legalActions`, every move it may make right now.
13
+ - A move is one of those actions, sent back by its opaque `actionId` together
14
+ with the view's `revision`.
15
+ - Nothing else advances the game. There is no timer and no background step, so
16
+ the same seed and the same `actionId`s always produce the same game.
17
+
18
+ ## On the server
19
+
20
+ Your server holds the games. Each client only ever receives its own view.
21
+
22
+ ```ts
23
+ import { EngineError, GameService, type SavedGame } from "open-optcg/engine";
24
+ import { resolveDeck, parseDeckList } from "open-optcg/decks";
25
+ import type { GameObservation, PlayerId } from "open-optcg/protocol";
26
+
27
+ const service = new GameService();
28
+
29
+ // `send` is your transport: a WebSocket per player, a queue, anything.
30
+ type Send = (seat: PlayerId, view: GameObservation) => void;
31
+
32
+ export function startGame(listP1: string, listP2: string, send: Send) {
33
+ const deck = (list: string, id: string) => {
34
+ const parsed = parseDeckList(list);
35
+ return resolveDeck(id, { id, leader: parsed.leader, cards: parsed.cards });
36
+ };
37
+ const view = service.create({
38
+ p1: deck(listP1, "p1-deck"),
39
+ p2: deck(listP2, "p2-deck"),
40
+ seed: crypto.randomUUID(),
41
+ startingPlayer: "random",
42
+ });
43
+ broadcast(view.gameId, send);
44
+ return view.gameId;
45
+ }
46
+
47
+ // A client's move: the actionId it picked and the revision it saw.
48
+ export function onMove(
49
+ gameId: string,
50
+ seat: PlayerId,
51
+ move: { actionId: string; revision: number },
52
+ send: Send,
53
+ ) {
54
+ const current = service.observe(gameId, seat);
55
+ if (current.activePlayer !== seat) return; // not this player's decision
56
+ try {
57
+ service.act(gameId, move.revision, move.actionId);
58
+ } catch (error) {
59
+ // STALE_ACTION: the client acted on an old view, so resend the fresh one.
60
+ // ILLEGAL_ACTION: not an offered move. GAME_FINISHED: the game is over.
61
+ if (error instanceof EngineError) return send(seat, current);
62
+ throw error;
63
+ }
64
+ broadcast(gameId, send);
65
+ }
66
+
67
+ function broadcast(gameId: string, send: Send) {
68
+ for (const seat of ["p1", "p2"] as const) {
69
+ send(seat, service.observe(gameId, seat));
70
+ }
71
+ }
72
+
73
+ // Checkpoints: store `save` as JSON; `restore` resumes it after a restart.
74
+ export const checkpoint = (gameId: string): string =>
75
+ JSON.stringify(service.save(gameId));
76
+ export const resume = (json: string) =>
77
+ service.restore(JSON.parse(json) as SavedGame);
78
+ ```
79
+
80
+ Each player's view withholds what that seat can't see: the opponent's hand,
81
+ face-down Life, and deck order. Only the seat to act gets a non-empty
82
+ `legalActions`.
83
+
84
+ `restore` refuses a save made by a different engine version or with different
85
+ card data, with `INCOMPATIBLE_SAVE`. Finish or abandon those games before you
86
+ upgrade.
87
+
88
+ ## On the client: clicks to actions
89
+
90
+ Your board already renders cards by id. Each `LegalAction` names the card
91
+ instances it involves, so clickability comes straight from the list:
92
+
93
+ | Action type | First click | Second click |
94
+ | ------------------------ | ----------------------------------- | ------------------ |
95
+ | `play-card` | `cardInstanceId` (the card in hand) | |
96
+ | `attach-don` | `targetInstanceId` (who gets DON!!) | |
97
+ | `attack` | `attackerInstanceId` | `targetInstanceId` |
98
+ | `block` | `blockerInstanceId` | |
99
+ | `counter` | `cardInstanceId` (the Counter card) | |
100
+ | `activate-counter-event` | `eventInstanceId` | |
101
+ | `activate-main` | `sourceInstanceId` | |
102
+
103
+ Everything else is a button: `pass`, keep or mulligan (`mulligan-decision`),
104
+ effect choices (`resolve-effect`) and Trigger choices (`resolve-trigger`).
105
+ Every action has a `summary` you can use as its label, so a button can carry
106
+ its `actionId` directly.
107
+
108
+ ```ts
109
+ import type { GameObservation, LegalAction } from "open-optcg/protocol";
110
+
111
+ /** The card ids a click on the board can start an action from. */
112
+ export function firstClick(action: LegalAction): string | undefined {
113
+ switch (action.type) {
114
+ case "play-card":
115
+ case "counter":
116
+ return action.cardInstanceId;
117
+ case "attach-don":
118
+ return action.targetInstanceId;
119
+ case "attack":
120
+ return action.attackerInstanceId;
121
+ case "block":
122
+ return action.blockerInstanceId;
123
+ case "activate-counter-event":
124
+ return action.eventInstanceId;
125
+ case "activate-main":
126
+ return action.sourceInstanceId;
127
+ default:
128
+ return undefined;
129
+ }
130
+ }
131
+
132
+ /** Which cards to highlight as clickable. */
133
+ export const selectable = (view: GameObservation): Set<string> =>
134
+ new Set(view.legalActions.map(firstClick).filter((id) => id !== undefined));
135
+
136
+ /** Moves that are buttons rather than cards. */
137
+ export const buttons = (view: GameObservation): LegalAction[] =>
138
+ view.legalActions.filter((action) => firstClick(action) === undefined);
139
+
140
+ /**
141
+ * A click on the board. Returns the action to send, or the cards to highlight
142
+ * for the second click when the first click starts more than one action --
143
+ * an attacker with several targets, a card with two activations.
144
+ */
145
+ export function click(
146
+ view: GameObservation,
147
+ cardId: string,
148
+ armed: LegalAction[] | undefined,
149
+ ): { send: LegalAction } | { arm: LegalAction[]; highlight: string[] } | null {
150
+ if (armed) {
151
+ const chosen = armed.find(
152
+ (action) =>
153
+ action.type === "attack" && action.targetInstanceId === cardId,
154
+ );
155
+ return chosen ? { send: chosen } : null;
156
+ }
157
+ const started = view.legalActions.filter(
158
+ (action) => firstClick(action) === cardId,
159
+ );
160
+ if (started.length === 0) return null;
161
+ if (started.length === 1) return { send: started[0]! };
162
+ const targets = started.flatMap((action) =>
163
+ action.type === "attack" ? [action.targetInstanceId] : [],
164
+ );
165
+ // Several non-attack actions on one card: show their summaries as a menu.
166
+ return { arm: started, highlight: targets };
167
+ }
168
+ ```
169
+
170
+ Some effects ask for an arrangement, such as putting cards on the top or bottom
171
+ of the deck in an order. The engine offers that as one `resolve-effect` action
172
+ whose option has `kind: "order"`. The player may submit any arrangement of the
173
+ offered cards. No card the engine supports asks a player to type a card's name.
174
+
175
+ For a battle panel, `view.activeBattle` holds the attack in progress. The
176
+ `counter` actions carry `attackPower`, `targetPower` and `counterShortfall`, so
177
+ the panel can show what each Counter card would add.
178
+
179
+ ## The game log
180
+
181
+ `view.publicEvents` lists what happened, in order, as structured events with
182
+ card numbers and instance ids, such as `{ type: "don-attached", playerId,
183
+ targetInstanceId, count }`. Render them in your own words and languages. The
184
+ engine never gives you prose to parse.
185
+
186
+ ## A computer opponent
187
+
188
+ The learned opponent is a separate package, `open-optcg-model`. It reads the
189
+ same view a player gets, so it plays fair, and its whole interface is one call.
190
+ Run it on the server, or in a Web Worker so the page stays responsive:
191
+
192
+ ```ts
193
+ import { loadModel, type LearnedModel } from "open-optcg-model";
194
+ import type { GameObservation } from "open-optcg/protocol";
195
+
196
+ let model: Promise<LearnedModel> | undefined;
197
+
198
+ /** The CPU's move for the seat whose view this is. */
199
+ export async function cpuMove(
200
+ view: GameObservation,
201
+ deck: { leader: string; cards: Record<string, number> },
202
+ ): Promise<string> {
203
+ model ??= loadModel("/models/scorer-model.json");
204
+ const agent = (await model).agentFor(deck);
205
+ return (await agent.chooseAction(view)).actionId;
206
+ }
207
+ ```
208
+
209
+ Host the weights (`scorer-model.json` from a `model-*` release on GitHub)
210
+ yourself, because GitHub doesn't serve release files to browsers from other
211
+ sites.
212
+
213
+ ## Cards the engine doesn't ship yet
214
+
215
+ Card numbers are Bandai's, so your images and deck lists map one to one. For a
216
+ card the engine doesn't have, call `registerCards` once at startup, before the
217
+ first game:
218
+
219
+ ```ts
220
+ import { registerCards } from "open-optcg/cards";
221
+
222
+ registerCards([
223
+ {
224
+ cardNumber: "HOST-001",
225
+ name: "Training Dummy",
226
+ category: "character",
227
+ cost: 1,
228
+ power: 3000,
229
+ counter: 1000,
230
+ colors: ["Red"],
231
+ types: ["Training"],
232
+ attribute: "Strike",
233
+ effect: "[Blocker]",
234
+ trigger: "",
235
+ },
236
+ ]);
237
+ ```
238
+
239
+ A registered card plays its stats and the keywords derived from its text, such
240
+ as `[Blocker]` or `[Rush]`. Its other effects don't run until the engine
241
+ supports that card. Card behaviour is engine code, written and tested clause by
242
+ clause. A card number the engine already ships is refused rather than replaced.
243
+
244
+ Contributions are welcome, and that includes new cards once they are
245
+ officially released.
package/README.md CHANGED
@@ -38,6 +38,9 @@ console.log(`${view.winner} wins on turn ${view.turn} (${view.resultReason})`);
38
38
  To play against the learned opponent, see
39
39
  [`open-optcg-model`](https://www.npmjs.com/package/open-optcg-model).
40
40
 
41
+ To put the engine behind an app that already has its own board UI and game
42
+ server, see [INTEGRATING.md](./INTEGRATING.md).
43
+
41
44
  ## Entry points
42
45
 
43
46
  | Import | What it is |
package/dist/cards.js CHANGED
@@ -1 +1 @@
1
- import{cardFormatLegality,cardRegistry,createCardDatabase,isStandardLegal,loadPrintedText,parsePrintedTextPack,printedText,requireKnownCard}from"./chunks/chunk-4I54H2QI.js";export{cardFormatLegality,cardRegistry,createCardDatabase,isStandardLegal,loadPrintedText,parsePrintedTextPack,printedText,requireKnownCard};
1
+ import{cardFormatLegality,cardRegistry,isStandardLegal,loadPrintedText,parsePrintedTextPack,printedText,registerCards,requireKnownCard}from"./chunks/chunk-CRP6FDKG.js";export{cardFormatLegality,cardRegistry,isStandardLegal,loadPrintedText,parsePrintedTextPack,printedText,registerCards,requireKnownCard};