@netforge/template-idle 0.0.0-stage → 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/LICENSE +21 -0
- package/NOTICE +27 -0
- package/README.md +42 -2
- package/client/decor.ts +5 -0
- package/client/index.html +11 -0
- package/client/main.ts +402 -0
- package/client/names.ts +44 -0
- package/llms.txt +78 -0
- package/package.json +54 -3
- package/src/autopilot.ts +58 -0
- package/src/game.ts +68 -0
- package/src/manifest.ts +44 -0
- package/src/store.ts +20 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NetNet Capital Management
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
@netforge/template-idle (BRINEWORKS)
|
|
2
|
+
Copyright (c) 2026 NetNet Capital Management
|
|
3
|
+
|
|
4
|
+
An original game. No names, characters, maps, models, sounds or code from
|
|
5
|
+
any other game or franchise.
|
|
6
|
+
|
|
7
|
+
Art and sound
|
|
8
|
+
- Models come from the per-style asset library
|
|
9
|
+
(@netforge/asset-library): CC0 1.0 models by Kenney and
|
|
10
|
+
Quaternius (Space Kit, Nature Kit, ships), each pack listed with its
|
|
11
|
+
source URL and licence verification in that package's NOTICE and
|
|
12
|
+
manifest/packs.json. The files are fetched and built, never committed
|
|
13
|
+
here; the build copies only the models this game uses.
|
|
14
|
+
- Fallback shapes (@netforge/tabletop stage.ts) are generated from three.js
|
|
15
|
+
primitives; all textures are drawn on a canvas at load; sounds are the
|
|
16
|
+
library's CC0 samples with WebAudio synth fallbacks.
|
|
17
|
+
|
|
18
|
+
Dependencies (unmodified, via npm)
|
|
19
|
+
- three.js 0.186.1 (https://github.com/mrdoob/three.js), MIT.
|
|
20
|
+
- @netforge/engine and @netforge/engine-sim (this repository);
|
|
21
|
+
see their NOTICE files (bitECS MPL-2.0, Rapier Apache-2.0, Colyseus MIT,
|
|
22
|
+
zod MIT).
|
|
23
|
+
|
|
24
|
+
Patterns (ideas only)
|
|
25
|
+
- The idle and incremental genre's general mechanics (producers bought
|
|
26
|
+
and levelled with rising costs, a click action, a production chain).
|
|
27
|
+
Mechanics, not expression; no names, art or text were copied.
|
package/README.md
CHANGED
|
@@ -1,3 +1,43 @@
|
|
|
1
|
-
#
|
|
1
|
+
# BRINEWORKS (idle template)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A white salt flat under a hot sky. Up to eight players each run a works:
|
|
4
|
+
pumps draw brine, pans boil it into salt, kilns cut crystals, and salt buys
|
|
5
|
+
more plants. A barge on the canal takes crystals for a contract everyone
|
|
6
|
+
fills together. A shift lasts fifteen minutes; then a new one begins.
|
|
7
|
+
|
|
8
|
+
A board genre (`packages/engine/src/rules/idle.ts`) on the shared runtime;
|
|
9
|
+
client kit `@netforge/tabletop`. Room template `idle` (cap 8).
|
|
10
|
+
|
|
11
|
+
## Why it cannot be farmed
|
|
12
|
+
|
|
13
|
+
- Production runs only in the room's sim, on its ticks, for players in the
|
|
14
|
+
room. No wall clock, no offline accrual, no client-reported numbers.
|
|
15
|
+
- A works stalls two minutes after its last tend (rake, buy, ship): an idle
|
|
16
|
+
tab makes nothing.
|
|
17
|
+
- Raking has a cooldown; mashing adds nothing.
|
|
18
|
+
- Earn rules pay once a run. A seat that leaves and rejoins starts a fresh
|
|
19
|
+
works from 20 kg of salt. The genre's gate check simulates a fresh works
|
|
20
|
+
with perfect play and with pure raking: the first payout comes 199 s in
|
|
21
|
+
(the floor is 180 s), and the most one seat can earn is about 550 coins an
|
|
22
|
+
hour even rejoining every time a rule pays, before the per-wallet daily
|
|
23
|
+
cap, the free-play lifetime cap and the stake terms. With full shifts the
|
|
24
|
+
four rules pay at most 80 coins a shift (320 an hour).
|
|
25
|
+
- One earning seat per wallet (the room's seat lock), and the run replays
|
|
26
|
+
from its inputs for the referee.
|
|
27
|
+
|
|
28
|
+
## Run it
|
|
29
|
+
|
|
30
|
+
This template runs on your own computer with play money through
|
|
31
|
+
`kit play` (`@netforge/kit-play`). From the repository root:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pnpm install --frozen-lockfile && pnpm build
|
|
35
|
+
mkdir -p play-idle/src && cp packages/template-idle/src/game.ts play-idle/src/
|
|
36
|
+
echo '{"template":"idle"}' > play-idle/netnet.json
|
|
37
|
+
node packages/kit-play/dist/cli.js play-idle # opens http://127.0.0.1:4663
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Edit `play-idle/src/game.ts` and run it again to try a change. Real-token
|
|
41
|
+
earning, stakes and claims run only on the hosted Net Forge server.
|
|
42
|
+
|
|
43
|
+
Space rakes (or click your plot), 1-5 buy a plant, S ships crystals.
|
package/client/decor.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Library models the scene draws on its own: the barge, crystals, the flat's edge. Copied into ./library in every art style. */
|
|
2
|
+
export const BARGE_MODEL = "style.lowpoly.cargo_ship";
|
|
3
|
+
export const CRYSTAL_MODEL = "style.lowpoly.crystal";
|
|
4
|
+
export const EDGE_MODELS = ["style.lowpoly.rock_small", "style.lowpoly.cactus", "style.lowpoly.rock_tall", "style.lowpoly.tent"] as const;
|
|
5
|
+
export const SCENE_MODELS: readonly string[] = [BARGE_MODEL, CRYSTAL_MODEL, ...EDGE_MODELS];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
|
|
6
|
+
<title>Brineworks</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<script type="module" src="./main.ts"></script>
|
|
10
|
+
</body>
|
|
11
|
+
</html>
|
package/client/main.ts
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BRINEWORKS client: your works is one of eight plots on a shared salt
|
|
3
|
+
* flat. Rake (Space or the big button) to bring in salt by hand and keep the
|
|
4
|
+
* works tended, buy and level plants from the bar, ship crystals to the
|
|
5
|
+
* barge. The room makes every litre, kilo and crystal on its own clock;
|
|
6
|
+
* your foreman (the cursor) is predicted, so pointing never waits.
|
|
7
|
+
*
|
|
8
|
+
* URL parameters: see @netforge/tabletop boot.ts.
|
|
9
|
+
*/
|
|
10
|
+
import { BoxGeometry, CircleGeometry, Color, ConeGeometry, DoubleSide, Group, Mesh, MeshBasicMaterial, MeshStandardMaterial, PlaneGeometry, RingGeometry } from "three/webgpu";
|
|
11
|
+
import type { InterpolatedEntity } from "@netforge/engine/net";
|
|
12
|
+
import { IdleAct, plantSpot, plotCenter, PLOT, RF, RulesKind, UNITS, IDLE_SEATS } from "@netforge/engine/rules";
|
|
13
|
+
import type { RulesInput } from "@netforge/engine/rules";
|
|
14
|
+
import { bootBoard, cssColor, type Placed } from "@netforge/tabletop";
|
|
15
|
+
import { COSMETICS, GAME_VERSION, idleAdapter, idleGame, idleManifest, PLACE, TEMPLATE } from "../src/manifest.js";
|
|
16
|
+
import { BRINE_COIN } from "../src/store.js";
|
|
17
|
+
import { idleAutopilot, type IdleView } from "../src/autopilot.js";
|
|
18
|
+
import { BARGE_MODEL, CRYSTAL_MODEL, EDGE_MODELS } from "./decor.js";
|
|
19
|
+
import { KEEPER_COLORS, RULE_NAMES, SKU_NAMES, TITLE, UPGRADE_NAMES, WEAPON_NAMES } from "./names.js";
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
const genre = idleGame();
|
|
23
|
+
const g = genre.game;
|
|
24
|
+
const manifest = idleManifest();
|
|
25
|
+
const FLAT_W = PLOT.cols * (PLOT.w + PLOT.gapX) + 4;
|
|
26
|
+
const FLAT_H = 2 * PLOT.d + PLOT.gapZ + 4;
|
|
27
|
+
const FINISH = [0xd8c39a, 0xf4f1ea, 0xb5552e];
|
|
28
|
+
const fmt = (n: number) => (n >= 10_000 ? `${(n / 1000).toFixed(1)}t` : `${Math.floor(n).toLocaleString("en-US")}`);
|
|
29
|
+
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
|
30
|
+
|
|
31
|
+
let lastView: IdleView | null = null;
|
|
32
|
+
const autopilot = (tick: number): RulesInput | null => idleAutopilot(lastView, tick);
|
|
33
|
+
|
|
34
|
+
async function main(): Promise<void> {
|
|
35
|
+
const app = await bootBoard({
|
|
36
|
+
adapter: idleAdapter(),
|
|
37
|
+
manifest,
|
|
38
|
+
template: TEMPLATE,
|
|
39
|
+
place: PLACE,
|
|
40
|
+
version: GAME_VERSION,
|
|
41
|
+
labels: { title: TITLE, rules: RULE_NAMES, skus: SKU_NAMES, upgrades: UPGRADE_NAMES, loading: "Opening the sluices", help: "Space rake · 1-5 buy a plant · S ship crystals · ` stats" },
|
|
42
|
+
theme: {
|
|
43
|
+
accent: "#2aa7d8",
|
|
44
|
+
gold: "#e8a33c",
|
|
45
|
+
ink: "#1d2a33",
|
|
46
|
+
dim: "#5d6f7b",
|
|
47
|
+
panel: "rgba(250, 247, 240, 0.9)",
|
|
48
|
+
bg: "#efe9dc",
|
|
49
|
+
font: '"Futura", "Century Gothic", "Trebuchet MS", system-ui, sans-serif',
|
|
50
|
+
display: '"Futura", "Century Gothic", system-ui, sans-serif',
|
|
51
|
+
radius: 2,
|
|
52
|
+
},
|
|
53
|
+
coin: BRINE_COIN,
|
|
54
|
+
cosmetics: COSMETICS,
|
|
55
|
+
swatch: (c) => `linear-gradient(135deg, ${cssColor(FINISH[c] ?? 0xd8c39a)}, #2a3a44)`,
|
|
56
|
+
ground: { w: FLAT_W, h: FLAT_H },
|
|
57
|
+
preset: "day",
|
|
58
|
+
theme3d: "desert",
|
|
59
|
+
groundColor: 0xe9e1cc,
|
|
60
|
+
pitch: 1.05,
|
|
61
|
+
autopilot,
|
|
62
|
+
soundKey: "brineworks:sound",
|
|
63
|
+
});
|
|
64
|
+
const { stage, hud, clock, sfx } = app;
|
|
65
|
+
sfx.define("rake", { wave: "noise", f0: 1800, f1: 600, ms: 90, gain: 0.35 }, { minGapMs: 60, voices: 3, pitchVar: 0.15 });
|
|
66
|
+
sfx.define("crate", { wave: "triangle", f0: 520, f1: 780, ms: 140, gain: 0.5 }, { minGapMs: 100, voices: 2, pitchVar: 0 });
|
|
67
|
+
sfx.define("ship", { wave: "sine", f0: 300, f1: 900, ms: 260, gain: 0.55 }, { minGapMs: 200, voices: 1, pitchVar: 0 });
|
|
68
|
+
|
|
69
|
+
// ---- The flat: plots, the canal and its barge, the edge of the pan.
|
|
70
|
+
const plotMat = (mine: boolean) => new MeshStandardMaterial({ color: mine ? 0xe6f6fb : 0xc9dde2, roughness: 0.35, metalness: 0.05, emissive: new Color(mine ? 0x2a6a80 : 0x0e2a33), emissiveIntensity: mine ? 0.35 : 0.15 });
|
|
71
|
+
const plots: Mesh[] = [];
|
|
72
|
+
for (let slot = 0; slot < IDLE_SEATS; slot++) {
|
|
73
|
+
const c = plotCenter(slot);
|
|
74
|
+
const m = new Mesh(new BoxGeometry(PLOT.w, 0.08, PLOT.d), plotMat(false));
|
|
75
|
+
m.position.set(c.x, stage.groundAt(c.x, c.z) + 0.04, c.z);
|
|
76
|
+
m.receiveShadow = true;
|
|
77
|
+
stage.add(m);
|
|
78
|
+
plots.push(m);
|
|
79
|
+
// Low berms around each plot.
|
|
80
|
+
for (const [dx, dz, w, d] of [
|
|
81
|
+
[0, -PLOT.d / 2, PLOT.w, 0.3],
|
|
82
|
+
[0, PLOT.d / 2, PLOT.w, 0.3],
|
|
83
|
+
[-PLOT.w / 2, 0, 0.3, PLOT.d],
|
|
84
|
+
[PLOT.w / 2, 0, 0.3, PLOT.d],
|
|
85
|
+
] as const) {
|
|
86
|
+
const berm = new Mesh(new BoxGeometry(w, 0.28, d), new MeshStandardMaterial({ color: 0xc9bc9d, roughness: 1 }));
|
|
87
|
+
berm.position.set(c.x + dx, m.position.y + 0.12, c.z + dz);
|
|
88
|
+
stage.add(berm);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const canal = new Mesh(new PlaneGeometry(FLAT_W + 30, PLOT.gapZ * 0.8), new MeshStandardMaterial({ color: 0x3b9cc4, roughness: 0.2, metalness: 0.1, emissive: new Color(0x0a3448) }));
|
|
92
|
+
canal.rotation.x = -Math.PI / 2;
|
|
93
|
+
canal.position.y = 0.02;
|
|
94
|
+
stage.add(canal);
|
|
95
|
+
const barge = stage.model(BARGE_MODEL, 2.2, 0x8a6a4a, 7);
|
|
96
|
+
barge.root.position.set(0, 0.1, 0);
|
|
97
|
+
barge.root.rotation.y = Math.PI / 2;
|
|
98
|
+
stage.add(barge.root);
|
|
99
|
+
const hold = new Mesh(new BoxGeometry(1, 1, 1), new MeshStandardMaterial({ color: 0xbfe8ff, emissive: new Color(0x4fb6e0), emissiveIntensity: 0.6, roughness: 0.3 }));
|
|
100
|
+
hold.position.set(0, 1.2, 0);
|
|
101
|
+
hold.scale.set(4, 0.01, 1.6);
|
|
102
|
+
stage.add(hold);
|
|
103
|
+
let seed = 5;
|
|
104
|
+
const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
|
|
105
|
+
for (const id of EDGE_MODELS) {
|
|
106
|
+
const at: { x: number; z: number; yaw: number; s: number }[] = [];
|
|
107
|
+
for (let i = 0; i < 9; i++) {
|
|
108
|
+
const side = rnd() < 0.5 ? -1 : 1;
|
|
109
|
+
at.push({ x: (rnd() - 0.5) * (FLAT_W + 20), z: side * (FLAT_H / 2 + 2 + rnd() * 8), yaw: rnd() * 6.3, s: 0.7 + rnd() * 0.6 });
|
|
110
|
+
}
|
|
111
|
+
const crowd = stage.crowd(id, id.endsWith("tent") ? 2.4 : 1.6, at.length, 0xb59a7a);
|
|
112
|
+
app.loop((_, now) => {
|
|
113
|
+
crowd.begin();
|
|
114
|
+
for (const p of at) crowd.push(p.x, stage.groundAt(p.x, p.z), p.z, p.yaw, 0, 0, 0, 0, p.s);
|
|
115
|
+
crowd.end(now / 1000);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---- Works: plant models, brine pools, salt piles, crystals; foremen.
|
|
120
|
+
const plantModels = new Map<number, { placed: Placed; level: number; type: number }>();
|
|
121
|
+
const piles = new Map<number, { pool: Mesh; pile: Mesh; crystals: Placed[]; salt: number }>();
|
|
122
|
+
const foremen = new Map<number, Group>();
|
|
123
|
+
const pileFor = (id: number) => {
|
|
124
|
+
let p = piles.get(id);
|
|
125
|
+
if (p) return p;
|
|
126
|
+
const pool = new Mesh(new CircleGeometry(1, 28), new MeshStandardMaterial({ color: 0x5fc3e8, roughness: 0.15, metalness: 0.1, transparent: true, opacity: 0.85 }));
|
|
127
|
+
pool.rotation.x = -Math.PI / 2;
|
|
128
|
+
const pile = new Mesh(new ConeGeometry(1, 1, 12), new MeshStandardMaterial({ color: 0xffffff, roughness: 0.6, emissive: new Color(0x404040) }));
|
|
129
|
+
stage.add(pool, pile);
|
|
130
|
+
p = { pool, pile, crystals: [], salt: 0 };
|
|
131
|
+
piles.set(id, p);
|
|
132
|
+
return p;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// ---- The bar: rake, plants, ship; the works meter.
|
|
136
|
+
const style = document.createElement("style");
|
|
137
|
+
style.textContent = `
|
|
138
|
+
#bar { left: 50%; bottom: max(10px, env(safe-area-inset-bottom)); transform: translateX(-50%); display: flex; gap: 6px; align-items: stretch; }
|
|
139
|
+
#bar button { pointer-events: auto; }
|
|
140
|
+
#rake { width: 92px; font-weight: 900; letter-spacing: 0.12em; background: var(--accent); color: #fff; border: none; position: relative; overflow: hidden; }
|
|
141
|
+
#rake i { position: absolute; left: 0; bottom: 0; height: 4px; background: rgba(255,255,255,0.8); }
|
|
142
|
+
#bar .plant { width: min(118px, 17vw); padding: 6px 8px; text-align: left; display: grid; gap: 1px; }
|
|
143
|
+
#bar .plant b { font-size: 12px; }
|
|
144
|
+
#bar .plant span { font-size: 10px; color: var(--dim); }
|
|
145
|
+
#bar .plant i { font-style: normal; color: var(--gold); font-weight: 800; font-size: 12px; }
|
|
146
|
+
#bar .plant.poor { opacity: 0.55; }
|
|
147
|
+
#bar .plant kbd { font: 700 10px ui-monospace, monospace; color: var(--accent); }
|
|
148
|
+
#ship { width: 90px; font-weight: 800; }
|
|
149
|
+
#meter { left: 50%; top: max(10px, env(safe-area-inset-top)); transform: translateX(-50%); width: min(380px, 46vw); text-align: center; font-size: 11px; letter-spacing: 0.14em; color: var(--dim); }
|
|
150
|
+
#meter .row { display: flex; justify-content: space-between; margin-top: 4px; font-weight: 700; color: var(--ink); letter-spacing: 0.04em; font-size: 13px; }
|
|
151
|
+
#meter .bar { height: 8px; background: rgba(0,0,0,0.12); border-radius: 2px; overflow: hidden; margin-top: 4px; }
|
|
152
|
+
#meter .bar > i { display: block; height: 100%; background: linear-gradient(90deg, #2aa7d8, #8fe1ff); transform-origin: left; }
|
|
153
|
+
#stall { left: 50%; top: 38%; transform: translate(-50%, -50%); font: 900 30px var(--display, inherit); letter-spacing: 0.18em; color: #b5552e; background: rgba(250,247,240,0.92); padding: 10px 18px; border: 2px solid #b5552e; display: none; }
|
|
154
|
+
#stall.show { display: block; }
|
|
155
|
+
.puff { position: fixed; z-index: 6; pointer-events: none; font: 800 14px var(--display, inherit); color: #e8a33c; text-shadow: 0 1px 0 #fff; transition: transform 700ms ease-out, opacity 700ms; }
|
|
156
|
+
@media (max-width: 560px), (max-height: 500px) { #bar .plant span { display: none; } #bar .plant { width: 74px; padding: 4px 5px; } #bar .plant b { font-size: 10px; } #rake { width: 70px; } #ship { width: 64px; } }
|
|
157
|
+
`;
|
|
158
|
+
document.head.append(style);
|
|
159
|
+
const bar = document.createElement("div");
|
|
160
|
+
bar.id = "bar";
|
|
161
|
+
bar.className = "hud";
|
|
162
|
+
document.body.append(bar);
|
|
163
|
+
const rakeBtn = document.createElement("button");
|
|
164
|
+
rakeBtn.id = "rake";
|
|
165
|
+
rakeBtn.innerHTML = "RAKE<i></i>";
|
|
166
|
+
bar.append(rakeBtn);
|
|
167
|
+
const cards = g.plants.map((p, i) => {
|
|
168
|
+
const c = document.createElement("button");
|
|
169
|
+
c.className = "plant";
|
|
170
|
+
c.innerHTML = `<kbd>${i + 1}</kbd><b></b><span></span><i></i>`;
|
|
171
|
+
(c.querySelector("b") as HTMLElement).textContent = WEAPON_NAMES[p.id]?.name ?? p.id;
|
|
172
|
+
c.addEventListener("click", () => buy(i));
|
|
173
|
+
bar.append(c);
|
|
174
|
+
return c;
|
|
175
|
+
});
|
|
176
|
+
const shipBtn = document.createElement("button");
|
|
177
|
+
shipBtn.id = "ship";
|
|
178
|
+
shipBtn.addEventListener("click", () => ship());
|
|
179
|
+
bar.append(shipBtn);
|
|
180
|
+
const meter = document.createElement("div");
|
|
181
|
+
meter.id = "meter";
|
|
182
|
+
meter.className = "hud";
|
|
183
|
+
meter.innerHTML = `CONTRACT<div class="bar"><i></i></div><div class="row"><span id="m-left"></span><span id="m-hold"></span></div>`;
|
|
184
|
+
document.body.append(meter);
|
|
185
|
+
const stall = document.createElement("div");
|
|
186
|
+
stall.id = "stall";
|
|
187
|
+
stall.className = "hud";
|
|
188
|
+
stall.textContent = "WORKS STOPPED · RAKE";
|
|
189
|
+
document.body.append(stall);
|
|
190
|
+
|
|
191
|
+
const rake = () => {
|
|
192
|
+
sfx.unlock();
|
|
193
|
+
if ((lastView?.rake ?? 1) > 0) return;
|
|
194
|
+
clock.act(IdleAct.Rake);
|
|
195
|
+
sfx.play("rake");
|
|
196
|
+
const c = plotCenter(lastView?.slot ?? 0);
|
|
197
|
+
stage.burst("dust", c.x + 4.6, 0.4, c.z + 3.4, 0xffffff, 8);
|
|
198
|
+
puff(`+${g.rakeSalt / 1000} kg`, c.x + 4.6, c.z + 3.4);
|
|
199
|
+
};
|
|
200
|
+
const buy = (i: number) => {
|
|
201
|
+
sfx.unlock();
|
|
202
|
+
const v = lastView;
|
|
203
|
+
if (!v?.costs || (v.salt ?? 0) < (v.costs[i] ?? Infinity)) {
|
|
204
|
+
hud.toast("Not enough salt");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
clock.act(IdleAct.Buy, i);
|
|
208
|
+
sfx.play("land");
|
|
209
|
+
};
|
|
210
|
+
const ship = () => {
|
|
211
|
+
const v = lastView;
|
|
212
|
+
if (!v || (v.crystals ?? 0) < 1 || v.filled) return;
|
|
213
|
+
clock.act(IdleAct.Ship, v.crystals ?? 1);
|
|
214
|
+
sfx.play("ship");
|
|
215
|
+
stage.burst("magic", 0, 1.5, 0, 0x8fe1ff, 20);
|
|
216
|
+
};
|
|
217
|
+
rakeBtn.addEventListener("click", rake);
|
|
218
|
+
addEventListener("keydown", (e) => {
|
|
219
|
+
if (e.code === "Space") {
|
|
220
|
+
e.preventDefault();
|
|
221
|
+
rake();
|
|
222
|
+
}
|
|
223
|
+
const n = Number(e.key);
|
|
224
|
+
if (n >= 1 && n <= g.plants.length) buy(n - 1);
|
|
225
|
+
if (e.code === "KeyS") ship();
|
|
226
|
+
});
|
|
227
|
+
// Clicking your own plot rakes too; pointing walks your foreman.
|
|
228
|
+
app.canvas.addEventListener("pointermove", (e) => {
|
|
229
|
+
const p = stage.pick(e.clientX, e.clientY, app.canvas.getBoundingClientRect());
|
|
230
|
+
if (p) clock.cursor(p.x, p.z);
|
|
231
|
+
});
|
|
232
|
+
app.canvas.addEventListener("pointerdown", (e) => {
|
|
233
|
+
const p = stage.pick(e.clientX, e.clientY, app.canvas.getBoundingClientRect());
|
|
234
|
+
if (!p) return;
|
|
235
|
+
clock.cursor(p.x, p.z);
|
|
236
|
+
const c = plotCenter(lastView?.slot ?? 0);
|
|
237
|
+
if (Math.abs(p.x - c.x) < PLOT.w / 2 && Math.abs(p.z - c.z) < PLOT.d / 2) rake();
|
|
238
|
+
});
|
|
239
|
+
function puff(text: string, x: number, z: number): void {
|
|
240
|
+
const s = stage.project(x, 1.5, z, app.canvas.clientWidth, app.canvas.clientHeight);
|
|
241
|
+
const el = document.createElement("div");
|
|
242
|
+
el.className = "puff";
|
|
243
|
+
el.textContent = text;
|
|
244
|
+
el.style.left = `${s.x}px`;
|
|
245
|
+
el.style.top = `${s.y}px`;
|
|
246
|
+
document.body.append(el);
|
|
247
|
+
requestAnimationFrame(() => {
|
|
248
|
+
el.style.transform = "translateY(-36px)";
|
|
249
|
+
el.style.opacity = "0";
|
|
250
|
+
});
|
|
251
|
+
setTimeout(() => el.remove(), 750);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let mySlot = -1;
|
|
255
|
+
let knownPacks = 0;
|
|
256
|
+
let knownFilled = false;
|
|
257
|
+
const m = (e: InterpolatedEntity, f: number) => (e.values[f] ?? 0) / UNITS;
|
|
258
|
+
app.loop((_dt, now) => {
|
|
259
|
+
const conn = app.conn();
|
|
260
|
+
const v = app.view() as unknown as IdleView | null;
|
|
261
|
+
lastView = v;
|
|
262
|
+
const view = conn.net.render(now);
|
|
263
|
+
const t = now / 1000;
|
|
264
|
+
if (v?.slot !== undefined && v.slot !== mySlot) {
|
|
265
|
+
const old = plots[mySlot];
|
|
266
|
+
if (old) old.material = plotMat(false);
|
|
267
|
+
mySlot = v.slot;
|
|
268
|
+
(plots[mySlot] as Mesh).material = plotMat(true);
|
|
269
|
+
}
|
|
270
|
+
const seenPlants = new Set<number>();
|
|
271
|
+
const seenPiles = new Set<number>();
|
|
272
|
+
for (const e of view.others) {
|
|
273
|
+
if (e.kind === RulesKind.Structure) {
|
|
274
|
+
seenPlants.add(e.id);
|
|
275
|
+
const type = Math.round(e.values[RF.type] ?? 0);
|
|
276
|
+
const level = Math.round(e.values[RF.value] ?? 0);
|
|
277
|
+
let pm = plantModels.get(e.id);
|
|
278
|
+
if (!pm) {
|
|
279
|
+
const def = g.plants[type]?.def;
|
|
280
|
+
const h = def?.kind === "kiln" ? 2.4 : def?.kind === "pan" ? 1.1 : 1.6;
|
|
281
|
+
const placed = stage.model(def?.model ?? "style.lowpoly.barrel", h, 0x9a8a6a, 2.4);
|
|
282
|
+
placed.root.position.set(m(e, RF.x), stage.groundAt(m(e, RF.x), m(e, RF.z)) + 0.08, m(e, RF.z));
|
|
283
|
+
stage.add(placed.root);
|
|
284
|
+
pm = { placed, level: 0, type };
|
|
285
|
+
plantModels.set(e.id, pm);
|
|
286
|
+
stage.burst("dust", m(e, RF.x), 0.5, m(e, RF.z), 0xe8dcc0, 14);
|
|
287
|
+
}
|
|
288
|
+
if (level > pm.level && pm.level > 0) stage.burst("sparks", m(e, RF.x), 1.6, m(e, RF.z), 0xffd76a, 10);
|
|
289
|
+
pm.level = level;
|
|
290
|
+
const running = (Math.round(e.values[RF.flags] ?? 0) & 1) !== 0;
|
|
291
|
+
const s = 1 + Math.min(10, level) * 0.04;
|
|
292
|
+
pm.placed.root.scale.set(s, s * (running ? 1 + Math.sin(t * 8 + e.id) * 0.03 : 1), s);
|
|
293
|
+
} else if (e.kind === RulesKind.Pickup) {
|
|
294
|
+
seenPiles.add(e.owner);
|
|
295
|
+
const p = pileFor(e.owner);
|
|
296
|
+
const x = m(e, RF.x);
|
|
297
|
+
const z = m(e, RF.z);
|
|
298
|
+
const brine = e.values[RF.hp] ?? 0;
|
|
299
|
+
const salt = e.values[RF.value] ?? 0;
|
|
300
|
+
const crystals = e.values[RF.aux] ?? 0;
|
|
301
|
+
const pr = 0.4 + Math.min(1.8, Math.sqrt(brine) * 0.05);
|
|
302
|
+
p.pool.position.set(x - 2.6, stage.groundAt(x, z) + 0.1, z);
|
|
303
|
+
p.pool.scale.setScalar(pr);
|
|
304
|
+
const ps = 0.3 + Math.min(2.2, Math.cbrt(salt) * 0.16);
|
|
305
|
+
p.pile.position.set(x, stage.groundAt(x, z) + ps * 0.5, z);
|
|
306
|
+
p.pile.scale.set(ps * 0.9, ps, ps * 0.9);
|
|
307
|
+
const want = Math.min(6, Math.floor(crystals));
|
|
308
|
+
while (p.crystals.length < want) {
|
|
309
|
+
const c = stage.model(CRYSTAL_MODEL, 0.8, 0x8fe1ff);
|
|
310
|
+
const k = p.crystals.length;
|
|
311
|
+
c.root.position.set(x + 1.4 + (k % 3) * 0.55, stage.groundAt(x, z) + 0.05, z - 0.5 + Math.floor(k / 3) * 0.6);
|
|
312
|
+
stage.add(c.root);
|
|
313
|
+
p.crystals.push(c);
|
|
314
|
+
}
|
|
315
|
+
while (p.crystals.length > want) stage.scene.remove((p.crystals.pop() as Placed).root);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
for (const [id, pm] of plantModels)
|
|
319
|
+
if (!seenPlants.has(id)) {
|
|
320
|
+
stage.scene.remove(pm.placed.root);
|
|
321
|
+
plantModels.delete(id);
|
|
322
|
+
}
|
|
323
|
+
for (const [id, p] of piles)
|
|
324
|
+
if (!seenPiles.has(id)) {
|
|
325
|
+
stage.scene.remove(p.pool, p.pile);
|
|
326
|
+
for (const c of p.crystals) stage.scene.remove(c.root);
|
|
327
|
+
piles.delete(id);
|
|
328
|
+
}
|
|
329
|
+
// Foremen: ours predicted, the others interpolated.
|
|
330
|
+
const seenF = new Set<number>();
|
|
331
|
+
const place = (e: InterpolatedEntity | undefined, owner: number) => {
|
|
332
|
+
if (!e) return;
|
|
333
|
+
seenF.add(owner);
|
|
334
|
+
let f = foremen.get(owner);
|
|
335
|
+
if (!f) {
|
|
336
|
+
f = new Group();
|
|
337
|
+
const col = KEEPER_COLORS[(owner - 1) % KEEPER_COLORS.length] ?? 0xffffff;
|
|
338
|
+
f.add(stage.model(manifest.game.players.prefab, 1.8, col).root);
|
|
339
|
+
const ring = new Mesh(new RingGeometry(0.45, 0.62, 24), new MeshBasicMaterial({ color: col, side: DoubleSide }));
|
|
340
|
+
ring.rotation.x = -Math.PI / 2;
|
|
341
|
+
ring.position.y = 0.12;
|
|
342
|
+
f.add(ring);
|
|
343
|
+
stage.add(f);
|
|
344
|
+
foremen.set(owner, f);
|
|
345
|
+
}
|
|
346
|
+
f.position.set(m(e, RF.x), stage.groundAt(m(e, RF.x), m(e, RF.z)) + 0.08, m(e, RF.z));
|
|
347
|
+
f.rotation.y = e.values[RF.yaw] ?? 0;
|
|
348
|
+
};
|
|
349
|
+
place(view.self, app.self);
|
|
350
|
+
for (const e of view.others) if (e.kind === RulesKind.Avatar) place(e, e.owner);
|
|
351
|
+
for (const [o, f] of foremen)
|
|
352
|
+
if (!seenF.has(o)) {
|
|
353
|
+
stage.scene.remove(f);
|
|
354
|
+
foremen.delete(o);
|
|
355
|
+
}
|
|
356
|
+
if (!v) return;
|
|
357
|
+
hold.scale.y = Math.max(0.01, (v.contract / Math.max(1, v.contractTarget)) * 1.4);
|
|
358
|
+
hold.position.y = 0.9 + hold.scale.y / 2;
|
|
359
|
+
// HUD.
|
|
360
|
+
hud.status([
|
|
361
|
+
["SALT", `${fmt(v.salt ?? 0)} kg`],
|
|
362
|
+
["BRINE", `${fmt(v.brine ?? 0)} L`],
|
|
363
|
+
["CRYSTALS", `${v.crystals ?? 0}`],
|
|
364
|
+
["NEXT CRATE", `${fmt(v.made ?? 0)} / ${fmt(v.nextPack ?? 0)} kg`],
|
|
365
|
+
]);
|
|
366
|
+
((meter.querySelector(".bar") as HTMLElement).firstChild as HTMLElement).style.transform = `scaleX(${Math.min(1, v.contract / Math.max(1, v.contractTarget))})`;
|
|
367
|
+
(meter.querySelector("#m-left") as HTMLElement).textContent = `SHIFT ${mmss(v.left)}`;
|
|
368
|
+
(meter.querySelector("#m-hold") as HTMLElement).textContent = v.filled ? "HOLD FULL" : `${v.contract} / ${v.contractTarget} crystals`;
|
|
369
|
+
const ready = (v.rake ?? 0) === 0;
|
|
370
|
+
rakeBtn.style.opacity = ready ? "1" : "0.75";
|
|
371
|
+
(rakeBtn.lastChild as HTMLElement).style.width = `${ready ? 100 : Math.max(0, 100 - ((v.rake ?? 0) / g.rakeTicks) * 100)}%`;
|
|
372
|
+
cards.forEach((c, i) => {
|
|
373
|
+
const cost = v.costs?.[i] ?? 0;
|
|
374
|
+
c.classList.toggle("poor", (v.salt ?? 0) < cost);
|
|
375
|
+
(c.querySelector("span") as HTMLElement).textContent = `Lv ${v.levels?.[i] ?? 0} · ${WEAPON_NAMES[g.plants[i]?.id ?? ""]?.line ?? ""}`;
|
|
376
|
+
(c.querySelector("i") as HTMLElement).textContent = `${fmt(cost)} kg`;
|
|
377
|
+
});
|
|
378
|
+
shipBtn.textContent = v.filled ? "Hold full" : `Ship ${v.crystals ?? 0}`;
|
|
379
|
+
shipBtn.disabled = v.filled || (v.crystals ?? 0) < 1;
|
|
380
|
+
stall.classList.toggle("show", v.stall === 0 && v.phase !== "ended" && !app.preview);
|
|
381
|
+
const packs = Math.floor((v.made ?? 0) / (g.packEvery / 1000));
|
|
382
|
+
if (packs > knownPacks && knownPacks > 0) {
|
|
383
|
+
sfx.play("crate");
|
|
384
|
+
if (packs % 5 === 0) hud.toast(`Crate ${packs}`);
|
|
385
|
+
}
|
|
386
|
+
knownPacks = packs;
|
|
387
|
+
if (v.filled && !knownFilled) {
|
|
388
|
+
hud.toast("The barge is full");
|
|
389
|
+
stage.ring(0, 0, 6, 0x8fe1ff);
|
|
390
|
+
sfx.play("levelUp");
|
|
391
|
+
}
|
|
392
|
+
knownFilled = v.filled;
|
|
393
|
+
hud.end(v.phase === "ended", v.filled ? "SHIFT DONE · HOLD FULL" : "SHIFT DONE", `${fmt(v.made ?? 0)} kg of salt made`, v.endsIn ?? null);
|
|
394
|
+
});
|
|
395
|
+
app.extraStats(() => ({ plants: plantModels.size, works: piles.size, salt: lastView?.salt ?? 0, made: lastView?.made ?? 0, contract: lastView?.contract ?? 0 }));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
main().catch((e: unknown) => {
|
|
399
|
+
const t = document.getElementById("banner-text");
|
|
400
|
+
if (t) t.textContent = `Could not start: ${e instanceof Error ? e.message : String(e)}`;
|
|
401
|
+
console.error(e);
|
|
402
|
+
});
|
package/client/names.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
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). Plants are the "weapons" table.
|
|
6
|
+
*/
|
|
7
|
+
export const TITLE = "Brineworks";
|
|
8
|
+
|
|
9
|
+
export const WEAPON_NAMES: Readonly<Record<string, { name: string; line: string }>> = {
|
|
10
|
+
hand_pump: { name: "Hand Pump", line: "Draws brine" },
|
|
11
|
+
boil_pan: { name: "Boil Pan", line: "Boils brine into salt" },
|
|
12
|
+
steam_pump: { name: "Steam Pump", line: "Draws a lot of brine" },
|
|
13
|
+
sun_pan: { name: "Sun Pan", line: "Boils fast" },
|
|
14
|
+
crystal_kiln: { name: "Crystal Kiln", line: "Cuts salt into crystals" },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const UPGRADE_NAMES: Readonly<Record<string, { name: string; line: string }>> = {
|
|
18
|
+
bellows: { name: "Bellows", line: "Plants work 15% faster" },
|
|
19
|
+
long_rake: { name: "Long Rake", line: "+30% salt a rake" },
|
|
20
|
+
quick_hands: { name: "Quick Hands", line: "Rake faster" },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Creatures by id (the flat has none; a game that adds some labels them here).
|
|
24
|
+
export const ENEMY_NAMES: Readonly<Record<string, string>> = {
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const RULE_NAMES: Readonly<Record<string, string>> = {
|
|
28
|
+
first_crate: "First Crates",
|
|
29
|
+
long_haul: "Long Haul",
|
|
30
|
+
full_hold: "Full Hold",
|
|
31
|
+
cutter: "Cutter",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const SKU_NAMES: Readonly<Record<string, string>> = {
|
|
35
|
+
bellows: "Bellows",
|
|
36
|
+
long_rake: "Long Rake",
|
|
37
|
+
quick_hands: "Quick Hands",
|
|
38
|
+
skin_saltwhite: "Saltwhite finish",
|
|
39
|
+
skin_rust: "Rust finish",
|
|
40
|
+
brass_bellows: "Brass bellows",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const KEEPER_COLORS = [0xffb347, 0x5ec8ff, 0x9dff7a, 0xff7ab8, 0xc9a7ff, 0xffe066, 0x7affd4, 0xff9a5c];
|
|
44
|
+
export const AURORA = { body: 0x3cf2c4, glow: 0xa77bff };
|
package/llms.txt
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# BRINEWORKS: the idle template (NetNet game runtime, board genre)
|
|
2
|
+
|
|
3
|
+
> A shared salt flat for up to 8 players. Each player runs a works on a
|
|
4
|
+
> plot: pumps draw brine, pans boil it into salt, kilns cut salt into
|
|
5
|
+
> crystals, and salt buys and levels more plants. Players rake by hand for
|
|
6
|
+
> salt, and everyone ships crystals to one contract barge. A shift has a
|
|
7
|
+
> fixed length; then a new shift starts from nothing.
|
|
8
|
+
> You change the game by editing src/game.ts only. The template already has
|
|
9
|
+
> the works, the production chain, buying and levelling, raking, tending,
|
|
10
|
+
> the contract, the shift clock, netcode, rendering, earn and the store.
|
|
11
|
+
|
|
12
|
+
## Files
|
|
13
|
+
|
|
14
|
+
- src/game.ts: THE GAME. game(), saltFlat(), plant(), upgrade(),
|
|
15
|
+
earn.rule(), earn.stake(), store.sku().
|
|
16
|
+
- src/names.json: the title and every label. Plants go in "weapons"
|
|
17
|
+
({ name, line }); "enemies" stays empty.
|
|
18
|
+
|
|
19
|
+
## Rules the build enforces (the anti-farm rules)
|
|
20
|
+
|
|
21
|
+
- game({ template: "idle", style, places, players: { prefab, max } }), max at most 8.
|
|
22
|
+
- Exactly one saltFlat(...); 1 to 8 plant(...)s with at least one "pump",
|
|
23
|
+
one "pan" and one "kiln" (the chain).
|
|
24
|
+
- A shift is 600 to 3600 seconds. Every earn rule is oncePer: "run" (a run
|
|
25
|
+
is a shift, or a player's time in it).
|
|
26
|
+
- tend is at most 300 seconds: a works nobody tends stops, so an idle tab
|
|
27
|
+
makes nothing. Production happens only on the room's clock while the
|
|
28
|
+
player is in the room; nothing is earned offline and a player who leaves
|
|
29
|
+
and comes back starts a fresh works.
|
|
30
|
+
- No earn rule may pay sooner than 180 seconds into a fresh works, even
|
|
31
|
+
with perfect play or raking as fast as the cooldown allows. The gate
|
|
32
|
+
measures it; if a draft pays sooner it fails with the number. Raise
|
|
33
|
+
packEvery or the rule's n, or slow raking.
|
|
34
|
+
- Upgrades read three stats: might (plant output), haste (raking speed),
|
|
35
|
+
amount (salt a rake). No weapon() or prefab().
|
|
36
|
+
- An earn rule must fire within about 4 minutes of 4-player play.
|
|
37
|
+
|
|
38
|
+
## Units
|
|
39
|
+
|
|
40
|
+
Seconds, litres of brine, kg of salt, whole crystals. A plant's rate is a
|
|
41
|
+
second, a level. Costs are kg of salt; each level costs growth times the last.
|
|
42
|
+
|
|
43
|
+
## Common changes
|
|
44
|
+
|
|
45
|
+
### Faster or slower progress
|
|
46
|
+
plant("hand_pump", { model: "style.lowpoly.barrel", kind: "pump", cost: 15, growth: 1.15, rate: 3 });
|
|
47
|
+
Lower growth makes levels cheap for longer; a higher rate makes each level count more.
|
|
48
|
+
Keep the first payout at least 180 s away (the gate checks).
|
|
49
|
+
|
|
50
|
+
### A new plant
|
|
51
|
+
plant("deep_bore", { model: "style.lowpoly.generator_large", kind: "pump", cost: 5000, growth: 1.2, rate: 200 });
|
|
52
|
+
Add its label to names.json weapons. The bar shows plants in order (keys 1-8).
|
|
53
|
+
|
|
54
|
+
### Longer or shorter shifts
|
|
55
|
+
saltFlat({ shift: 1200, ... }) (600 to 3600 s). A bigger contract: contract: { crystals: 120 }.
|
|
56
|
+
|
|
57
|
+
### Earn rules
|
|
58
|
+
earn.rule("first_crate", { when: "salt.packed", n: 30, pay: 5, oncePer: "run" });
|
|
59
|
+
Events: salt.packed (one each packEvery kg a player makes), crystal.cut,
|
|
60
|
+
crystal.shipped, plant.built, plant.upgraded (each credits the player),
|
|
61
|
+
contract.filled (the players who shipped to it; world progress, so never a
|
|
62
|
+
player whose run started after the shift began). Pays coins once a run.
|
|
63
|
+
|
|
64
|
+
### The buy-in run (required)
|
|
65
|
+
earn.stake({ min: 20, max: 200, multiple: 2 }); one top-level line. "stake" is reserved.
|
|
66
|
+
|
|
67
|
+
### Coins: earn and the store
|
|
68
|
+
store.sku("bellows", { coins: 40 }); // one level of "bellows" for the shift
|
|
69
|
+
store.sku("skin_rust", { coins: 500, grants: { cosmetic: 2 } }); // a finish for your foreman
|
|
70
|
+
store.sku("brass_bellows", { coins: 150, grants: { gear: "bellows", loseBelow: 0.25 } }); // losable gear
|
|
71
|
+
Keep the SKUs the client is wired to (bellows, long_rake, quick_hands,
|
|
72
|
+
skin_saltwhite, skin_rust, brass_bellows) at their prices.
|
|
73
|
+
|
|
74
|
+
## Test
|
|
75
|
+
|
|
76
|
+
engine.test.run({ ticks: 30 * 240, bots: 4 }) plays the flat and reports
|
|
77
|
+
crates packed (as kills), earned by rule, softlock, ms per tick and the
|
|
78
|
+
genre's checks (the farming bounds) under rules.checks.
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@netforge/template-idle",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "BRINEWORKS: the idle template of the NetNet game runtime, a shared salt flat for up to eight: pump brine, boil salt, cut crystals, fill the contract together. The game module, its browser client and its playtest bots.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/manifest.ts",
|
|
9
|
+
"./store": "./src/store.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"client",
|
|
14
|
+
"!client/vite.config.ts",
|
|
15
|
+
"llms.txt",
|
|
16
|
+
"README.md",
|
|
17
|
+
"NOTICE",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/netnetdev/net-forge.git",
|
|
26
|
+
"directory": "packages/template-idle"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/netnetdev/net-forge/tree/main/packages/template-idle#readme",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"three": "0.186.1",
|
|
34
|
+
"@netforge/asset-library": "0.1.0",
|
|
35
|
+
"@netforge/engine-sim": "0.1.0",
|
|
36
|
+
"@netforge/engine": "0.1.0",
|
|
37
|
+
"@netforge/tabletop": "0.1.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "22.20.1",
|
|
41
|
+
"@types/three": "0.186.0",
|
|
42
|
+
"tsx": "4.23.0",
|
|
43
|
+
"typescript": "5.8.3",
|
|
44
|
+
"vite": "6.4.3",
|
|
45
|
+
"vitest": "3.2.7"
|
|
46
|
+
},
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/netnetdev/net-forge/issues"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "node ../engine/scripts/fresh-dist.mjs && vite build --config client/vite.config.ts",
|
|
52
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"dev": "vite --config client/vite.config.ts",
|
|
55
|
+
"preview": "vite preview --config client/vite.config.ts"
|
|
56
|
+
}
|
|
6
57
|
}
|
package/src/autopilot.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The autopilot over a player's own run state (no DOM): the browser's
|
|
3
|
+
* ?autopilot=1 and the measure script's node clients play the works the
|
|
4
|
+
* way the genre's bot does, from what the room sends them.
|
|
5
|
+
*/
|
|
6
|
+
import { IdleAct, plotCenter } from "@netforge/engine/rules";
|
|
7
|
+
import type { RulesInput } from "@netforge/engine/rules";
|
|
8
|
+
import { idleGame } from "./manifest.js";
|
|
9
|
+
|
|
10
|
+
/** What the room sends one player (adapter infoFor over the genre's view). */
|
|
11
|
+
export interface IdleView {
|
|
12
|
+
readonly phase?: "playing" | "ended";
|
|
13
|
+
readonly outcome?: "won" | "lost" | null;
|
|
14
|
+
readonly endsIn?: number | null;
|
|
15
|
+
readonly earn?: readonly { id: string; when: string; n: number; have: number; paid: boolean }[];
|
|
16
|
+
readonly left: number;
|
|
17
|
+
readonly shift: number;
|
|
18
|
+
readonly contract: number;
|
|
19
|
+
readonly contractTarget: number;
|
|
20
|
+
readonly filled: boolean;
|
|
21
|
+
readonly players: readonly { id: number; slot: number; made: number; shipped: number; tended: boolean }[];
|
|
22
|
+
readonly slot?: number;
|
|
23
|
+
readonly brine?: number;
|
|
24
|
+
readonly salt?: number;
|
|
25
|
+
readonly crystals?: number;
|
|
26
|
+
readonly made?: number;
|
|
27
|
+
readonly nextPack?: number;
|
|
28
|
+
readonly levels?: readonly number[];
|
|
29
|
+
readonly costs?: readonly number[];
|
|
30
|
+
readonly stall?: number;
|
|
31
|
+
readonly rake?: number;
|
|
32
|
+
readonly rates?: { pump: number; pan: number; kiln: number };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const g = idleGame().game;
|
|
36
|
+
|
|
37
|
+
/** The browser's autopilot: rake when ready, buy what the chain lacks, ship crystals. */
|
|
38
|
+
export function idleAutopilot(v: IdleView | null, tick: number): RulesInput | null {
|
|
39
|
+
if (!v || v.salt === undefined || !v.levels || !v.costs || !v.rates || tick % 3 !== 0) return null;
|
|
40
|
+
const c = plotCenter(v.slot ?? 0);
|
|
41
|
+
const base = { mx: 0, mz: 0, cx: Math.round(c.x * 16), cz: Math.round((c.z + 2) * 16), b: 0, c: 0 };
|
|
42
|
+
const saltRate = Math.min(v.rates.pan, v.rates.pump / g.brinePerSalt);
|
|
43
|
+
const kind = v.left < v.shift * 0.65 && saltRate > v.rates.kiln * g.saltPerCrystal * 2 ? "kiln" : v.rates.pump / g.brinePerSalt <= v.rates.pan ? "pump" : "pan";
|
|
44
|
+
let best = -1;
|
|
45
|
+
let score = Infinity;
|
|
46
|
+
g.plants.forEach((p, i) => {
|
|
47
|
+
if (p.def.kind !== kind) return;
|
|
48
|
+
const sc = (v.costs?.[i] ?? Infinity) / p.def.rate;
|
|
49
|
+
if (sc < score) {
|
|
50
|
+
score = sc;
|
|
51
|
+
best = i;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
if (best >= 0 && v.salt >= (v.costs[best] ?? Infinity)) return { ...base, act: IdleAct.Buy, a: best };
|
|
55
|
+
if ((v.rake ?? 1) === 0) return { ...base, act: IdleAct.Rake, a: 0 };
|
|
56
|
+
if ((v.crystals ?? 0) >= 1 && !v.filled) return { ...base, act: IdleAct.Ship, a: v.crystals ?? 1 };
|
|
57
|
+
return null;
|
|
58
|
+
}
|
package/src/game.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BRINEWORKS: the idle template (play style: idle).
|
|
3
|
+
*
|
|
4
|
+
* A white salt flat under a hot sky. Up to eight players each run a works on
|
|
5
|
+
* the flat: pumps draw brine, pans boil it into salt, kilns cut salt into
|
|
6
|
+
* crystals, and salt buys and levels more plants. A barge on the canal
|
|
7
|
+
* takes crystals for a contract everyone fills together. A shift lasts
|
|
8
|
+
* fifteen minutes; then the flat is raked clean and a new shift begins.
|
|
9
|
+
*
|
|
10
|
+
* Works run only while their player is on the flat and has tended them in
|
|
11
|
+
* the last two minutes (a rake, a purchase, a shipment): no offline
|
|
12
|
+
* production, nothing for an idle tab.
|
|
13
|
+
*
|
|
14
|
+
* This file is the whole game as the model writes it: declarations only,
|
|
15
|
+
* against the typed API in @netforge/engine.
|
|
16
|
+
*/
|
|
17
|
+
import { earn, game, plant, saltFlat, store, upgrade } from "@netforge/engine";
|
|
18
|
+
|
|
19
|
+
export default game({
|
|
20
|
+
template: "idle",
|
|
21
|
+
style: "lowpoly",
|
|
22
|
+
places: { flat: { chunks: "places/flat", spawn: [0, 0, 0] } },
|
|
23
|
+
players: { prefab: "style.lowpoly.worker", max: 8 },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// The flat: a fifteen-minute shift, works that stall two minutes after the last tend,
|
|
27
|
+
// a rake every 0.4 s for 3 kg, 3 L of brine to a kg of salt, 25 kg of salt to a crystal, a crate every 150 kg.
|
|
28
|
+
saltFlat({
|
|
29
|
+
shift: 900,
|
|
30
|
+
tend: 120,
|
|
31
|
+
start: 20,
|
|
32
|
+
rake: { salt: 3, cooldown: 0.4 },
|
|
33
|
+
brinePerSalt: 3,
|
|
34
|
+
saltPerCrystal: 25,
|
|
35
|
+
packEvery: 150,
|
|
36
|
+
contract: { crystals: 60 },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Plants. cost is kg of salt for the first level, growth the price rise a level; rate is a second, a level
|
|
40
|
+
// (pumps: litres of brine; pans: kg of salt boiled; kilns: crystals cut).
|
|
41
|
+
plant("hand_pump", { model: "style.lowpoly.barrel", kind: "pump", cost: 15, growth: 1.15, rate: 3 });
|
|
42
|
+
plant("boil_pan", { model: "style.lowpoly.crate", kind: "pan", cost: 25, growth: 1.15, rate: 1 });
|
|
43
|
+
plant("steam_pump", { model: "style.lowpoly.generator", kind: "pump", cost: 400, growth: 1.18, rate: 30 });
|
|
44
|
+
plant("sun_pan", { model: "style.lowpoly.barrels", kind: "pan", cost: 600, growth: 1.18, rate: 10 });
|
|
45
|
+
plant("crystal_kiln", { model: "style.lowpoly.generator_large", kind: "kiln", cost: 900, growth: 1.25, rate: 0.05 });
|
|
46
|
+
|
|
47
|
+
// Store upgrades: one level each, for the rest of the shift, on the buyer's own works.
|
|
48
|
+
upgrade("bellows", { stat: "might", step: 0.15, maxLevel: 1 });
|
|
49
|
+
upgrade("long_rake", { stat: "amount", step: 0.3, maxLevel: 1 });
|
|
50
|
+
upgrade("quick_hands", { stat: "haste", step: 0.25, maxLevel: 1 });
|
|
51
|
+
|
|
52
|
+
// Earn (mandatory). Pays the game's coins (1 coin = 1 token), once a shift each. Values are PLACEHOLDER.
|
|
53
|
+
earn.rule("first_crate", { when: "salt.packed", n: 30, pay: 5, oncePer: "run" });
|
|
54
|
+
earn.rule("long_haul", { when: "salt.packed", n: 300, pay: 20, oncePer: "run" });
|
|
55
|
+
earn.rule("full_hold", { when: "contract.filled", n: 1, pay: 50, oncePer: "run" });
|
|
56
|
+
earn.rule("cutter", { when: "crystal.cut", n: 30, pay: 5, oncePer: "run" });
|
|
57
|
+
// Buy-in run (the mandatory sink loop): stake coins at shift start to earn above free play. Values are PLACEHOLDER.
|
|
58
|
+
earn.stake({ min: 20, max: 200, multiple: 2 });
|
|
59
|
+
|
|
60
|
+
// Store (the Kit), priced in coins: three shift upgrades, two finishes owned for good, one piece of losable gear.
|
|
61
|
+
store.sku("bellows", { coins: 40 });
|
|
62
|
+
store.sku("long_rake", { coins: 40 });
|
|
63
|
+
store.sku("quick_hands", { coins: 60 });
|
|
64
|
+
store.sku("skin_saltwhite", { coins: 300, grants: { cosmetic: 1 } });
|
|
65
|
+
store.sku("skin_rust", { coins: 500, grants: { cosmetic: 2 } });
|
|
66
|
+
// Losable gear: owned for good until lost. It brings one level of its upgrade into every staked shift,
|
|
67
|
+
// and is lost when that shift makes less than loseBelow of its most earn. Values are PLACEHOLDER.
|
|
68
|
+
store.sku("brass_bellows", { coins: 150, grants: { gear: "bellows", 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, idleGenre } from "@netforge/engine/rules";
|
|
8
|
+
import type { RulesInput, RulesWorld, IdleState } 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 = "brineworks-0.1.0";
|
|
14
|
+
export const GAME_NAME = "brineworks";
|
|
15
|
+
/** Room template (as the Net Forge room server names it) and place used by the client and the bots. */
|
|
16
|
+
export const TEMPLATE = "idle";
|
|
17
|
+
export const PLACE = "flat";
|
|
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 idleGenre> | null = null;
|
|
26
|
+
|
|
27
|
+
/** The game's manifest (every declaration). */
|
|
28
|
+
export function idleManifest(): GameManifest {
|
|
29
|
+
return MANIFEST;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The compiled idle genre (flat, plants). */
|
|
33
|
+
export function idleGame(): ReturnType<typeof idleGenre> {
|
|
34
|
+
genre ??= idleGenre(MANIFEST);
|
|
35
|
+
return genre;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The SimAdapter the room server and the client both run. */
|
|
39
|
+
export function idleAdapter(): SimAdapter<RulesWorld<IdleState>, RulesInput> {
|
|
40
|
+
return rulesAdapter(idleGame(), 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_saltwhite: 1, skin_rust: 2 };
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store hooks (the Coins model). BRINEWORKS
|
|
3
|
+
* sells three shift upgrades (a store command named after the upgrade grants
|
|
4
|
+
* one level for the rest of the shift), two finishes and one piece of
|
|
5
|
+
* losable gear, all priced in coins. The Kit bridge lives in @netforge/tabletop.
|
|
6
|
+
*/
|
|
7
|
+
export { mockKit } from "@netforge/tabletop";
|
|
8
|
+
export type { KitStoreBridge, MockKit, SkuPrice } from "@netforge/tabletop";
|
|
9
|
+
|
|
10
|
+
/** Coin prices the client is wired to (they match store.sku(...) in game.ts; the generator's gate keeps them). */
|
|
11
|
+
export const COIN_PRICES = { bellows: 40, long_rake: 40, quick_hands: 60, skin_saltwhite: 300, skin_rust: 500, brass_bellows: 150 } as const;
|
|
12
|
+
export const UPGRADE_SKUS = ["bellows", "long_rake", "quick_hands"] as const;
|
|
13
|
+
export const COSMETIC_SKUS = ["skin_saltwhite", "skin_rust"] as const;
|
|
14
|
+
|
|
15
|
+
/** The template's own coin until a creator renames it. */
|
|
16
|
+
export const BRINE_COIN = {
|
|
17
|
+
name: "Brine",
|
|
18
|
+
symbol: "BRINE",
|
|
19
|
+
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='%235ec8ff'/%3E%3Cpath d='M16 6l7 9-7 11-7-11z' fill='%23f6fbff'/%3E%3Cpath d='M16 6l7 9h-14z' fill='%23d6eefc'/%3E%3C/svg%3E",
|
|
20
|
+
} as const;
|