Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
# Run `make extract` with your own copy of the map (see tools/extractor/README.md).
/reference/
/data/extracted/
# Decoded map imagery (minimap, route overlays) — derived Blizzard/WC3 assets,
# kept local like the map file itself; not redistributed.
/data/reference/

# Local agent/editor tooling and scratch (preview config, workflow scripts).
.claude/

node_modules/
dist/
Expand Down
77 changes: 77 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# CLAUDE.md — working rules for this repo

A faithful, standalone **browser** recreation of the Warcraft III custom map
**BattleShips Pro v1.187** (by Sked, edited by Quantum_Theory): 5v5 naval
combat, three lanes, auto-firing weapon arcs, creeps, towers, traders, missile
silos. Original code and art only — see the design intent in [README.md](README.md)
and [docs/DESIGN.md](docs/DESIGN.md). **For current state / where to pick up, read
[docs/STATUS.md](docs/STATUS.md) first.**

## Hard rules (do not violate)

1. **Never commit the map or decoded game assets.** The original
`reference/BattleShipsPro_v1.187.w3x` and decoded imagery under
`data/reference/` are NOT redistributed (both are `.gitignore`d). The project
ships extracted *numbers* (`data/json/`), never Blizzard/WC3 assets or the map
file. If you regenerate data, do it from your own copy via `make extract`.
2. **The simulation is deterministic and must stay that way.** `packages/core`
runs at a fixed 20 ticks/s with a seeded `mulberry32` RNG and produces
bit-identical replays for seed-equal inputs (`hashState`; an AI-only
seed-equal replay test enforces this). Never use `Date.now()`/`Math.random()`
or wall-clock/iteration-order nondeterminism in the sim — take randomness from
the seeded `Rng`. A change that breaks the determinism test is a regression.
3. **The server is authoritative.** The client compiles the ruleset only for
*display* (`packages/client/src/catalog.ts` `getCatalog()`); every gameplay
outcome is decided by `packages/server` running the same `@bships/core` sim.
Never make the client trust its own sim for outcomes.
4. **Fidelity to the original beats everything else.** Gameplay numbers and
behaviors come from the map script (`data/extracted/war3map.j`) and object
data; deliberate divergences are documented in [docs/SEMANTICS.md](docs/SEMANTICS.md)
and [docs/BALANCE.md](docs/BALANCE.md). The owner is a former competitive
player and prioritizes gameplay TRUTH over graphics. When unsure, match the
JASS, and don't "improve" balance silently.
5. **Green before commit/merge.** `pnpm build`, `pnpm test`, and `pnpm lint` must
all pass. Don't mark work done or merge on red.

## Verify like the owner plays

UX claims have repeatedly been "verified" via screenshots/subagent reports and
then failed in real play. Don't trust those for live behavior. Prove logic with
**deterministic tests** (drive the real sim / pure HUD functions), and prove UI
by reading **DOM + game state** (not screenshots) in a real browser, or by the
owner confirming. Don't declare a UX fix working until one of those holds.

## Architecture

| Package | Runtime | Role |
| --- | --- | --- |
| `packages/core` (`@bships/core`) | shared | Deterministic fixed-tick sim: movement/pathfinding, weapon arcs, creeps, towers, income, abilities, AI. `compileClassicRuleset(rawDataFiles)` builds the `Ruleset` from `data/json/`. |
| `packages/server` (`@bships/server`) | node | Authoritative WebSocket server: lobbies, 5v5 rooms, solo-vs-AI, reconnect. Per tick: `applyCommands` → `stepTick`, sends snapshots. |
| `packages/client` (`@bships/client`) | browser | PixiJS v8 + Vite, WC3-style HUD. Pure HUD logic lives in `src/hud/hudmath.ts` (unit-tested with no DOM). |
| `packages/stats` (`@bships/stats`) | node | Global stats board (`node:sqlite`): anonymous-claim accounts, match history, ladder. |
| `tools/extractor` | python | `.w3x` → `data/json/` object data + script. |

## Commands

```sh
pnpm install
pnpm build # pnpm -r build
pnpm test # pnpm -r test (vitest)
pnpm lint # eslint .
pnpm dev # client :5173, server :8787, stats :8088
make extract # regenerate data/json from reference/BattleShipsPro_v1.187.w3x (yours)
```

Requires Node ≥ 22, pnpm 10; the extractor needs Python ≥ 3.10. TypeScript is
strict, ESM throughout, tests are vitest.

## Conventions

- Match the surrounding code's style, naming, and comment density.
- HUD logic that can be pure goes in `hudmath.ts` with a unit test; DOM wiring in
the panel modules (`inventory.ts`, `shop.ts`, …).
- Ships are WC3 **hero units**: `ShipSpec.name` is the generic class (drives the
sprite); `ShipSpec.properName` is the distinct hull name shown in UI.
- Commit messages end with the co-author trailer:
`Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`
- Branch off `main` for changes; open a PR to merge.
24 changes: 21 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,27 @@ extract: $(VENV)/bin/python
$(VENV)/bin/python tools/extractor/extract.py $(MAP)
$(MAKE) terrain

# Parse the WC3 pathing map (war3map.wpm, already in data/extracted) into the
# static land/water mask data/json/terrain.json. Pure stdlib: no venv needed,
# and reproducible without the (gitignored) .w3x.
# Build the static land/water mask data/json/terrain.json by CLASSIFYING the map's
# own embedded minimap (data/reference/war3mapMap.png, owner-confirmed correct) per
# terrain tile via the owner's CONFIRMED colour key: SAILABLE WATER = NON-BLUE
# (yellow deep + green shallow + pink passable), LAND = only the blue-dominant ridge
# pixels. Then carve only MINIMAL 1-cell necks so every shop + dock/spawn reaches the
# sea and the two bases stay connected, PLUS the two owner-approved WEST sail-around
# island moats (Swedish Lumber Mill + Goblin Potion Dealer: a 25-cell land core ringed
# by a closed 1-cell water loop with EXACTLY ONE entrance each; the green shallow water
# already rings the blue cores, so the loops largely emerge naturally).
# war3map.w3e is read only for the grid geometry, then CROPPED to the playable
# rectangle (the unplayable border removed; the WEST bound is extended 3 cells west
# of the camera bounds so the Goblin Potion Dealer shop sits off the grid edge on a
# sail-around island -- see terrain.py WEST_EXTEND_CELLS). The optional `depth` field
# (0=land,1=deep,2=shallow,3=pink) is additive render metadata the sim IGNORES. Pure
# stdlib (a pure-stdlib PNG decoder reads the committed minimap): no venv needed,
# byte-reproducible without the (gitignored) .w3x. Also writes the 3-panel
# data/reference/colorkey-compare.png (minimap | rebuilt 4-shade mask + shop dots |
# land-vs-water diff) and the zoomed [before | after] data/reference/westedge-compare.png
# of the two west sail-around island rings, and prints the colour-key agreement. Fails
# loud if the mask does not pass the structures-on-water / base-to-base /
# all-shops-reachable / colour-key-agreement / water-fraction gates.
terrain:
python3 tools/extractor/terrain.py

Expand Down
9 changes: 8 additions & 1 deletion data/json/map-layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"minY": -8192.0,
"maxX": 5312.0,
"maxY": 6656.0,
"note": "matches editor Entire_Map rect and war3map.w3i playable size 85x116 tiles at 128 units/tile"
"note": "editor Entire_Map rect (war3map.w3i playable size 85x116 tiles at 128 units/tile). NOTE: this is NOT the rect the runtime uses for the map extent. The compiled MapSpec.bounds (camera, minimap, movement clamp) and data/json/terrain.json are CROPPED to cameraBoundsW3i below — the playable rectangle that matches the embedded minimap; the editor Entire_Map rect still includes part of the unplayable border, which threw off lane proportions. Kept here for provenance / region math."
},
"cameraBoundsW3i": {
"minX": -4992.0,
Expand All @@ -27,6 +27,13 @@
"right": 6,
"bottom": 4,
"top": 8
},
"note": "THE PLAYABLE RECTANGLE used at runtime, with the WEST bound then extended 3 cells (384u) further west (see westBoundExtension below): MapSpec.bounds + terrain.json bounds are minX=-5440, minY=-7424, maxX=4864, maxY=6912 (the unplayable asymmetric border cropped: 8 tiles north, 4 south, 5 west, 6 east; then the west bound pushed 3 cells out so the Goblin Potion Dealer shop sits off the grid edge on a sail-around island). Chosen because it matches the embedded minimap content; the terrain extractor crops the w3e tilepoint grid to the tilepoints whose center lies in this rect (cols 6..86 => 81 wide after the west extension, rows 6..118 => 113 tall, 128 units/tile).",
"westBoundExtension": {
"cellsAddedWest": 3,
"extendedMinX": -5440.0,
"cols": 81,
"note": "Owner-approved: the camera-bounds crop (minX=-4992) placed the Goblin Potion Dealer (world x=-4960) on grid col 0 (the west edge), so it could not be a sail-around island. The minimap content + w3e tile-edge extent reach west to x=-6144, so there is real minimap content west of the camera bound. terrain.py (WEST_EXTEND_CELLS=3) extends the playable west bound by 3 whole 128u cells (only minX/cols change), filling the new west columns from the SAME minimap trace, so the Goblin shop lands on grid col 3 == the minimum island-anchor col and sits on a compact 25-cell land core ringed by a closed 1-cell moat with exactly one entrance (a true sail-around island, like the Swedish Lumber Mill)."
}
},
"cameraBoundsScript": {
Expand Down
2 changes: 1 addition & 1 deletion data/json/terrain.json

Large diffs are not rendered by default.

81 changes: 81 additions & 0 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Project status / session handoff

Snapshot for picking the project up in a new session. Durable rules are in
[CLAUDE.md](../CLAUDE.md); owner design decisions in [DESIGN.md](DESIGN.md);
engine semantics in [SEMANTICS.md](SEMANTICS.md); balance audit in
[BALANCE.md](BALANCE.md).

_Last updated: 2026-06-17._

## Where it stands

The game **boots and plays solo-vs-AI** end to end: core sim + authoritative
server + PixiJS client + stats board are all functional. Test counts at this
snapshot: **client 398, core 520, server 143**; lint clean; full build green;
determinism (AI-only seed-equal replay) intact.

To run: `pnpm install && pnpm dev`, open the client (`:5173`), **Create room →
Play vs AI** for a solo match.

## Done in the recent gameplay push

- **Pathfinding** routes ships around landmasses instead of hanging on them
(`packages/core/src/sim/movement.ts`, NavField/water-mask).
- **AI trader** is seated in solo-vs-AI and runs trade routes / reaches the
repair-refinery station (`packages/core/src/sim/ai.ts`, `packages/server/src/rooms.ts`).
- **Wave-imitating lane ribbons removed** from the client (`render/fieldoverlay.ts`).
- **Multi-ability HUD cast bar**: each hull shows one quick-key per castable
ability incl. active `special`s (`hud/hudmath.ts` `shipAbilitySlots`).
- **Shop direction arrow** no longer overlaps an inventory hotkey (`hud/hud.ts`).
- **Ability learn flow fixed (was genuinely broken).** The "+1pt" level-up badge
only rendered on *castable* slots, so the **passive** hero skills (Enforced/
Reinforced/Super Hull, Onboard Mechanics Crew, Ship Sails, auras) — most of a
hull's progression — had no badge anywhere and could never be ranked; hulls
whose only castable skill was level-gated showed no badges at all ("other ships
show no skills"). Fix: `shipPassiveLearnableSkills()` + a dedicated **SKILLS**
strip above the inventory bar (`hud/inventory.ts`, `.bh-skillstrip` in
`hud/hud.ts`) with the same +1pt badge, so every learnable skill on every hull
is reachable. Distinct icons for hull/sails/repair/true-sight. A deterministic
test asserts **0 orphan learnable skills** across all hulls. Verified live in a
solo match by DOM/state reads: clicking a passive's + ranked it 0→1 and spent
the point through the full client→server→sim→snapshot round-trip.
- **Distinct ship names.** Hulls compiled to the generic class name (several were
all "Battle Ship" / "Cruiser"). Added `ShipSpec.properName` from the WC3 Proper
Name (`upro`) field (`core/src/sim/ruleset.ts` `properShipName`): Sailor,
Crusader, Interceptor, Sea Punisher, Dominator, Destroyer, Overlord, Juggernaut,
Battle Royal, The Black Pearl, Elven predator, Ghost armada, Goblin Junker,
Trader, Leviathian, Submarine. Shown in shop/scoreboard/banner/gallery; the
renderer still keys the sprite off `.name`.

## Ability/skill model (faithful — don't "simplify")

Ships are hero units. You gain skill points (1 per level, Dota-style) and choose
what to rank. Bigger ships have more skills. Both **passive** skills (hull HP,
sail speed, mechanics/repair regen, auras — ranked in the SKILLS strip) and
**active** skills (Captain's Cannon, Fishing Net, Capsize, Hide, Dive, EMP, …
cast from the quick-key bar) are leveled with points. Innate abilities (Shore
Leave, True Sight) have no skill rule and are always available.

## Open / next

- **In-game crash (task #15) — still unreproduced.** 150+ sim-minutes of headless
AI play and this session's solo match threw nothing; likely client-side
render/HUD. If it recurs, capture *what action* triggered it (buying a ship?
casting? a shop? level-up? a death?) — that narrows it fast.
- **Exhaustive ability cast-system audit — re-run in the new session.** At the
end of this session a multi-agent audit was running to *drive the sim and
learn+cast every ability on every player hull* (proving each fires, not just
the learn path), adversarially refute the results, and statically hunt the
crash. Background workflows don't transfer across sessions — re-launch an
equivalent audit and address any real defects it finds. The learn path and
names are already verified; the broad **cast-fires-an-effect** path across all
exotic `special` kinds is the main thing still to confirm end to end.
- **Map fidelity (task #14) — PARKED.** The color-key terrain is "pretty close";
revisit later. Owner has described the topology (lanes, islands, entrances).

## Verifying UX reliably (lesson learned)

Screenshots/subagent "tests green" have missed real breakage. For HUD/ability
work: prove logic with deterministic tests, and confirm UI by reading **DOM +
store state** (e.g. `store.match.you.heroSkillLevels`, the `.bh-skillstrip`
chips) in a real browser — or have the owner confirm — before calling it done.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"build": "pnpm -r build",
"test": "pnpm -r test",
"lint": "eslint .",
"dev": "pnpm --filter @bships/core build && STATS_INGEST_SECRET=\"${STATS_INGEST_SECRET:-dev-local-secret}\" STATS_URL=\"${STATS_URL:-http://localhost:8088}\" concurrently -k -n stats,server,client -c magenta,blue,green \"pnpm --filter @bships/stats dev\" \"pnpm --filter @bships/server dev\" \"pnpm --filter @bships/client dev\"",
"dev": "pnpm --filter @bships/core build && STATS_INGEST_SECRET=\"${STATS_INGEST_SECRET:-dev-local-secret}\" STATS_URL=\"${STATS_URL:-http://localhost:8088}\" concurrently -k -n stats,server,client -c magenta,blue,green \"pnpm --filter @bships/stats dev\" \"PORT=8787 pnpm --filter @bships/server dev\" \"pnpm --filter @bships/client dev\"",
"start": "pnpm -r build && STATS_INGEST_SECRET=\"${STATS_INGEST_SECRET:?set STATS_INGEST_SECRET for production}\" STATS_URL=\"${STATS_URL:-http://localhost:8088}\" concurrently -k -n stats,server -c magenta,blue \"pnpm --filter @bships/stats start\" \"pnpm --filter @bships/server start\""
},
"devDependencies": {
Expand Down
5 changes: 3 additions & 2 deletions packages/client/src/gallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,8 @@ function buildShips(stage: Container): void {
let col = 0;
let rowPair = 0;
for (const [typeId, spec] of ships) {
const name = (spec as { name: string }).name;
const s = spec as { name: string; properName?: string };
const name = s.properName ?? s.name;
const gold = (spec as { gold: number }).gold;

// South on the upper half-row, North on the lower half-row of one band.
Expand All @@ -252,7 +253,7 @@ function buildShips(stage: Container): void {
const subIds = ships.filter(([, s]) => (s as { isSub?: boolean }).isSub).map(([id]) => id);
let sc = 0;
for (const id of subIds) {
const nm = (catalog.ships[id] as { name: string }).name;
const nm = (catalog.ships[id] as { name: string; properName?: string }).properName ?? (catalog.ships[id] as { name: string }).name;
const surf = cell(stage, sc, 0, subY, `${nm}`, 'surfaced');
previewShip(surf.container, id, 'south', SHIP_DISPLAY_SCALE, false);
const sub = cell(stage, sc + 1, 0, subY, `${nm}`, 'submerged');
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/hud/banner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function initBanner(ctx: HudContext): void {
let endBuiltFor: TeamId | null | 'none' = 'none';

function shipName(typeId: string): string {
return ctx.catalog.ships[typeId]?.name ?? typeId;
return ctx.catalog.ships[typeId]?.properName ?? typeId;
}

function buildEndPanel(winner: TeamId | null): void {
Expand Down
9 changes: 7 additions & 2 deletions packages/client/src/hud/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { onEvent, store } from '../net/store.js';
import { bindingFor, onAction } from '../input/keymap.js';
import type { HudContext } from './context.js';
import { el } from './context.js';
import { keyLabel } from './hudmath.js';
import { keyLabel, rejectionMessage } from './hudmath.js';

const MAX_LINES = 9;
const LINE_TTL_MS = 12000;
Expand Down Expand Up @@ -75,9 +75,14 @@ export function initChat(ctx: HudContext): void {
store.subscribe(drainChat);

// -- own command rejections (kill lines are now in killfeed.ts) -------------
// Surface the sim's terse rejection reason as a helpful, human line: a rank-0
// hero skill ('notLearned'), a Shore-Leave-away-from-harbour ('notAtMainHarbour'),
// a missing target, etc. The event only carries commandType (not the specific
// ability), so the message is reason-driven; the inventory bar adds the named
// hint when it blocks a cast locally before sending.
onEvent((ev) => {
if (ev.type === 'commandRejected' && ev.player === store.match.mySlot) {
pushLine(`Cannot ${ev.commandType}: ${ev.reason}`, 'bh-system bh-reject');
pushLine(rejectionMessage(ev.reason, null), 'bh-system bh-reject');
}
});

Expand Down
Loading
Loading