bga-dev-skill 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.
@@ -0,0 +1,321 @@
1
+ # BGA Notification Patterns
2
+
3
+ This skill is derived from real code in the rage project. Every example is
4
+ pulled directly from working PHP and JS — not documentation speculation.
5
+
6
+ ---
7
+
8
+ ## The Contract That Must Match Exactly
9
+
10
+ A notification is a named data packet PHP sends to all clients (or one client).
11
+ The name on the PHP side and the handler method name on the JS side must match
12
+ by a specific convention. A mismatch produces no error — the notification is
13
+ silently dropped and your UI does not update.
14
+
15
+ ### PHP side — modern framework
16
+
17
+ ```php
18
+ // $this->notify->all(type, message, data)
19
+ $this->notify->all("trump_change", clienttranslate('Trump is now ${trump_html}'), [
20
+ 'trump' => $trump, // the card object
21
+ 'trump_html' => $html, // pre-rendered HTML for the log message
22
+ ]);
23
+
24
+ // $this->notify->player(playerId, type, message, data)
25
+ $this->notify->player($playerId, "dealtCards", '', [
26
+ 'cards' => $cards,
27
+ ]);
28
+ ```
29
+
30
+ ### JS side — modern framework (ES module)
31
+
32
+ ```javascript
33
+ // Method name = "notif_" + type — must match exactly, including case
34
+ async notif_trump_change(args) {
35
+ this.gamedatas.trump = args.trump; // args.trump, not args.card
36
+ this.renderTrump(args.trump);
37
+ // args.trump_html exists but is only used in the log — JS doesn't need it
38
+ }
39
+
40
+ async notif_dealtCards(args) {
41
+ this.gamedatas.hand = args.cards; // args.cards, matches PHP 'cards' key
42
+ this.renderHand();
43
+ }
44
+ ```
45
+
46
+ ### The naming rules
47
+
48
+ | PHP type string | JS handler name |
49
+ |---|---|
50
+ | `"trump_change"` | `notif_trump_change` |
51
+ | `"dealtCards"` | `notif_dealtCards` |
52
+ | `"actionBid"` | `notif_actionBid` |
53
+ | `"winningCardNotif"` | `notif_winningCardNotif` |
54
+ | `"scoringNotif"` | `notif_scoringNotif` |
55
+
56
+ The prefix is always `notif_`. After that, the string is copied verbatim —
57
+ `trump_change` stays `trump_change`, not `trumpChange`.
58
+
59
+ ---
60
+
61
+ ## The Casing Gotcha (Most Common Silent Bug)
62
+
63
+ PHP array keys are sent as-is to JS. `notif.args` keys must match what PHP
64
+ put in the data array — character for character.
65
+
66
+ ```php
67
+ // PHP sends snake_case
68
+ $this->notify->all("winningCardNotif", '...', [
69
+ 'player_id' => $pid, // snake_case
70
+ 'plus5' => $plus5, // no underscore
71
+ 'minus5' => $minus5,
72
+ ]);
73
+ ```
74
+
75
+ ```javascript
76
+ // JS must read the same keys
77
+ async notif_winningCardNotif(args) {
78
+ const pid = args.player_id; // ✅ snake_case matches
79
+ this.gamedatas.plus5 = args.plus5;
80
+ this.gamedatas.minus5 = args.minus5;
81
+ // args.playerId would silently be undefined
82
+ }
83
+ ```
84
+
85
+ Never camelCase a key on the JS side that PHP sent as snake_case. There is no
86
+ error. The value will be undefined and your handler will silently misrender.
87
+
88
+ ---
89
+
90
+ ## Data Shapes from rage (Reference)
91
+
92
+ These are the exact payloads for each notification type in rage.
93
+ When generating tests or writing new notifications, use these as the canonical
94
+ shape.
95
+
96
+ ### `trump_change`
97
+ ```php
98
+ // PHP
99
+ $this->notify->all("trump_change", clienttranslate('Trump is now ${trump_html}'), [
100
+ 'trump' => $trump, // full card row from DB: {id, type, type_arg, location, location_arg}
101
+ 'trump_html' => $html, // pre-rendered HTML string for log only
102
+ ]);
103
+ ```
104
+ ```javascript
105
+ // JS — only trump is used; trump_html is consumed by the log renderer automatically
106
+ async notif_trump_change(args) {
107
+ this.gamedatas.trump = args.trump;
108
+ this.renderTrump(args.trump);
109
+ }
110
+ ```
111
+
112
+ ### `dealtCards`
113
+ ```php
114
+ // PHP — sent only to the receiving player (notify->player, not ->all)
115
+ $this->notify->player($playerId, "dealtCards", '', [
116
+ 'cards' => $this->cards->getPlayerHand($playerId),
117
+ ]);
118
+ ```
119
+ ```javascript
120
+ async notif_dealtCards(args) {
121
+ this.gamedatas.hand = args.cards; // keyed by card id
122
+ this.renderHand();
123
+ }
124
+ ```
125
+
126
+ ### `actionBid`
127
+ ```php
128
+ $this->notify->all("actionBid", clienttranslate('${player_name} bids ${bid}'), [
129
+ 'player_id' => $playerId,
130
+ 'player_name' => $this->getPlayerNameById($playerId),
131
+ 'bid' => $bid,
132
+ ]);
133
+ ```
134
+ ```javascript
135
+ async notif_actionBid(args) {
136
+ const player_id = args.player_id;
137
+ this.gamedatas.players[player_id].bid = args.bid;
138
+ document.getElementById('tricks_' + player_id).innerText = args.bid;
139
+ }
140
+ ```
141
+
142
+ ### `actionPlayCard`
143
+ ```php
144
+ // card has an extra 'color' key when a joker was played
145
+ $this->notify->all("actionPlayCard", clienttranslate('${player_name} plays ${card_html}'), [
146
+ 'player_id' => $playerId,
147
+ 'player_name' => $this->getPlayerNameById($playerId),
148
+ 'card' => $card, // full card object, including location_arg = player_id
149
+ 'card_html' => $cardHtml,
150
+ ]);
151
+ ```
152
+ ```javascript
153
+ async notif_actionPlayCard(args) {
154
+ const card = args.card; // args.card, not args.card_id
155
+ // card.location_arg is the player_id who played it
156
+ if (card.type === 'joker') {
157
+ this.gamedatas.players[card.location_arg].joker_color = card.color;
158
+ }
159
+ this.gamedatas.table.push(card);
160
+ this.addCardTo(document.getElementById('table_cards'), card, true);
161
+ await this.bga.gameui.wait(600);
162
+ }
163
+ ```
164
+
165
+ ### `winningCardNotif`
166
+ ```php
167
+ $this->notify->all("winningCardNotif", clienttranslate('${player_name} wins the trick'), [
168
+ 'player_id' => $winnerId,
169
+ 'player_name' => $this->getPlayerNameById($winnerId),
170
+ 'plus5' => $plus5CountByPlayer, // array keyed by player_id
171
+ 'minus5' => $minus5CountByPlayer,
172
+ ]);
173
+ ```
174
+ ```javascript
175
+ async notif_winningCardNotif(args) {
176
+ const pid = args.player_id;
177
+ this.gamedatas.plus5 = args.plus5;
178
+ this.gamedatas.minus5 = args.minus5;
179
+ await this.bga.gameui.wait(2000);
180
+ // clear table, increment tricks_taken, update UI
181
+ this.gamedatas.players[pid].tricks_taken++;
182
+ this.updateTricks(pid);
183
+ }
184
+ ```
185
+
186
+ ### `revealBids`
187
+ ```php
188
+ // Only sent for hidden-bidding variants
189
+ $this->notify->all("revealBids", clienttranslate('Revealed bids: ${html}'), [
190
+ 'bids' => $bids, // {player_id: bid_amount, ...}
191
+ 'html' => $html, // rendered string for log
192
+ ]);
193
+ ```
194
+ ```javascript
195
+ async notif_revealBids(args) {
196
+ for (const player_id in args.bids) {
197
+ this.gamedatas.players[player_id].bid = args.bids[player_id];
198
+ this.updateTricks(player_id);
199
+ }
200
+ }
201
+ ```
202
+
203
+ ---
204
+
205
+ ## Notification Setup — Modern Framework
206
+
207
+ ```javascript
208
+ setupNotifications() {
209
+ // One-liner in the modern framework — registers all notif_* methods automatically
210
+ this.bga.notifications.setupPromiseNotifications();
211
+ }
212
+ ```
213
+
214
+ This replaces the old Dojo pattern of manually calling
215
+ `this.notifqueue.subscribe()` for each type. All `async notif_*` methods on
216
+ the Game class are registered automatically.
217
+
218
+ **Do not** call `setSynchronous()` — the modern promise-based notifications
219
+ handle sequencing via `await`.
220
+
221
+ ---
222
+
223
+ ## Pre-rendered HTML in Notification Messages
224
+
225
+ PHP frequently builds an HTML string to embed in the log message:
226
+
227
+ ```php
228
+ public function getCardHtml($card): string
229
+ {
230
+ $special = isset($this->special_cards[$card["type"]]) ? "special" : "";
231
+ $val = $special
232
+ ? $this->special_cards[$card["type"]]["short"]
233
+ : $card["type_arg"];
234
+ $color = isset($card["color"]) ? $card["color"] : "";
235
+ return "<span class=\"card_type {$card['type']} {$color} {$special}\">{$val}</span>";
236
+ }
237
+
238
+ // Used in a notification message template:
239
+ $this->notify->all("trump_change", clienttranslate('Trump is now ${trump_html}'), [
240
+ 'trump' => $trump,
241
+ 'trump_html' => $this->getCardHtml($trump),
242
+ ]);
243
+ ```
244
+
245
+ The `${trump_html}` in the message template is substituted by BGA's log
246
+ renderer using the `trump_html` value from the data array. The JS handler does
247
+ not need to use `args.trump_html` at all — it reads `args.trump` (the card
248
+ object) to update the UI.
249
+
250
+ **Pattern:** always name pre-rendered HTML keys with a `_html` suffix so it's
251
+ clear which keys are for the log vs which are for UI updates.
252
+
253
+ ---
254
+
255
+ ## What notify->all vs notify->player Sends
256
+
257
+ ```php
258
+ // All players receive this (including spectators)
259
+ $this->notify->all("trump_change", $msg, $data);
260
+
261
+ // Only this specific player receives it (used for private hand info)
262
+ $this->notify->player($playerId, "dealtCards", '', $data);
263
+ ```
264
+
265
+ When a player reloads, `getAllDatas()` rebuilds their state. The
266
+ `notify->player` call is for real-time updates during play only. If a player
267
+ disconnects and reconnects, they get `getAllDatas()` — not replayed
268
+ notifications.
269
+
270
+ ---
271
+
272
+ ## Common Bugs
273
+
274
+ **Bug 1: JS key doesn't match PHP key**
275
+ ```php
276
+ $this->notify->all("bid", '...', ['player_id' => $id, 'bid_amount' => $bid]);
277
+ ```
278
+ ```javascript
279
+ async notif_bid(args) {
280
+ const bid = args.bid; // ❌ undefined — PHP key is 'bid_amount'
281
+ const bid = args.bid_amount; // ✅
282
+ }
283
+ ```
284
+
285
+ **Bug 2: Forgetting notify->player for private data**
286
+ ```php
287
+ // ❌ Sends all players' hands to everyone
288
+ $this->notify->all("dealtCards", '', ['cards' => $allCards]);
289
+
290
+ // ✅ Each player only sees their own hand
291
+ foreach ($players as $playerId => $player) {
292
+ $this->notify->player($playerId, "dealtCards", '', [
293
+ 'cards' => $this->cards->getPlayerHand($playerId),
294
+ ]);
295
+ }
296
+ ```
297
+
298
+ **Bug 3: Using old notify API in modern framework**
299
+ ```php
300
+ // ❌ Old API — works but inconsistent with modern codebase
301
+ $this->notifyAllPlayers("trump_change", $msg, $data);
302
+ $this->notifyPlayer($playerId, "dealtCards", '', $data);
303
+
304
+ // ✅ Modern API
305
+ $this->notify->all("trump_change", $msg, $data);
306
+ $this->notify->player($playerId, "dealtCards", '', $data);
307
+ ```
308
+
309
+ **Bug 4: Synchronous notification handler when awaiting animation**
310
+ ```javascript
311
+ // ❌ Returns before animation completes — next notification fires immediately
312
+ notif_winningCardNotif(args) {
313
+ setTimeout(() => { /* clear table */ }, 2000);
314
+ }
315
+
316
+ // ✅ Promise-based — next notification waits for this to resolve
317
+ async notif_winningCardNotif(args) {
318
+ await this.bga.gameui.wait(2000);
319
+ document.getElementById('table_cards').innerHTML = '';
320
+ }
321
+ ```
@@ -0,0 +1,82 @@
1
+ # Scaffold Templates (Modern Framework)
2
+
3
+ Generate modern-framework structure first. Do not scaffold legacy layout by default.
4
+
5
+ Legacy framework exists, but its file shape differs; consult official BGA docs when targeting legacy projects.
6
+
7
+ ## Required Structure
8
+
9
+ ```text
10
+ modules/
11
+ php/
12
+ Game.php
13
+ States/
14
+ <StateName>.php
15
+ Managers/
16
+ Models/
17
+ js/
18
+ Game.js
19
+ states/
20
+ <StateName>.js
21
+ templates/
22
+ ```
23
+
24
+ ## Required Files and Responsibilities
25
+
26
+ - `modules/php/Game.php`
27
+ - Main game class
28
+ - wiring of managers/models/states
29
+ - high-level actions and state entry points
30
+ - `modules/php/States/*.php`
31
+ - one class per complex state
32
+ - state-specific action validation and transitions
33
+ - `modules/php/Managers/*.php`
34
+ - data access and domain operations
35
+ - `modules/php/Models/*.php`
36
+ - value objects/DTO-style helpers
37
+ - `modules/js/Game.js`
38
+ - export class Game
39
+ - state registration
40
+ - notification setup
41
+ - UI orchestration
42
+ - `modules/js/states/*.js`
43
+ - frontend behavior per game state
44
+ - `onEnteringState`, `onLeavingState`, `onPlayerActivationChange`
45
+
46
+ ## Modern Game.js Shape
47
+
48
+ ```javascript
49
+ export class Game {
50
+ constructor(bga) {
51
+ this.bga = bga;
52
+ // register state handlers
53
+ // bga.states.register('stateName', new SomeState(this, bga));
54
+ }
55
+
56
+ setup(gamedatas) {
57
+ this.gamedatas = gamedatas;
58
+ this.bga.notifications.setupPromiseNotifications();
59
+ }
60
+ }
61
+ ```
62
+
63
+ ## PHP Action Flow Template
64
+
65
+ Use this order in server action methods:
66
+
67
+ 1. `checkAction(...)`
68
+ 2. validate arguments
69
+ 3. read DB state
70
+ 4. apply domain rule
71
+ 5. write DB
72
+ 6. notify
73
+ 7. transition
74
+
75
+ ## Test Scaffold Requirement
76
+
77
+ For each gameplay action, create corresponding PHPUnit tests using `BgaGameTestCase` fluent style:
78
+
79
+ - happy path
80
+ - invalid input path
81
+ - wrong-player path
82
+ - transition path
@@ -0,0 +1,126 @@
1
+ # State Machine Patterns
2
+
3
+ Write state machine code so transitions are explicit, action permissions are enforced, and game states never stall.
4
+
5
+ ## Required State Types
6
+
7
+ Use only the four supported state types:
8
+
9
+ - `manager`
10
+ - `activeplayer`
11
+ - `multipleactiveplayer`
12
+ - `game`
13
+
14
+ ## 12-State Example (Auction Cycle + Final Buying)
15
+
16
+ Use this as a structural template:
17
+
18
+ ```php
19
+ $machinestates = [
20
+ 1 => [
21
+ 'name' => 'gameSetup',
22
+ 'type' => 'manager',
23
+ 'action' => 'stGameSetup',
24
+ 'transitions' => ['next' => 10],
25
+ ],
26
+
27
+ 10 => [
28
+ 'name' => 'roundStart',
29
+ 'type' => 'game',
30
+ 'action' => 'stRoundStart',
31
+ 'transitions' => ['toAuction' => 20],
32
+ ],
33
+ 20 => [
34
+ 'name' => 'auctionBid',
35
+ 'type' => 'activeplayer',
36
+ 'description' => clienttranslate('${actplayer} must bid or pass'),
37
+ 'possibleactions' => ['actBid', 'actPassBid'],
38
+ 'transitions' => ['nextBidder' => 21, 'auctionClosed' => 30],
39
+ ],
40
+ 21 => [
41
+ 'name' => 'auctionAdvance',
42
+ 'type' => 'game',
43
+ 'action' => 'stAuctionAdvance',
44
+ 'transitions' => ['continueAuction' => 20, 'auctionClosed' => 30],
45
+ ],
46
+
47
+ 30 => [
48
+ 'name' => 'resolveAuction',
49
+ 'type' => 'game',
50
+ 'action' => 'stResolveAuction',
51
+ 'transitions' => ['toFinalBuying' => 40],
52
+ ],
53
+ 40 => [
54
+ 'name' => 'finalBuying',
55
+ 'type' => 'multipleactiveplayer',
56
+ 'description' => clienttranslate('All players select final purchases'),
57
+ 'possibleactions' => ['actFinalBuy'],
58
+ 'transitions' => ['allBought' => 50],
59
+ ],
60
+
61
+ 50 => [
62
+ 'name' => 'finalBuyingResolve',
63
+ 'type' => 'game',
64
+ 'action' => 'stFinalBuyingResolve',
65
+ 'transitions' => ['toAction' => 60],
66
+ ],
67
+ 60 => [
68
+ 'name' => 'playerAction',
69
+ 'type' => 'activeplayer',
70
+ 'description' => clienttranslate('${actplayer} must play'),
71
+ 'possibleactions' => ['actPlay', 'actPass'],
72
+ 'transitions' => ['nextPlayer' => 61, 'endRound' => 70],
73
+ ],
74
+ 61 => [
75
+ 'name' => 'advancePlayer',
76
+ 'type' => 'game',
77
+ 'action' => 'stAdvancePlayer',
78
+ 'transitions' => ['continueRound' => 60, 'endRound' => 70],
79
+ ],
80
+
81
+ 70 => [
82
+ 'name' => 'scoreRound',
83
+ 'type' => 'game',
84
+ 'action' => 'stScoreRound',
85
+ 'transitions' => ['nextRound' => 80, 'endGame' => 99],
86
+ ],
87
+ 80 => [
88
+ 'name' => 'prepareNextRound',
89
+ 'type' => 'game',
90
+ 'action' => 'stPrepareNextRound',
91
+ 'transitions' => ['roundStart' => 10, 'endGame' => 99],
92
+ ],
93
+
94
+ 99 => [
95
+ 'name' => 'gameEnd',
96
+ 'type' => 'manager',
97
+ 'action' => 'stGameEnd',
98
+ 'args' => 'argGameEnd',
99
+ ],
100
+ ];
101
+ ```
102
+
103
+ ## Rules You Must Enforce
104
+
105
+ - Every `activeplayer` and `multipleactiveplayer` state must define `possibleactions`.
106
+ - Every transition called in PHP must exist in the state's `transitions` map.
107
+ - `game` states must always call `nextState` (or equivalent transition logic) before returning.
108
+
109
+ ## multipleactiveplayer Completion Rule
110
+
111
+ When a player resolves action in a `multipleactiveplayer` state, mark them done:
112
+
113
+ ```php
114
+ $this->gamestate_setPlayerNonMultiactive($playerId, 'allBought');
115
+ ```
116
+
117
+ Pattern:
118
+ - Enter state and activate all relevant players.
119
+ - Each player action calls `setPlayerNonMultiactive`.
120
+ - Transition fires only after all active players are done.
121
+
122
+ ## Common Failure Modes
123
+
124
+ - Missing `possibleactions` causes `checkAction` to reject valid actions.
125
+ - Calling a transition name that is not declared stalls gameplay.
126
+ - Returning from a `game` state action without transition leaves the game stuck.
@@ -0,0 +1,47 @@
1
+ function allSameOrAllDifferent(values) {
2
+ const unique = new Set(values);
3
+ return unique.size === 1 || unique.size === 3;
4
+ }
5
+
6
+ function isValidSet(cards) {
7
+ if (!Array.isArray(cards) || cards.length !== 3) {
8
+ return false;
9
+ }
10
+
11
+ const colors = cards.map((c) => c.color);
12
+ const numbers = cards.map((c) => c.number);
13
+ const shadings = cards.map((c) => c.shading);
14
+
15
+ return allSameOrAllDifferent(colors) && allSameOrAllDifferent(numbers) && allSameOrAllDifferent(shadings);
16
+ }
17
+
18
+ function calculateScore(setCount, streak = 0) {
19
+ const base = Number(setCount) || 0;
20
+ return streak >= 3 ? base * 2 : base;
21
+ }
22
+
23
+ function getLegalMoves(cards) {
24
+ if (!Array.isArray(cards)) {
25
+ return [];
26
+ }
27
+
28
+ const moves = [];
29
+ for (let i = 0; i < cards.length; i += 1) {
30
+ for (let j = i + 1; j < cards.length; j += 1) {
31
+ for (let k = j + 1; k < cards.length; k += 1) {
32
+ const combo = [cards[i], cards[j], cards[k]];
33
+ if (isValidSet(combo)) {
34
+ moves.push(combo);
35
+ }
36
+ }
37
+ }
38
+ }
39
+
40
+ return moves;
41
+ }
42
+
43
+ module.exports = {
44
+ isValidSet,
45
+ calculateScore,
46
+ getLegalMoves
47
+ };