+
Island editor
+
+
+
+
+
+
+
+
+
+
+
+ {mode === 'shape' ? (
+ <>
+
Height profile
+ {PROFILE_FIELDS.map((f) => (
+
+ ))}
+
Drag the orange handles to reshape the coastline.
+ >
+ ) : (
+ <>
+
Brush
+
+ {BRUSH_MODES.map((m) => (
+
+ ))}
+
+
+
+
Drag on the island to sculpt relief. Switch to Shape to edit the coastline.
+ >
+ )}
+
+
Scene
+
+
+
+
+
+
+
+ )
+}
diff --git a/island-editor/src/ui/panel.css b/island-editor/src/ui/panel.css
new file mode 100644
index 0000000..6e7ae47
--- /dev/null
+++ b/island-editor/src/ui/panel.css
@@ -0,0 +1,126 @@
+.tool-panel {
+ position: fixed;
+ top: 12px;
+ right: 12px;
+ width: 252px;
+ padding: 12px 14px 14px;
+ border-radius: 12px;
+ background: rgba(16, 22, 38, 0.82);
+ -webkit-backdrop-filter: blur(8px);
+ backdrop-filter: blur(8px);
+ color: #e7ecf6;
+ font: 12px/1.4 ui-sans-serif, system-ui, sans-serif;
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
+ opacity: 0.22;
+ transition: opacity 160ms ease;
+ user-select: none;
+ z-index: 10;
+}
+.tool-panel:hover {
+ opacity: 1;
+}
+
+.tool-panel__title {
+ font-weight: 600;
+ font-size: 13px;
+ margin-bottom: 8px;
+ letter-spacing: 0.02em;
+}
+
+.tool-panel__topbar {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ margin-bottom: 10px;
+}
+.tool-panel__tabs {
+ display: flex;
+ gap: 6px;
+ flex: 1;
+}
+.tool-panel__tabs button {
+ flex: 1;
+}
+.tool-panel__history {
+ display: flex;
+ gap: 4px;
+}
+.tool-panel__history button {
+ width: 26px;
+ padding: 5px 0;
+ font-size: 13px;
+ line-height: 1;
+}
+.tool-panel button:disabled {
+ opacity: 0.35;
+ cursor: default;
+}
+.tool-panel button:disabled:hover {
+ background: rgba(255, 255, 255, 0.05);
+}
+.tool-panel__modes {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 6px;
+ margin-bottom: 8px;
+}
+.tool-panel button {
+ appearance: none;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(255, 255, 255, 0.05);
+ color: #cfd8ea;
+ padding: 5px 8px;
+ border-radius: 7px;
+ font: inherit;
+ font-size: 11px;
+ cursor: pointer;
+ text-transform: capitalize;
+ transition: background 120ms, border-color 120ms, color 120ms;
+}
+.tool-panel button:hover {
+ background: rgba(255, 255, 255, 0.1);
+}
+.tool-panel button.is-active {
+ background: #ff7b54;
+ border-color: #ff7b54;
+ color: #1a1206;
+ font-weight: 600;
+}
+
+.tool-panel__section {
+ text-transform: uppercase;
+ font-size: 10px;
+ opacity: 0.55;
+ margin: 8px 0 4px;
+ letter-spacing: 0.08em;
+}
+.tool-panel__row {
+ display: grid;
+ grid-template-columns: 92px 1fr 40px;
+ align-items: center;
+ gap: 8px;
+ margin: 5px 0;
+}
+.tool-panel__label {
+ opacity: 0.85;
+}
+.tool-panel__row input[type='range'] {
+ width: 100%;
+ accent-color: #ff7b54;
+}
+.tool-panel__value {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ opacity: 0.7;
+}
+.tool-panel__hint {
+ font-size: 10px;
+ opacity: 0.5;
+ margin-top: 10px;
+ line-height: 1.35;
+}
+.tool-panel__actions {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 6px;
+}
diff --git a/island-editor/test/brush.test.ts b/island-editor/test/brush.test.ts
new file mode 100644
index 0000000..3f28b66
--- /dev/null
+++ b/island-editor/test/brush.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest'
+import { applyBrush } from '../src/terrain/brush'
+import type { ReliefGrid } from '../src/terrain/islandSpec'
+
+function emptyRelief(resolution = 65): ReliefGrid {
+ return { resolution, data: new Array(resolution * resolution).fill(0) }
+}
+
+const WORLD = 24
+
+describe('sculpt brush', () => {
+ it('raise lifts the center cell the most', () => {
+ const relief = emptyRelief()
+ applyBrush(relief, WORLD, 0, 0, { radius: 4, strength: 0.5, mode: 'raise' })
+ const res = relief.resolution
+ const center = relief.data[Math.floor(res / 2) * res + Math.floor(res / 2)]
+ const corner = relief.data[0]
+ expect(center).toBeGreaterThan(0)
+ expect(center).toBeGreaterThan(corner)
+ expect(corner).toBe(0) // outside the brush radius
+ })
+
+ it('lower is the inverse of raise', () => {
+ const relief = emptyRelief()
+ applyBrush(relief, WORLD, 0, 0, { radius: 4, strength: 0.5, mode: 'lower' })
+ const res = relief.resolution
+ const center = relief.data[Math.floor(res / 2) * res + Math.floor(res / 2)]
+ expect(center).toBeLessThan(0)
+ })
+
+ it('flatten pulls neighbors toward the center value', () => {
+ const relief = emptyRelief()
+ // first raise a bump, then flatten should reduce variance around center
+ applyBrush(relief, WORLD, 0, 0, { radius: 5, strength: 0.8, mode: 'raise' })
+ const before = relief.data.slice()
+ applyBrush(relief, WORLD, 0, 0, { radius: 5, strength: 0.6, mode: 'flatten' })
+ // values changed (flatten did something)
+ expect(relief.data).not.toEqual(before)
+ })
+
+ it('only touches cells within the radius', () => {
+ const relief = emptyRelief()
+ applyBrush(relief, WORLD, 0, 0, { radius: 2, strength: 0.5, mode: 'raise' })
+ // a far cell stays zero
+ expect(relief.data[0]).toBe(0)
+ })
+})
diff --git a/island-editor/test/commandStack.test.ts b/island-editor/test/commandStack.test.ts
new file mode 100644
index 0000000..dbb6632
--- /dev/null
+++ b/island-editor/test/commandStack.test.ts
@@ -0,0 +1,163 @@
+import { describe, it, expect } from 'vitest'
+import { createCommandStack } from '../src/editor/commandStack'
+import type { Command } from '../src/editor/commandStack'
+
+// Helper: build a command that appends a label to a log on do/undo.
+function makeCmd(log: string[], doLabel: string, undoLabel: string): Command {
+ return {
+ label: doLabel,
+ do: () => log.push(doLabel),
+ undo: () => log.push(undoLabel),
+ }
+}
+
+describe('commandStack', () => {
+ describe('push then undo', () => {
+ it('runs the correct undo function and flips canUndo / canRedo', () => {
+ const stack = createCommandStack()
+ const log: string[] = []
+ const cmd = makeCmd(log, 'do-a', 'undo-a')
+
+ stack.push(cmd)
+ expect(stack.canUndo()).toBe(true)
+ expect(stack.canRedo()).toBe(false)
+ expect(log).toEqual([]) // push must NOT call do()
+
+ const result = stack.undo()
+ expect(result).toBe(true)
+ expect(log).toEqual(['undo-a'])
+ expect(stack.canUndo()).toBe(false)
+ expect(stack.canRedo()).toBe(true)
+ })
+
+ it('returns false when there is nothing to undo', () => {
+ const stack = createCommandStack()
+ expect(stack.undo()).toBe(false)
+ })
+ })
+
+ describe('redo', () => {
+ it('runs the correct do function and moves the command back to the undo stack', () => {
+ const stack = createCommandStack()
+ const log: string[] = []
+ const cmd = makeCmd(log, 'do-b', 'undo-b')
+
+ stack.push(cmd)
+ stack.undo()
+ log.length = 0 // reset log to isolate redo
+
+ const result = stack.redo()
+ expect(result).toBe(true)
+ expect(log).toEqual(['do-b'])
+ expect(stack.canUndo()).toBe(true)
+ expect(stack.canRedo()).toBe(false)
+ })
+
+ it('returns false when there is nothing to redo', () => {
+ const stack = createCommandStack()
+ expect(stack.redo()).toBe(false)
+ })
+ })
+
+ describe('push clears the redo stack', () => {
+ it('discards redo entries when a new command is pushed', () => {
+ const stack = createCommandStack()
+ const log: string[] = []
+ const a = makeCmd(log, 'do-a', 'undo-a')
+ const b = makeCmd(log, 'do-b', 'undo-b')
+ const c = makeCmd(log, 'do-c', 'undo-c')
+
+ stack.push(a)
+ stack.push(b)
+ stack.undo() // b moves to redo
+ expect(stack.canRedo()).toBe(true)
+
+ stack.push(c) // should clear redo
+ expect(stack.canRedo()).toBe(false)
+ expect(stack.size()).toBe(2) // a and c
+ })
+ })
+
+ describe('capacity eviction', () => {
+ it('evicts the oldest undo entry when capacity is exceeded', () => {
+ const stack = createCommandStack(2)
+ const log: string[] = []
+ const a = makeCmd(log, 'do-a', 'undo-a')
+ const b = makeCmd(log, 'do-b', 'undo-b')
+ const c = makeCmd(log, 'do-c', 'undo-c')
+
+ stack.push(a)
+ stack.push(b)
+ stack.push(c) // exceeds capacity of 2; a should be evicted
+
+ expect(stack.size()).toBe(2)
+
+ // Undo twice: should see c then b, NOT a
+ stack.undo()
+ stack.undo()
+ expect(log).toEqual(['undo-c', 'undo-b'])
+ expect(stack.canUndo()).toBe(false)
+ })
+ })
+
+ describe('clear', () => {
+ it('empties both the undo and redo stacks', () => {
+ const stack = createCommandStack()
+ const log: string[] = []
+ const a = makeCmd(log, 'do-a', 'undo-a')
+ const b = makeCmd(log, 'do-b', 'undo-b')
+
+ stack.push(a)
+ stack.push(b)
+ stack.undo() // b β redo
+
+ stack.clear()
+
+ expect(stack.canUndo()).toBe(false)
+ expect(stack.canRedo()).toBe(false)
+ expect(stack.size()).toBe(0)
+ expect(stack.undo()).toBe(false)
+ expect(stack.redo()).toBe(false)
+ })
+ })
+
+ describe('multi-command undo/redo ordering', () => {
+ it('undoes commands in LIFO order and redoes in the reverse of that', () => {
+ const stack = createCommandStack()
+ const log: string[] = []
+ const a = makeCmd(log, 'do-a', 'undo-a')
+ const b = makeCmd(log, 'do-b', 'undo-b')
+ const c = makeCmd(log, 'do-c', 'undo-c')
+
+ stack.push(a)
+ stack.push(b)
+ stack.push(c)
+
+ stack.undo() // c
+ stack.undo() // b
+ expect(log).toEqual(['undo-c', 'undo-b'])
+
+ log.length = 0
+ stack.redo() // b
+ stack.redo() // c
+ expect(log).toEqual(['do-b', 'do-c'])
+ })
+ })
+
+ describe('size', () => {
+ it('reflects the number of undoable commands', () => {
+ const stack = createCommandStack()
+ const cmd = makeCmd([], 'do', 'undo')
+
+ expect(stack.size()).toBe(0)
+ stack.push(cmd)
+ expect(stack.size()).toBe(1)
+ stack.push(cmd)
+ expect(stack.size()).toBe(2)
+ stack.undo()
+ expect(stack.size()).toBe(1)
+ stack.redo()
+ expect(stack.size()).toBe(2)
+ })
+ })
+})
diff --git a/island-editor/test/exportSpec.test.ts b/island-editor/test/exportSpec.test.ts
new file mode 100644
index 0000000..77608f9
--- /dev/null
+++ b/island-editor/test/exportSpec.test.ts
@@ -0,0 +1,96 @@
+// NOTE: downloadSpec and importSpecFromFile are browser-only (Blob, FileReader,
+// document.createElement) and are intentionally not unit-tested here.
+// They are exercised manually / in browser integration tests.
+
+import { describe, expect, it } from 'vitest'
+import { seedFromCurrentIsland } from '../src/terrain/islandSpec'
+import { deserializeSpec, serializeSpec } from '../src/editor/exportSpec'
+
+describe('exportSpec', () => {
+ const spec = seedFromCurrentIsland()
+
+ describe('serializeSpec β deserializeSpec round-trip', () => {
+ it('produces a JSON string', () => {
+ const json = serializeSpec(spec)
+ expect(typeof json).toBe('string')
+ expect(() => JSON.parse(json)).not.toThrow()
+ })
+
+ it('round-trips to a deep-equal spec', () => {
+ const json = serializeSpec(spec)
+ const restored = deserializeSpec(json)
+ expect(restored).toEqual(spec)
+ })
+
+ it('round-trips with a small custom spec', () => {
+ const custom = seedFromCurrentIsland(6, 4)
+ const restored = deserializeSpec(serializeSpec(custom))
+ expect(restored).toEqual(custom)
+ })
+ })
+
+ describe('deserializeSpec β malformed JSON', () => {
+ it('throws on completely invalid JSON', () => {
+ expect(() => deserializeSpec('not json at all')).toThrow('Invalid island spec: malformed JSON')
+ })
+
+ it('throws on truncated JSON', () => {
+ expect(() => deserializeSpec('{"version":1,')).toThrow(
+ 'Invalid island spec: malformed JSON',
+ )
+ })
+ })
+
+ describe('deserializeSpec β valid JSON, wrong shape', () => {
+ it('throws when version !== 1', () => {
+ const bad = JSON.stringify({ ...spec, version: 2 })
+ expect(() => deserializeSpec(bad)).toThrow('Invalid island spec: version must be 1')
+ })
+
+ it('throws when worldSize is not finite', () => {
+ const bad = JSON.stringify({ ...spec, worldSize: Infinity })
+ expect(() => deserializeSpec(bad)).toThrow(
+ 'Invalid island spec: worldSize must be a finite number',
+ )
+ })
+
+ it('throws when worldSize is missing', () => {
+ const { worldSize: _ws, ...rest } = spec as unknown as { worldSize: number } & Record