@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.
- package/AGENTS.md +71 -0
- package/CLAUDE.md +11 -0
- package/LICENSE +21 -0
- package/NOTICE +28 -0
- package/README.md +82 -2
- package/client/decor.ts +8 -0
- package/client/index.html +11 -0
- package/client/main.ts +688 -0
- package/client/names.ts +58 -0
- package/llms-full.txt +243 -0
- package/llms.txt +94 -0
- package/package.json +79 -3
- package/src/game.ts +86 -0
- package/src/manifest.ts +44 -0
- package/src/store.ts +24 -0
package/client/names.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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 } });
|
package/src/manifest.ts
ADDED
|
@@ -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;
|