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.
package/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # bga-assist
2
+
3
+ ## What This Is
4
+ This repository gives you two things for BGA development with Claude Code: a ready-to-use skill file and a local testing harness. The skill file (`SKILL.md`) teaches Claude Code the project conventions and generation patterns, while the harness provides PHP and JS test scaffolding so generated logic can be validated locally. Use the skill alone if you only want better code generation, or use the full harness if you also want runnable tests.
5
+
6
+ ## Installation
7
+
8
+ ### Skill Only (No Testing Infrastructure)
9
+
10
+ 1. Clone or download this repository.
11
+ 2. Add `SKILL.md` to your Claude Code workspace instructions.
12
+
13
+ ### Full Harness
14
+
15
+ 1. Clone this repo into your BGA game's development dependencies.
16
+ 2. Install PHP support:
17
+
18
+ ```bash
19
+ composer require --dev thebgllc/bga-assist
20
+ ```
21
+
22
+ 3. Install JS support:
23
+
24
+ ```bash
25
+ npm install --save-dev bga-assist
26
+ ```
27
+
28
+ 4. Extend `BgaGameTestCase` in your PHPUnit tests.
29
+
30
+ ## Using with Claude Code
31
+
32
+ These prompts are designed to map directly to the tested patterns in `SampleGameTest.php`:
33
+
34
+ 1. "What happens if the active player submits only 2 cards?"
35
+ 2. "Write tests for the _checkValidSet function"
36
+ 3. "Generate a states.inc.php for a game with a bidding phase and a resolution phase"
37
+ 4. "I'm getting a PHP error in my action method - what's wrong?"
38
+ 5. "How do I notify only the active player when they draw a card?"
39
+
40
+ ## Running Tests
41
+
42
+ ```bash
43
+ # PHP
44
+ ./vendor/bin/phpunit
45
+
46
+ # JS
47
+ npm test
48
+ ```
49
+
50
+ ## Scope Boundaries
51
+
52
+ This v1 repo intentionally does not include:
53
+
54
+ - MCP server setup
55
+ - SFTP deployment tooling
56
+ - Studio log tailing
57
+ - Playwright live Studio E2E tests
58
+ - BGA Studio API integration
59
+ - Game-specific production implementations
package/SKILL.md ADDED
@@ -0,0 +1,117 @@
1
+ ---
2
+ name: bga-dev-skill
3
+ description: Instructions for Claude Code to generate BGA-compatible server code and tests using this repository's implemented harness and patterns.
4
+ ---
5
+
6
+ # BGA Development Instructions
7
+
8
+ ## 1) Your Role
9
+ Act like a senior BGA gameplay engineer who writes production-style server logic and runnable tests. Use only APIs that exist in this repository's harness and tested examples, prefer deterministic game logic, and generate tests in the fluent style used by the provided base test case.
10
+
11
+ ## 2) Framework Version Detection
12
+ Before writing code, detect project style and stay consistent:
13
+
14
+ - Legacy-style indicators: root-level game files, Dojo module frontend, notifyAllPlayers and notifyPlayer usage.
15
+ - Modern-style indicators: namespaced PHP classes and different notification/action APIs.
16
+
17
+ If the project is mixed or unclear, stop and ask which framework version to target. Do not mix APIs from different versions in one patch.
18
+
19
+ ## 3) PHP Server - Critical Rules
20
+ These are enforced by implemented harness behavior and passing example tests:
21
+
22
+ - Use only harness-supported DB methods in game logic:
23
+ - DbQuery
24
+ - getCollectionFromDB
25
+ - getObjectFromDB
26
+ - getUniqueValueFromDB
27
+ - getIntFromDB
28
+ - Validate player actions with checkAction before mutating state.
29
+ - Use throwUserError for gameplay validation failures.
30
+ - Use throwVisibleSystemError for server/system failures.
31
+ - Drive state changes through:
32
+ - gamestate_nextState
33
+ - gamestate_changeActivePlayer
34
+ - gamestate_setAllPlayersMultiactive
35
+ - gamestate_setPlayerNonMultiactive
36
+ - Keep notification payload keys stable and explicit; use the same key names end-to-end.
37
+ - Do not use raw PDO directly in game classes.
38
+ - Do not use superglobals in action logic.
39
+ - Do not invent framework methods that are not present in the harness.
40
+
41
+ ## 4) PHP Server - Common Patterns
42
+ Use these concrete patterns from the implemented sample game and harness:
43
+
44
+ - Setup/deal flow:
45
+ - Create required tables if missing.
46
+ - Seed baseline data if empty.
47
+ - Deal cards by moving records between locations.
48
+ - Set initial state and game-state values.
49
+ - Action flow:
50
+ - checkAction
51
+ - validate input
52
+ - load from DB
53
+ - run pure validation logic
54
+ - write DB changes
55
+ - send notifications
56
+ - transition state
57
+ - Scoring flow:
58
+ - Update score in DB.
59
+ - Update card locations in DB.
60
+ - Notify with result payload.
61
+ - Multi-active flow:
62
+ - mark all players multiactive.
63
+ - mark each player non-multiactive as they complete.
64
+ - transition when all are done.
65
+
66
+ ## 5) Testing - Generating Tests from Natural Language
67
+ When asked "what happens when...", generate PHPUnit tests using BgaGameTestCase fluent helpers and preserve this pattern:
68
+
69
+ - Given:
70
+ - givenActivePlayer
71
+ - givenCurrentPlayer when actor mismatch matters
72
+ - givenState
73
+ - givenDatabaseRows
74
+ - givenGameStateValue
75
+ - When:
76
+ - whenAction(method, args)
77
+ - Then:
78
+ - result->assertSucceeded() or result->assertFailedWith(code)
79
+ - thenStateShouldBe
80
+ - thenNotificationSent or thenNotificationNotSent
81
+ - thenPlayerNotifiedWith when target-specific behavior matters
82
+ - thenDatabaseHas or thenDatabaseCount
83
+
84
+ Use these scenario templates from the sample tests:
85
+
86
+ - happy path
87
+ - invalid input with explicit error code
88
+ - wrong player acting
89
+ - endgame transition condition
90
+ - pure logic function test with direct assertions
91
+
92
+ Keep the CC PATTERN comment style in generated example-heavy tests because it is intentional teaching content.
93
+
94
+ ## 6) Testing - What Can and Cannot Be Tested
95
+ Can test locally with this harness:
96
+
97
+ - server action validation
98
+ - state transitions
99
+ - DB writes/reads
100
+ - notification emission and payload subsets
101
+ - pure gameplay logic methods
102
+
103
+ Cannot test with this harness alone:
104
+
105
+ - Studio rendering behavior
106
+ - animation timing in live client
107
+ - real network transport behavior
108
+ - full end-to-end Studio integration
109
+
110
+ ## 7) Sub-Skill References
111
+ When work touches these topics, consult these files and follow their guidance:
112
+
113
+ - State machine patterns: skills/state-machine.md
114
+ - Database patterns: skills/database-patterns.md
115
+ - JS Dojo patterns: skills/js-dojo-patterns.md
116
+ - Notifications contract: skills/notifications.md
117
+ - Scaffold templates: skills/scaffold-templates.md
package/composer.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "your-handle/bga-dev-skill",
3
+ "description": "CC skill and testing harness for BoardGameArena game development",
4
+ "require-dev": {
5
+ "phpunit/phpunit": "^10.0"
6
+ },
7
+ "autoload": {
8
+ "psr-4": {
9
+ "BgaHarness\\": "harness/php/",
10
+ "BgaExample\\": "harness/example/"
11
+ }
12
+ }
13
+ }
@@ -0,0 +1,160 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace BgaExample;
6
+
7
+ use BgaHarness\BgaStubs;
8
+
9
+ class SampleGame extends BgaStubs
10
+ {
11
+ public function __construct()
12
+ {
13
+ parent::__construct();
14
+
15
+ $this->_setAllowedActions([
16
+ 'playerTurn' => ['action_playSet', 'action_pass'],
17
+ ]);
18
+
19
+ $this->_setTransitions([
20
+ 'playerTurn' => [
21
+ 'nextPlayer' => 'playerTurn',
22
+ 'endGame' => 'endGame',
23
+ ],
24
+ ]);
25
+ }
26
+
27
+ public function setupNewGame(): void
28
+ {
29
+ $this->DbQuery(
30
+ "CREATE TABLE IF NOT EXISTS card (" .
31
+ "card_id INTEGER PRIMARY KEY, " .
32
+ "card_type TEXT, " .
33
+ "card_number INTEGER, " .
34
+ "card_shading TEXT, " .
35
+ "card_location TEXT, " .
36
+ "card_location_arg INTEGER)"
37
+ );
38
+
39
+ $existingCards = $this->getIntFromDB('SELECT COUNT(*) FROM card');
40
+ if ($existingCards === 0) {
41
+ $types = ['red', 'green', 'blue'];
42
+ $numbers = [1, 2, 3];
43
+ $shadings = ['solid', 'striped', 'open'];
44
+ $cardId = 1;
45
+
46
+ for ($i = 0; $i < 3; $i++) {
47
+ foreach ($types as $type) {
48
+ foreach ($numbers as $number) {
49
+ foreach ($shadings as $shading) {
50
+ $this->DbQuery(sprintf(
51
+ "INSERT INTO card (card_id, card_type, card_number, card_shading, card_location, card_location_arg) VALUES (%d, '%s', %d, '%s', 'deck', 0)",
52
+ $cardId,
53
+ $type,
54
+ $number,
55
+ $shading
56
+ ));
57
+ $cardId++;
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }
63
+
64
+ $players = $this->getCollectionFromDB('SELECT player_id FROM player');
65
+ foreach ($players as $player) {
66
+ $playerId = (int) ($player['player_id'] ?? 0);
67
+ if ($playerId <= 0) {
68
+ continue;
69
+ }
70
+
71
+ $cardsToDeal = $this->getCollectionFromDB('SELECT card_id FROM card WHERE card_location = "deck" ORDER BY card_id LIMIT 3');
72
+ foreach ($cardsToDeal as $card) {
73
+ $this->DbQuery(sprintf(
74
+ "UPDATE card SET card_location = 'hand', card_location_arg = %d WHERE card_id = %d",
75
+ $playerId,
76
+ (int) $card['card_id']
77
+ ));
78
+ }
79
+ }
80
+
81
+ $this->_setState('playerTurn');
82
+ $this->_setGameStateValue('cards_remaining', $this->getIntFromDB("SELECT COUNT(*) FROM card WHERE card_location = 'deck'"));
83
+ }
84
+
85
+ public function action_playSet(array $cardIds): void
86
+ {
87
+ $this->checkAction('action_playSet');
88
+
89
+ if (count($cardIds) !== 3) {
90
+ $this->throwUserError('invalidSet');
91
+ }
92
+
93
+ $cards = [];
94
+ foreach ($cardIds as $cardId) {
95
+ $row = $this->getObjectFromDB('SELECT * FROM card WHERE card_id = ' . (int) $cardId);
96
+ if ($row === null) {
97
+ $this->throwUserError('invalidSet');
98
+ }
99
+ $cards[] = [
100
+ 'type' => (string) ($row['card_type'] ?? ''),
101
+ 'number' => (int) ($row['card_number'] ?? 1),
102
+ 'shading' => (string) ($row['card_shading'] ?? 'solid'),
103
+ ];
104
+ }
105
+
106
+ if (!$this->_checkValidSet($cards)) {
107
+ $this->throwUserError('invalidSet');
108
+ }
109
+
110
+ $this->_scoreSet($this->getActivePlayerId(), $cardIds);
111
+
112
+ $remaining = $this->incGameStateValue('cards_remaining', -3);
113
+ if ($remaining <= 0) {
114
+ $this->gamestate_nextState('endGame');
115
+
116
+ return;
117
+ }
118
+
119
+ $this->gamestate_nextState('nextPlayer');
120
+ }
121
+
122
+ public function action_pass(): void
123
+ {
124
+ $this->checkAction('action_pass');
125
+ $this->notifyAllPlayers('playerPassed', '', ['player_id' => $this->getActivePlayerId()]);
126
+ $this->gamestate_nextState('nextPlayer');
127
+ }
128
+
129
+ public function _checkValidSet(array $cards): bool
130
+ {
131
+ if (count($cards) !== 3) {
132
+ return false;
133
+ }
134
+
135
+ foreach (['type', 'number', 'shading'] as $field) {
136
+ $values = array_map(static fn (array $card): mixed => $card[$field] ?? null, $cards);
137
+ $unique = count(array_unique($values));
138
+ if ($unique !== 1 && $unique !== 3) {
139
+ return false;
140
+ }
141
+ }
142
+
143
+ return true;
144
+ }
145
+
146
+ public function _scoreSet(int $playerId, array $cards): void
147
+ {
148
+ $this->DbQuery('UPDATE player SET player_score = player_score + 1 WHERE player_id = ' . $playerId);
149
+ foreach ($cards as $cardId) {
150
+ $this->DbQuery("UPDATE card SET card_location = 'discard' WHERE card_id = " . (int) $cardId);
151
+ }
152
+
153
+ $score = (int) $this->getUniqueValueFromDB('SELECT player_score FROM player WHERE player_id = ' . $playerId);
154
+ $this->notifyAllPlayers('setPlayed', '', [
155
+ 'player_id' => $playerId,
156
+ 'score' => $score,
157
+ 'card_ids' => $cards,
158
+ ]);
159
+ }
160
+ }
@@ -0,0 +1,104 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace BgaExample;
6
+
7
+ use BgaHarness\BgaGameTestCase;
8
+ use BgaHarness\BgaStubs;
9
+
10
+ class SampleGameTest extends BgaGameTestCase
11
+ {
12
+ protected function createGame(): BgaStubs
13
+ {
14
+ $game = new SampleGame();
15
+ $game->_setState('playerTurn');
16
+ $game->_setGameStateValue('cards_remaining', 12);
17
+ $game->_getDb()->seedTable('player', [
18
+ ['player_id' => 1, 'player_name' => 'Alice', 'player_score' => 0],
19
+ ['player_id' => 2, 'player_name' => 'Bob', 'player_score' => 0],
20
+ ]);
21
+
22
+ return $game;
23
+ }
24
+
25
+ public function test_valid_set_scores_and_advances_state(): void
26
+ {
27
+ // CC PATTERN: happy path — given setup -> when action -> then state + notification
28
+ $this->givenActivePlayer(1)
29
+ ->givenState('playerTurn')
30
+ ->givenDatabaseRows('card', [
31
+ ['card_id' => 1, 'card_type' => 'red', 'card_number' => 1, 'card_shading' => 'solid', 'card_location' => 'hand', 'card_location_arg' => 1],
32
+ ['card_id' => 2, 'card_type' => 'green', 'card_number' => 2, 'card_shading' => 'striped', 'card_location' => 'hand', 'card_location_arg' => 1],
33
+ ['card_id' => 3, 'card_type' => 'blue', 'card_number' => 3, 'card_shading' => 'open', 'card_location' => 'hand', 'card_location_arg' => 1],
34
+ ]);
35
+
36
+ $result = $this->whenAction('action_playSet', ['card_ids' => [1, 2, 3]]);
37
+
38
+ $result->assertSucceeded();
39
+ $this->thenNotificationSent('setPlayed', ['player_id' => 1, 'score' => 1]);
40
+ $this->thenDatabaseHas('card', ['card_location' => 'discard', 'card_id' => 1]);
41
+ $this->thenStateShouldBe('playerTurn');
42
+ }
43
+
44
+ public function test_invalid_set_rejected_with_error(): void
45
+ {
46
+ // CC PATTERN: invalid action — assert specific BGA error code thrown
47
+ $this->givenActivePlayer(1)
48
+ ->givenState('playerTurn')
49
+ ->givenDatabaseRows('card', [
50
+ ['card_id' => 1, 'card_type' => 'red', 'card_number' => 1, 'card_shading' => 'solid'],
51
+ ['card_id' => 2, 'card_type' => 'red', 'card_number' => 2, 'card_shading' => 'striped'],
52
+ ['card_id' => 3, 'card_type' => 'blue', 'card_number' => 3, 'card_shading' => 'open'],
53
+ ]);
54
+
55
+ $result = $this->whenAction('action_playSet', ['card_ids' => [1, 2, 3]]);
56
+
57
+ $result->assertFailedWith('invalidSet');
58
+ $this->thenNotificationNotSent('setPlayed');
59
+ $this->thenStateShouldBe('playerTurn');
60
+ }
61
+
62
+ public function test_inactive_player_cannot_play(): void
63
+ {
64
+ // CC PATTERN: wrong actor — checkAction should block this
65
+ $this->givenActivePlayer(1)
66
+ ->givenCurrentPlayer(2)
67
+ ->givenState('playerTurn');
68
+
69
+ $result = $this->whenAction('action_playSet', ['card_ids' => [4, 5, 6]]);
70
+
71
+ $result->assertFailedWith('notYourTurn');
72
+ }
73
+
74
+ public function test_last_set_triggers_endgame(): void
75
+ {
76
+ // CC PATTERN: state transition — verify the right transition fires
77
+ $this->givenGameStateValue('cards_remaining', 3)
78
+ ->givenActivePlayer(1)
79
+ ->givenDatabaseRows('card', [
80
+ ['card_id' => 1, 'card_type' => 'red', 'card_number' => 1, 'card_shading' => 'solid', 'card_location' => 'hand', 'card_location_arg' => 1],
81
+ ['card_id' => 2, 'card_type' => 'green', 'card_number' => 2, 'card_shading' => 'striped', 'card_location' => 'hand', 'card_location_arg' => 1],
82
+ ['card_id' => 3, 'card_type' => 'blue', 'card_number' => 3, 'card_shading' => 'open', 'card_location' => 'hand', 'card_location_arg' => 1],
83
+ ]);
84
+
85
+ $this->whenAction('action_playSet', ['card_ids' => [1, 2, 3]]);
86
+
87
+ $this->thenStateShouldBe('endGame');
88
+ }
89
+
90
+ public function test_set_validation_logic(): void
91
+ {
92
+ // CC PATTERN: pure function — no given/when/then needed, just call directly
93
+ $validSet = [
94
+ ['type' => 'red', 'number' => 1, 'shading' => 'solid'],
95
+ ['type' => 'green', 'number' => 2, 'shading' => 'striped'],
96
+ ['type' => 'blue', 'number' => 3, 'shading' => 'open'],
97
+ ];
98
+ $this->assertTrue($this->game->_checkValidSet($validSet));
99
+
100
+ $invalidSet = $validSet;
101
+ $invalidSet[2]['type'] = 'red';
102
+ $this->assertFalse($this->game->_checkValidSet($invalidSet));
103
+ }
104
+ }
@@ -0,0 +1,53 @@
1
+ const { isValidSet, calculateScore, getLegalMoves } = require('../../src/setgame.utils.js');
2
+
3
+ describe('isValidSet', () => {
4
+ test('all same color is valid', () => {
5
+ const cards = [
6
+ { color: 'red', number: 1, shading: 'solid' },
7
+ { color: 'red', number: 2, shading: 'striped' },
8
+ { color: 'red', number: 3, shading: 'open' }
9
+ ];
10
+ expect(isValidSet(cards)).toBe(true);
11
+ });
12
+
13
+ test('all different colors is valid', () => {
14
+ const cards = [
15
+ { color: 'red', number: 1, shading: 'solid' },
16
+ { color: 'green', number: 2, shading: 'striped' },
17
+ { color: 'blue', number: 3, shading: 'open' }
18
+ ];
19
+ expect(isValidSet(cards)).toBe(true);
20
+ });
21
+
22
+ test('two same one different is invalid', () => {
23
+ const cards = [
24
+ { color: 'red', number: 1, shading: 'solid' },
25
+ { color: 'red', number: 2, shading: 'striped' },
26
+ { color: 'blue', number: 3, shading: 'open' }
27
+ ];
28
+ expect(isValidSet(cards)).toBe(false);
29
+ });
30
+ });
31
+
32
+ describe('calculateScore', () => {
33
+ test('base score for standard set', () => {
34
+ expect(calculateScore(1, 0)).toBe(1);
35
+ });
36
+
37
+ test('bonus multiplier for 3-in-a-row', () => {
38
+ expect(calculateScore(2, 3)).toBe(4);
39
+ });
40
+ });
41
+
42
+ describe('getLegalMoves', () => {
43
+ test('finds at least one legal set', () => {
44
+ const cards = [
45
+ { color: 'red', number: 1, shading: 'solid' },
46
+ { color: 'green', number: 2, shading: 'striped' },
47
+ { color: 'blue', number: 3, shading: 'open' },
48
+ { color: 'red', number: 1, shading: 'solid' }
49
+ ];
50
+
51
+ expect(getLegalMoves(cards).length).toBeGreaterThan(0);
52
+ });
53
+ });
@@ -0,0 +1,32 @@
1
+ global.dojo = {
2
+ place: jest.fn(),
3
+ query: jest.fn(() => ({ forEach: jest.fn(), length: 0 })),
4
+ style: jest.fn(),
5
+ addClass: jest.fn(),
6
+ removeClass: jest.fn(),
7
+ connect: jest.fn(),
8
+ on: jest.fn(),
9
+ animateProperty: jest.fn(() => ({ play: jest.fn() })),
10
+ require: jest.fn()
11
+ };
12
+
13
+ global.gameui = {
14
+ player_id: 1,
15
+ gamedatas: {},
16
+ addTooltip: jest.fn(),
17
+ ajaxcall: jest.fn(),
18
+ showMessage: jest.fn()
19
+ };
20
+
21
+ const notificationHandlers = {};
22
+
23
+ global.bgaNotification = {
24
+ register: (type, handler) => {
25
+ notificationHandlers[type] = handler;
26
+ },
27
+ trigger: (type, args) => {
28
+ if (notificationHandlers[type]) {
29
+ notificationHandlers[type]({ args });
30
+ }
31
+ }
32
+ };
@@ -0,0 +1,9 @@
1
+ function expectSubset(actual, subset) {
2
+ for (const [key, value] of Object.entries(subset)) {
3
+ expect(actual[key]).toEqual(value);
4
+ }
5
+ }
6
+
7
+ module.exports = {
8
+ expectSubset
9
+ };