@netforge/template-cards 0.0.0-stage → 0.2.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,58 @@
1
+ /**
2
+ * Display names for the template's content ids. Labels only; the rules are
3
+ * in src/game.ts. A Net Forge game generated on this template ships its own
4
+ * table (the model writes src/names.json; the build turns it into this
5
+ * module). Cards are the "weapons" table ({ name, line }).
6
+ */
7
+ export const TITLE = "Inkwrit";
8
+
9
+ export const WEAPON_NAMES: Readonly<Record<string, { name: string; line: string }>> = {
10
+ blot_imp: { name: "Blot Imp", line: "Small and spiteful" },
11
+ quill_sprite: { name: "Quill Sprite", line: "Strikes at once" },
12
+ ink_frog: { name: "Ink Frog", line: "Sturdy for its cost" },
13
+ margin_bat: { name: "Margin Bat", line: "Strikes at once" },
14
+ ledger_crab: { name: "Ledger Crab", line: "Guards your hero" },
15
+ serif_fox: { name: "Serif Fox", line: "Draws a card" },
16
+ gloss_wolf: { name: "Gloss Wolf", line: "A solid hunter" },
17
+ stamp_golem: { name: "Stamp Golem", line: "A wall of ink" },
18
+ folio_wyrm: { name: "Folio Wyrm", line: "Scorches every foe" },
19
+ grand_errata: { name: "Grand Errata", line: "The last word" },
20
+ splash: { name: "Splash", line: "2 damage" },
21
+ blotting_sand: { name: "Blotting Sand", line: "Heal your hero 5" },
22
+ underline: { name: "Underline", line: "+2/+2 to a unit" },
23
+ second_draft: { name: "Second Draft", line: "Draw two cards" },
24
+ ink_storm: { name: "Ink Storm", line: "2 to every foe" },
25
+ final_word: { name: "Final Word", line: "5 damage" },
26
+ };
27
+
28
+ export const UPGRADE_NAMES: Readonly<Record<string, { name: string; line: string }>> = {
29
+ thick_vellum: { name: "Thick Vellum", line: "+5 hero health" },
30
+ sharp_nib: { name: "Sharp Nib", line: "+1 attack on your units" },
31
+ quick_ink: { name: "Quick Ink", line: "+1 mana in your first turn" },
32
+ spare_page: { name: "Spare Page", line: "+1 card in your first hand" },
33
+ };
34
+
35
+ // Creatures by prefab id (the duel has none; a game that adds them labels them here).
36
+ export const ENEMY_NAMES: Readonly<Record<string, string>> = {
37
+ };
38
+
39
+ export const RULE_NAMES: Readonly<Record<string, string>> = {
40
+ first_ink: "First Ink",
41
+ full_gauntlet: "Full Gauntlet",
42
+ inked_duel: "Inked Duel",
43
+ signed_and_sealed: "Signed and Sealed",
44
+ };
45
+
46
+ export const SKU_NAMES: Readonly<Record<string, string>> = {
47
+ thick_vellum: "Thick Vellum",
48
+ sharp_nib: "Sharp Nib",
49
+ quick_ink: "Quick Ink",
50
+ spare_page: "Spare Page",
51
+ skin_vermilion: "Vermilion quill",
52
+ skin_gold_leaf: "Gold leaf quill",
53
+ bound_folio: "Bound folio",
54
+ };
55
+
56
+ /** Player colours by slot, and a highlight palette (the generated names module carries both). */
57
+ export const KEEPER_COLORS = [0x2f5d8a, 0xb2382e, 0x3f7a4a, 0x8a5a2b, 0x6b4a8a, 0xc9a227, 0x2a7a7a, 0x8a2a5a];
58
+ export const AURORA = { body: 0x3cf2c4, glow: 0xa77bff };
package/llms-full.txt ADDED
@@ -0,0 +1,243 @@
1
+ # Net Forge card battler template (INKWRIT)
2
+
3
+ > A one-against-one card battler. Two scribes duel at a desk: heroes with
4
+ > health, ink (mana) that rises each turn, a hand drawn from a shuffled
5
+ > deck, inked creatures on the board, spells. A run is a gauntlet of
6
+ > duels; after each one, every player drafts one of three cards into
7
+ > their deck. Alone, an AI scribe of the gauntlet takes the other seat.
8
+ > You change the game by editing src/game.ts only. The template already has
9
+ > the duel, turns and the turn timer, the shuffle (on the server), hidden
10
+ > hands, guards, rush, the draft, the AI, netcode, rendering, touch,
11
+ > earn and the store. Do not write renderers, netcode or AI.
12
+
13
+ ## Files
14
+
15
+ - src/game.ts: THE GAME. Declarations against @netforge/engine:
16
+ game(), cardTable(), card(), upgrade(), earn.rule(), earn.stake(), store.sku().
17
+ - src/names.json: the title and every label players read. Cards go in
18
+ "weapons" ({ name, line }), plus upgrades, earn rules and store items.
19
+ - Everything else is template machinery.
20
+
21
+ ## Rules the build enforces
22
+
23
+ - game({ template: "cards", style, places, players: { prefab, max } }), players.max at most 2.
24
+ - Exactly one cardTable(...); 4 to 40 card(...)s. The starter deck (8 to 30
25
+ cards) names declared cards, holds at least one unit and cards costing 2
26
+ or less. ai is 1 to 12 tiers { health, smart }.
27
+ - A unit has health 1 or more; a spell has an effect.
28
+ - Effects: kind "damage", "heal", "draw" or "buff"; target "enemy" (the
29
+ player picks an enemy unit or the hero), "enemy_hero", "all_enemies",
30
+ "own_hero", "own_unit" (the player picks one of theirs) or "all_own".
31
+ - Upgrades read four stats only: maxHealth (hero health), might (+attack on
32
+ the buyer's units), haste (+ink in the first turn), amount (+cards in
33
+ the first hand).
34
+ - No weapon(...). Asset ids look like "style.lowpoly.frog"; keep game({ style }).
35
+ - 2 playtest players must decide duels and fire an earn rule within 4 minutes.
36
+
37
+ ## Units
38
+
39
+ Health and attack in points, cost in ink (0 to 10), seconds for timers.
40
+ Tick rate 30 Hz.
41
+
42
+ ## The loop
43
+
44
+ A duel: a coin decides who goes first; the second player draws one extra
45
+ card. Each turn: +1 ink (up to manaMax), draw a card (an empty deck hurts
46
+ more each time), play cards, attack with ready units (a unit is ready the
47
+ turn after it lands, or at once with rush; guard units must be attacked
48
+ first), end the turn (or the timer ends it). A hero at 0 loses; turnCap
49
+ turns is a draw. After each duel but the last, a draft: one of three.
50
+
51
+ ## Common changes
52
+
53
+ ### A new card
54
+ card("mirror_moth", { model: "style.lowpoly.jelly_flyer", kind: "unit", cost: 3, attack: 2, health: 2, effect: { kind: "damage", amount: 1, target: "all_enemies" } });
55
+ Add its label to names.json weapons. The draft offers every card unless cardTable pool lists some.
56
+
57
+ ### Tune the table
58
+ cardTable({ health: 25, duels: 5, turnSeconds: 40, ... }). Lower health
59
+ makes faster duels; more duels make longer runs.
60
+
61
+ ### Harder or easier AI
62
+ ai: [{ health: 14, smart: 0.3 }, ...]: duel n against the AI uses tier n;
63
+ raise health and smart for a harder gauntlet.
64
+
65
+ ### Earn rules
66
+ earn.rule("ink_master", { when: "ai.beaten", n: 3, pay: 15, oncePer: "run" });
67
+ Events: duel.won (a duel won against a person; never against the AI, never
68
+ a forfeit), ai.beaten (a gauntlet AI scribe beaten; a forfeit pays
69
+ nothing), run.won (more duels won than lost in the run). Each rule pays
70
+ once per run per player, in coins; the room server is the referee; the AI
71
+ scribe never earns.
72
+
73
+ ### The buy-in run (required)
74
+ earn.stake({ min: 10, max: 100, multiple: 2 }); one top-level line. "stake" is reserved.
75
+
76
+ ### Coins: earn and the store
77
+ store.sku("thick_vellum", { coins: 40 }); // one level of the upgrade for the run
78
+ store.sku("skin_gold_leaf", { coins: 500, grants: { cosmetic: 2 } }); // a finish for your quill, owned for good
79
+ store.sku("bound_folio", { coins: 120, grants: { gear: "thick_vellum", loseBelow: 0.25 } }); // losable gear
80
+ Keep the SKUs the client is wired to (thick_vellum, sharp_nib, quick_ink,
81
+ spare_page, skin_vermilion, skin_gold_leaf, bound_folio) at their prices.
82
+
83
+ ## Hidden information
84
+
85
+ Decks are shuffled on the room server and their order never leaves it; a
86
+ hand and a draft offer are sent only to their owner. Nothing in game.ts
87
+ can change that.
88
+
89
+ ## Test
90
+
91
+ engine.test.run({ ticks: 30 * 240, bots: 2 }) plays duels between two
92
+ players (bots: 1 plays one person against the AI) and reports duels won
93
+ (kills) and lost (deaths), the duel reached (wave), runs, earned by rule,
94
+ softlock, ms per tick and the genre's checks under rules.checks.
95
+
96
+ ## Worked example: src/game.ts
97
+
98
+ This is the template's complete src/game.ts as it ships. A new game on this
99
+ template starts from this file and changes declarations in place.
100
+
101
+ ```ts
102
+ /**
103
+ * INKWRIT: the card battler template (play style:
104
+ * card battler).
105
+ *
106
+ * Scribes duel in a lamplit study. Every card is an inked creature or a
107
+ * spell written on a page: blot imps, ledger crabs, folio wyrms, a splash
108
+ * of ink. One against one, a person against a person or against the
109
+ * gauntlet's AI scribes. A run is four duels; after each, you draft one of
110
+ * three new cards into your deck.
111
+ *
112
+ * This file is the whole game as the model writes it: declarations only,
113
+ * against the typed API in @netforge/engine. The duel, the draft, the
114
+ * shuffle, hidden hands, the AI, netcode, renderer and referee are the
115
+ * template's machinery.
116
+ */
117
+ import { card, cardTable, earn, game, store, upgrade } from "@netforge/engine";
118
+
119
+ export default game({
120
+ template: "cards",
121
+ style: "lowpoly",
122
+ places: { study: { chunks: "places/study", spawn: [0, 0, 0] } },
123
+ players: { prefab: "style.lowpoly.hooded_adventurer", max: 2 },
124
+ });
125
+
126
+ // The table: hero health, hands, the board, the gauntlet and its AI scribes (tier n plays duel n against the AI).
127
+ cardTable({
128
+ health: 20,
129
+ startHand: 3,
130
+ handMax: 7,
131
+ boardMax: 5,
132
+ manaMax: 10,
133
+ turnSeconds: 30,
134
+ draftSeconds: 15,
135
+ duels: 4,
136
+ starter: ["blot_imp", "blot_imp", "quill_sprite", "quill_sprite", "ink_frog", "ink_frog", "margin_bat", "ledger_crab", "serif_fox", "splash", "splash", "blotting_sand"],
137
+ ai: [
138
+ { health: 14, smart: 0.3 },
139
+ { health: 16, smart: 0.55 },
140
+ { health: 20, smart: 0.75 },
141
+ { health: 24, smart: 0.9 },
142
+ ],
143
+ });
144
+
145
+ // Units: cost in mana, attack and health. guard units must be attacked first; rush units attack the turn they land.
146
+ card("blot_imp", { model: "style.lowpoly.imp", kind: "unit", cost: 1, attack: 2, health: 1 });
147
+ card("quill_sprite", { model: "style.lowpoly.bee", kind: "unit", cost: 1, attack: 1, health: 2, rush: true });
148
+ card("ink_frog", { model: "style.lowpoly.frog", kind: "unit", cost: 2, attack: 2, health: 3 });
149
+ card("margin_bat", { model: "style.lowpoly.bat", kind: "unit", cost: 2, attack: 3, health: 1, rush: true });
150
+ card("ledger_crab", { model: "style.lowpoly.crab", kind: "unit", cost: 3, attack: 2, health: 5, guard: true });
151
+ card("serif_fox", { model: "style.lowpoly.fox", kind: "unit", cost: 3, attack: 3, health: 3, effect: { kind: "draw", amount: 1 } });
152
+ card("gloss_wolf", { model: "style.lowpoly.wolf", kind: "unit", cost: 4, attack: 4, health: 4 });
153
+ card("stamp_golem", { model: "style.lowpoly.yeti", kind: "unit", cost: 5, attack: 4, health: 7, guard: true });
154
+ card("folio_wyrm", { model: "style.lowpoly.dragon_whelp", kind: "unit", cost: 6, attack: 5, health: 5, effect: { kind: "damage", amount: 2, target: "all_enemies" } });
155
+ card("grand_errata", { model: "style.lowpoly.dragon", kind: "unit", cost: 8, attack: 8, health: 8 });
156
+ // Spells: an effect only. "enemy" lets the player pick an enemy unit or the hero.
157
+ card("splash", { model: "style.lowpoly.crystal", kind: "spell", cost: 1, effect: { kind: "damage", amount: 2, target: "enemy" } });
158
+ card("blotting_sand", { model: "style.lowpoly.gem_mid", kind: "spell", cost: 2, effect: { kind: "heal", amount: 5, target: "own_hero" } });
159
+ card("underline", { model: "style.lowpoly.gem_pink", kind: "spell", cost: 2, effect: { kind: "buff", amount: 2, target: "own_unit" } });
160
+ card("second_draft", { model: "style.lowpoly.chest", kind: "spell", cost: 3, effect: { kind: "draw", amount: 2 } });
161
+ card("ink_storm", { model: "style.lowpoly.meteor", kind: "spell", cost: 4, effect: { kind: "damage", amount: 2, target: "all_enemies" } });
162
+ card("final_word", { model: "style.lowpoly.crystal_rock", kind: "spell", cost: 5, effect: { kind: "damage", amount: 5, target: "enemy" } });
163
+
164
+ // Store upgrades: one level each, for the rest of the run (hero health, unit attack, first-turn mana, starting hand).
165
+ upgrade("thick_vellum", { stat: "maxHealth", step: 5, maxLevel: 1 });
166
+ upgrade("sharp_nib", { stat: "might", step: 1, maxLevel: 1 });
167
+ upgrade("quick_ink", { stat: "haste", step: 1, maxLevel: 1 });
168
+ upgrade("spare_page", { stat: "amount", step: 1, maxLevel: 1 });
169
+
170
+ // Earn (mandatory). Pays the game's coins (1 coin = 1 token), once per run each. Values are PLACEHOLDER.
171
+ // duel.won counts duels won against a person (never against the AI, never a forfeit); ai.beaten counts gauntlet AI scribes beaten.
172
+ earn.rule("first_ink", { when: "ai.beaten", n: 1, pay: 5, oncePer: "run" });
173
+ earn.rule("full_gauntlet", { when: "ai.beaten", n: 4, pay: 20, oncePer: "run" });
174
+ earn.rule("inked_duel", { when: "duel.won", n: 1, pay: 12, oncePer: "run" });
175
+ earn.rule("signed_and_sealed", { when: "duel.won", n: 3, pay: 25, oncePer: "run" });
176
+ // Buy-in run (the mandatory sink loop): stake coins at run start to earn above free play. Values are PLACEHOLDER.
177
+ earn.stake({ min: 10, max: 100, multiple: 2 });
178
+
179
+ // Store (the Kit), priced in coins: four run upgrades and two finishes for your quill, owned for good.
180
+ store.sku("thick_vellum", { coins: 40 });
181
+ store.sku("sharp_nib", { coins: 60 });
182
+ store.sku("quick_ink", { coins: 40 });
183
+ store.sku("spare_page", { coins: 50 });
184
+ store.sku("skin_vermilion", { coins: 300, grants: { cosmetic: 1 } });
185
+ store.sku("skin_gold_leaf", { coins: 500, grants: { cosmetic: 2 } });
186
+ // Losable gear: one level of its upgrade in every staked run, lost when that run makes under loseBelow of its most earn. PLACEHOLDER.
187
+ store.sku("bound_folio", { coins: 120, grants: { gear: "thick_vellum", loseBelow: 0.25 } });
188
+ ```
189
+
190
+ ## Validate and playtest
191
+
192
+ ### With the MCP server
193
+
194
+ Run `npx -y @netforge/mcp` (package @netforge/mcp, bin netforge-mcp) and
195
+ connect it to your coding agent. Its tools are netforge_list_templates, netforge_create_game,
196
+ netforge_explain_api, netforge_search_docs, netforge_validate_game, netforge_run_playtest and netforge_play_locally.
197
+
198
+ - netforge_validate_game checks src/game.ts against the engine's schemas and this
199
+ template's rules, and returns one actionable line per problem.
200
+ - netforge_run_playtest plays the game headless with bots and returns the report.
201
+ - netforge_play_locally gives the steps to open the game in a browser with kit play.
202
+
203
+ ### In code
204
+
205
+ Import the game module first so its declarations register, then run the
206
+ headless harness. This plays two players for 4 minutes at 30 Hz (bots: 1 plays one person against the AI).
207
+
208
+ ```ts
209
+ import { engine } from "@netforge/engine";
210
+ import "./src/game.js";
211
+
212
+ const report = await engine.test.run({
213
+ ticks: 30 * 240,
214
+ bots: 2,
215
+ seed: 1,
216
+ assert: [
217
+ { name: "earn fired", check: (r) => r.earnEmitted > 0 },
218
+ { name: "no softlock", check: (r) => !r.softlock },
219
+ ],
220
+ });
221
+ if (report.failed.length > 0) throw new Error(report.failed.join("\n"));
222
+ ```
223
+
224
+ The report has duels won (kills) and lost (deaths), the duel reached (wave), runs, earned by rule, softlock, ms per tick and the genre's checks under rules.checks.
225
+
226
+ The template's own tests (vitest) are the reference checks:
227
+
228
+ - test/harness.test.ts
229
+ - test/store.test.ts
230
+
231
+ ### Play it locally
232
+
233
+ In a folder with netnet.json and src/game.ts:
234
+
235
+ ```sh
236
+ npm install @netforge/kit-play @netforge/engine @netforge/template-cards
237
+ echo '{"template":"cards"}' > netnet.json
238
+ npx kit-play # opens http://127.0.0.1:4663
239
+ ```
240
+
241
+ kit play builds the template client with your src/game.ts and runs a local
242
+ room host with play money. No wallet, no chain and no hosted servers are
243
+ involved. Open the link in more tabs for more players.
package/llms.txt ADDED
@@ -0,0 +1,94 @@
1
+ # Net Forge card battler template (INKWRIT)
2
+
3
+ > A one-against-one card battler. Two scribes duel at a desk: heroes with
4
+ > health, ink (mana) that rises each turn, a hand drawn from a shuffled
5
+ > deck, inked creatures on the board, spells. A run is a gauntlet of
6
+ > duels; after each one, every player drafts one of three cards into
7
+ > their deck. Alone, an AI scribe of the gauntlet takes the other seat.
8
+ > You change the game by editing src/game.ts only. The template already has
9
+ > the duel, turns and the turn timer, the shuffle (on the server), hidden
10
+ > hands, guards, rush, the draft, the AI, netcode, rendering, touch,
11
+ > earn and the store. Do not write renderers, netcode or AI.
12
+
13
+ ## Files
14
+
15
+ - src/game.ts: THE GAME. Declarations against @netforge/engine:
16
+ game(), cardTable(), card(), upgrade(), earn.rule(), earn.stake(), store.sku().
17
+ - src/names.json: the title and every label players read. Cards go in
18
+ "weapons" ({ name, line }), plus upgrades, earn rules and store items.
19
+ - Everything else is template machinery.
20
+
21
+ ## Rules the build enforces
22
+
23
+ - game({ template: "cards", style, places, players: { prefab, max } }), players.max at most 2.
24
+ - Exactly one cardTable(...); 4 to 40 card(...)s. The starter deck (8 to 30
25
+ cards) names declared cards, holds at least one unit and cards costing 2
26
+ or less. ai is 1 to 12 tiers { health, smart }.
27
+ - A unit has health 1 or more; a spell has an effect.
28
+ - Effects: kind "damage", "heal", "draw" or "buff"; target "enemy" (the
29
+ player picks an enemy unit or the hero), "enemy_hero", "all_enemies",
30
+ "own_hero", "own_unit" (the player picks one of theirs) or "all_own".
31
+ - Upgrades read four stats only: maxHealth (hero health), might (+attack on
32
+ the buyer's units), haste (+ink in the first turn), amount (+cards in
33
+ the first hand).
34
+ - No weapon(...). Asset ids look like "style.lowpoly.frog"; keep game({ style }).
35
+ - 2 playtest players must decide duels and fire an earn rule within 4 minutes.
36
+
37
+ ## Units
38
+
39
+ Health and attack in points, cost in ink (0 to 10), seconds for timers.
40
+ Tick rate 30 Hz.
41
+
42
+ ## The loop
43
+
44
+ A duel: a coin decides who goes first; the second player draws one extra
45
+ card. Each turn: +1 ink (up to manaMax), draw a card (an empty deck hurts
46
+ more each time), play cards, attack with ready units (a unit is ready the
47
+ turn after it lands, or at once with rush; guard units must be attacked
48
+ first), end the turn (or the timer ends it). A hero at 0 loses; turnCap
49
+ turns is a draw. After each duel but the last, a draft: one of three.
50
+
51
+ ## Common changes
52
+
53
+ ### A new card
54
+ card("mirror_moth", { model: "style.lowpoly.jelly_flyer", kind: "unit", cost: 3, attack: 2, health: 2, effect: { kind: "damage", amount: 1, target: "all_enemies" } });
55
+ Add its label to names.json weapons. The draft offers every card unless cardTable pool lists some.
56
+
57
+ ### Tune the table
58
+ cardTable({ health: 25, duels: 5, turnSeconds: 40, ... }). Lower health
59
+ makes faster duels; more duels make longer runs.
60
+
61
+ ### Harder or easier AI
62
+ ai: [{ health: 14, smart: 0.3 }, ...]: duel n against the AI uses tier n;
63
+ raise health and smart for a harder gauntlet.
64
+
65
+ ### Earn rules
66
+ earn.rule("ink_master", { when: "ai.beaten", n: 3, pay: 15, oncePer: "run" });
67
+ Events: duel.won (a duel won against a person; never against the AI, never
68
+ a forfeit), ai.beaten (a gauntlet AI scribe beaten; a forfeit pays
69
+ nothing), run.won (more duels won than lost in the run). Each rule pays
70
+ once per run per player, in coins; the room server is the referee; the AI
71
+ scribe never earns.
72
+
73
+ ### The buy-in run (required)
74
+ earn.stake({ min: 10, max: 100, multiple: 2 }); one top-level line. "stake" is reserved.
75
+
76
+ ### Coins: earn and the store
77
+ store.sku("thick_vellum", { coins: 40 }); // one level of the upgrade for the run
78
+ store.sku("skin_gold_leaf", { coins: 500, grants: { cosmetic: 2 } }); // a finish for your quill, owned for good
79
+ store.sku("bound_folio", { coins: 120, grants: { gear: "thick_vellum", loseBelow: 0.25 } }); // losable gear
80
+ Keep the SKUs the client is wired to (thick_vellum, sharp_nib, quick_ink,
81
+ spare_page, skin_vermilion, skin_gold_leaf, bound_folio) at their prices.
82
+
83
+ ## Hidden information
84
+
85
+ Decks are shuffled on the room server and their order never leaves it; a
86
+ hand and a draft offer are sent only to their owner. Nothing in game.ts
87
+ can change that.
88
+
89
+ ## Test
90
+
91
+ engine.test.run({ ticks: 30 * 240, bots: 2 }) plays duels between two
92
+ players (bots: 1 plays one person against the AI) and reports duels won
93
+ (kills) and lost (deaths), the duel reached (wave), runs, earned by rule,
94
+ softlock, ms per tick and the genre's checks under rules.checks.
package/package.json CHANGED
@@ -1,6 +1,82 @@
1
1
  {
2
2
  "name": "@netforge/template-cards",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Card battler game template for Net Forge: one against one or against the AI, a gauntlet of duels with drafts, server-side shuffle and hidden hands (INKWRIT).",
5
+ "keywords": [
6
+ "game-template",
7
+ "multiplayer",
8
+ "browser-game",
9
+ "html5-game",
10
+ "threejs",
11
+ "game-engine",
12
+ "typescript",
13
+ "gamedev",
14
+ "ai-agents",
15
+ "mcp",
16
+ "net-forge",
17
+ "card-game"
18
+ ],
19
+ "license": "MIT",
20
+ "type": "module",
21
+ "sideEffects": [
22
+ "./src/game.ts",
23
+ "./src/manifest.ts",
24
+ "./client/**"
25
+ ],
26
+ "exports": {
27
+ ".": "./src/manifest.ts",
28
+ "./store": "./src/store.ts",
29
+ "./package.json": "./package.json",
30
+ "./llms.txt": "./llms.txt",
31
+ "./llms-full.txt": "./llms-full.txt"
32
+ },
33
+ "files": [
34
+ "src",
35
+ "client",
36
+ "!client/vite.config.ts",
37
+ "llms.txt",
38
+ "README.md",
39
+ "NOTICE",
40
+ "LICENSE",
41
+ "llms-full.txt",
42
+ "AGENTS.md",
43
+ "CLAUDE.md"
44
+ ],
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "homepage": "https://forge.netnet.capital/docs/templates/cards",
49
+ "bugs": {
50
+ "url": "https://github.com/netnetdev/net-forge/issues"
51
+ },
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/netnetdev/net-forge.git",
55
+ "directory": "packages/template-cards"
56
+ },
57
+ "engines": {
58
+ "node": ">=20"
59
+ },
60
+ "dependencies": {
61
+ "three": "0.186.1",
62
+ "@netforge/asset-library": "0.2.0",
63
+ "@netforge/engine": "0.2.0",
64
+ "@netforge/engine-sim": "0.2.0",
65
+ "@netforge/tabletop": "0.2.0"
66
+ },
67
+ "devDependencies": {
68
+ "@types/node": "22.20.1",
69
+ "@types/three": "0.186.0",
70
+ "tsx": "4.23.0",
71
+ "typescript": "5.8.3",
72
+ "vite": "6.4.3",
73
+ "vitest": "3.2.7"
74
+ },
75
+ "scripts": {
76
+ "build": "node ../engine/scripts/fresh-dist.mjs && vite build --config client/vite.config.ts",
77
+ "typecheck": "tsc -p tsconfig.json",
78
+ "test": "vitest run",
79
+ "dev": "vite --config client/vite.config.ts",
80
+ "preview": "vite preview --config client/vite.config.ts"
81
+ }
6
82
  }
package/src/game.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * INKWRIT: the card battler template (play style:
3
+ * card battler).
4
+ *
5
+ * Scribes duel in a lamplit study. Every card is an inked creature or a
6
+ * spell written on a page: blot imps, ledger crabs, folio wyrms, a splash
7
+ * of ink. One against one, a person against a person or against the
8
+ * gauntlet's AI scribes. A run is four duels; after each, you draft one of
9
+ * three new cards into your deck.
10
+ *
11
+ * This file is the whole game as the model writes it: declarations only,
12
+ * against the typed API in @netforge/engine. The duel, the draft, the
13
+ * shuffle, hidden hands, the AI, netcode, renderer and referee are the
14
+ * template's machinery.
15
+ */
16
+ import { card, cardTable, earn, game, store, upgrade } from "@netforge/engine";
17
+
18
+ export default game({
19
+ template: "cards",
20
+ style: "lowpoly",
21
+ places: { study: { chunks: "places/study", spawn: [0, 0, 0] } },
22
+ players: { prefab: "style.lowpoly.hooded_adventurer", max: 2 },
23
+ });
24
+
25
+ // The table: hero health, hands, the board, the gauntlet and its AI scribes (tier n plays duel n against the AI).
26
+ cardTable({
27
+ health: 20,
28
+ startHand: 3,
29
+ handMax: 7,
30
+ boardMax: 5,
31
+ manaMax: 10,
32
+ turnSeconds: 30,
33
+ draftSeconds: 15,
34
+ duels: 4,
35
+ starter: ["blot_imp", "blot_imp", "quill_sprite", "quill_sprite", "ink_frog", "ink_frog", "margin_bat", "ledger_crab", "serif_fox", "splash", "splash", "blotting_sand"],
36
+ ai: [
37
+ { health: 14, smart: 0.3 },
38
+ { health: 16, smart: 0.55 },
39
+ { health: 20, smart: 0.75 },
40
+ { health: 24, smart: 0.9 },
41
+ ],
42
+ });
43
+
44
+ // Units: cost in mana, attack and health. guard units must be attacked first; rush units attack the turn they land.
45
+ card("blot_imp", { model: "style.lowpoly.imp", kind: "unit", cost: 1, attack: 2, health: 1 });
46
+ card("quill_sprite", { model: "style.lowpoly.bee", kind: "unit", cost: 1, attack: 1, health: 2, rush: true });
47
+ card("ink_frog", { model: "style.lowpoly.frog", kind: "unit", cost: 2, attack: 2, health: 3 });
48
+ card("margin_bat", { model: "style.lowpoly.bat", kind: "unit", cost: 2, attack: 3, health: 1, rush: true });
49
+ card("ledger_crab", { model: "style.lowpoly.crab", kind: "unit", cost: 3, attack: 2, health: 5, guard: true });
50
+ card("serif_fox", { model: "style.lowpoly.fox", kind: "unit", cost: 3, attack: 3, health: 3, effect: { kind: "draw", amount: 1 } });
51
+ card("gloss_wolf", { model: "style.lowpoly.wolf", kind: "unit", cost: 4, attack: 4, health: 4 });
52
+ card("stamp_golem", { model: "style.lowpoly.yeti", kind: "unit", cost: 5, attack: 4, health: 7, guard: true });
53
+ card("folio_wyrm", { model: "style.lowpoly.dragon_whelp", kind: "unit", cost: 6, attack: 5, health: 5, effect: { kind: "damage", amount: 2, target: "all_enemies" } });
54
+ card("grand_errata", { model: "style.lowpoly.dragon", kind: "unit", cost: 8, attack: 8, health: 8 });
55
+ // Spells: an effect only. "enemy" lets the player pick an enemy unit or the hero.
56
+ card("splash", { model: "style.lowpoly.crystal", kind: "spell", cost: 1, effect: { kind: "damage", amount: 2, target: "enemy" } });
57
+ card("blotting_sand", { model: "style.lowpoly.gem_mid", kind: "spell", cost: 2, effect: { kind: "heal", amount: 5, target: "own_hero" } });
58
+ card("underline", { model: "style.lowpoly.gem_pink", kind: "spell", cost: 2, effect: { kind: "buff", amount: 2, target: "own_unit" } });
59
+ card("second_draft", { model: "style.lowpoly.chest", kind: "spell", cost: 3, effect: { kind: "draw", amount: 2 } });
60
+ card("ink_storm", { model: "style.lowpoly.meteor", kind: "spell", cost: 4, effect: { kind: "damage", amount: 2, target: "all_enemies" } });
61
+ card("final_word", { model: "style.lowpoly.crystal_rock", kind: "spell", cost: 5, effect: { kind: "damage", amount: 5, target: "enemy" } });
62
+
63
+ // Store upgrades: one level each, for the rest of the run (hero health, unit attack, first-turn mana, starting hand).
64
+ upgrade("thick_vellum", { stat: "maxHealth", step: 5, maxLevel: 1 });
65
+ upgrade("sharp_nib", { stat: "might", step: 1, maxLevel: 1 });
66
+ upgrade("quick_ink", { stat: "haste", step: 1, maxLevel: 1 });
67
+ upgrade("spare_page", { stat: "amount", step: 1, maxLevel: 1 });
68
+
69
+ // Earn (mandatory). Pays the game's coins (1 coin = 1 token), once per run each. Values are PLACEHOLDER.
70
+ // duel.won counts duels won against a person (never against the AI, never a forfeit); ai.beaten counts gauntlet AI scribes beaten.
71
+ earn.rule("first_ink", { when: "ai.beaten", n: 1, pay: 5, oncePer: "run" });
72
+ earn.rule("full_gauntlet", { when: "ai.beaten", n: 4, pay: 20, oncePer: "run" });
73
+ earn.rule("inked_duel", { when: "duel.won", n: 1, pay: 12, oncePer: "run" });
74
+ earn.rule("signed_and_sealed", { when: "duel.won", n: 3, pay: 25, oncePer: "run" });
75
+ // Buy-in run (the mandatory sink loop): stake coins at run start to earn above free play. Values are PLACEHOLDER.
76
+ earn.stake({ min: 10, max: 100, multiple: 2 });
77
+
78
+ // Store (the Kit), priced in coins: four run upgrades and two finishes for your quill, owned for good.
79
+ store.sku("thick_vellum", { coins: 40 });
80
+ store.sku("sharp_nib", { coins: 60 });
81
+ store.sku("quick_ink", { coins: 40 });
82
+ store.sku("spare_page", { coins: 50 });
83
+ store.sku("skin_vermilion", { coins: 300, grants: { cosmetic: 1 } });
84
+ store.sku("skin_gold_leaf", { coins: 500, grants: { cosmetic: 2 } });
85
+ // Losable gear: one level of its upgrade in every staked run, lost when that run makes under loseBelow of its most earn. PLACEHOLDER.
86
+ store.sku("bound_folio", { coins: 120, grants: { gear: "thick_vellum", loseBelow: 0.25 } });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Loads the game module once per process and compiles it. The room server,
3
+ * the client, the bots and the tests all start here.
4
+ */
5
+ import { collectManifest } from "@netforge/engine/game";
6
+ import type { GameManifest } from "@netforge/engine";
7
+ import { rulesAdapter, cardsGenre } from "@netforge/engine/rules";
8
+ import type { RulesInput, RulesWorld, CardsState } from "@netforge/engine/rules";
9
+ import type { SimAdapter } from "@netforge/engine/net";
10
+ import "./game.js";
11
+
12
+ /** Content version: bump with any change to game.ts (hot swap compares it). */
13
+ export const GAME_VERSION = "inkwrit-0.1.0";
14
+ export const GAME_NAME = "inkwrit";
15
+ /** Room template (as the Net Forge room server names it) and place used by the client and the bots. */
16
+ export const TEMPLATE = "cards";
17
+ export const PLACE = "study";
18
+
19
+ /**
20
+ * Collected as soon as the module is imported: one process may load more
21
+ * than one game module (the room server hosts every template), and each
22
+ * manifest takes its own game before the next module declares.
23
+ */
24
+ const MANIFEST = collectManifest();
25
+ let genre: ReturnType<typeof cardsGenre> | null = null;
26
+
27
+ /** The game's manifest (every declaration). */
28
+ export function cardsManifest(): GameManifest {
29
+ return MANIFEST;
30
+ }
31
+
32
+ /** The compiled card battler genre (table, cards, AI). */
33
+ export function cardsGame(): ReturnType<typeof cardsGenre> {
34
+ genre ??= cardsGenre(MANIFEST);
35
+ return genre;
36
+ }
37
+
38
+ /** The SimAdapter the room server and the client both run. */
39
+ export function cardsAdapter(): SimAdapter<RulesWorld<CardsState>, RulesInput> {
40
+ return rulesAdapter(cardsGame(), MANIFEST, { name: GAME_NAME, version: GAME_VERSION });
41
+ }
42
+
43
+ /** Cosmetic ids for store SKUs (0 is the default finish). */
44
+ export const COSMETICS: Readonly<Record<string, number>> = { skin_vermilion: 1, skin_gold_leaf: 2 };
package/src/store.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Store hooks (the Coins model): every
3
+ * game has coins, 1 coin = 1 game token, shown under the token's own name,
4
+ * symbol and logo. INKWRIT sells four run upgrades (a store command named
5
+ * after the upgrade grants one level for the rest of the run), two quill
6
+ * finishes (permanent) and one piece of losable gear, all priced in coins.
7
+ * The Kit bridge and its in-memory stand-in live in @netforge/tabletop.
8
+ */
9
+ export { mockKit } from "@netforge/tabletop";
10
+ export type { KitStoreBridge, MockKit, SkuPrice } from "@netforge/tabletop";
11
+
12
+ /** Coin prices the client is wired to (they match store.sku(...) in game.ts; the generator's gate keeps them). */
13
+ export const COIN_PRICES = { thick_vellum: 40, sharp_nib: 60, quick_ink: 40, spare_page: 50, skin_vermilion: 300, skin_gold_leaf: 500, bound_folio: 120 } as const;
14
+ /** Run upgrades: spent, applied by the room for the rest of the run. */
15
+ export const UPGRADE_SKUS = ["thick_vellum", "sharp_nib", "quick_ink", "spare_page"] as const;
16
+ /** Cosmetic finishes: bought once, owned for good. */
17
+ export const COSMETIC_SKUS = ["skin_vermilion", "skin_gold_leaf"] as const;
18
+
19
+ /** The template's own coin, as its token metadata names it until a creator renames it. */
20
+ export const INKWRIT_COIN = {
21
+ name: "Ink",
22
+ symbol: "INK",
23
+ logo: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='15' fill='%231f3550'/%3E%3Cpath d='M16 5c4 6 7 10 7 14a7 7 0 0 1-14 0c0-4 3-8 7-14z' fill='%23e8dcc0'/%3E%3C/svg%3E",
24
+ } as const;