open-optcg 0.1.1-next.1 → 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 +245 -0
- package/README.md +3 -0
- package/dist/cards.js +1 -1
- package/dist/chunks/{chunk-4I54H2QI.js → chunk-CRP6FDKG.js} +1 -1
- package/dist/decks.js +1 -1
- package/dist/engine.js +1 -1
- package/dist/types/cards.d.ts +1 -1
- package/dist/types/engine.d.ts +1 -1
- package/dist/types/internal/cards/src/index.d.ts +1 -0
- package/dist/types/internal/engine-core/src/errors.d.ts +2 -2
- package/dist/types/internal/engine-core/src/index.d.ts +1 -1
- package/dist/types/internal/engine-core/src/service.d.ts +11 -0
- package/package.json +3 -2
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,
|
|
1
|
+
import{cardFormatLegality,cardRegistry,isStandardLegal,loadPrintedText,parsePrintedTextPack,printedText,registerCards,requireKnownCard}from"./chunks/chunk-CRP6FDKG.js";export{cardFormatLegality,cardRegistry,isStandardLegal,loadPrintedText,parsePrintedTextPack,printedText,registerCards,requireKnownCard};
|