openvibers/openvibe.games Commits
Scraplandia: a first-steps checklist for new scrappers (gather, a Stone Axe, an oak, the quarry, the scrapyard, a haul) and branch and stone piles just inside every gate (#109)
Changes
@@@ -18,6 +18,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).### Added++- **First steps.** A new scrapper gets a checklist in the corner:++ - gather branches and stones (piles just inside every city gate);++ - craft a Stone Axe;++ - chop an oak in the west forest;++ - mine at the quarry;++ - salvage the scrapyard;++ - extract a haul.++++ Each step says where to go. Progress only moves forward and is kept in the browser, and the list can be closed.- **Scrap Island, Scraplandia's world (2026-10-19).** Production ran on an empty map document, so a new playerspawned over open water and drowned within seconds. `apps/server/src/maps/scrapIsland.ts` generates the island as anordinary v2 map document, the file the editor saves, so staff keep editing it in `/editor`. It is deterministic.@@@ -59,6 +59,7 @@ import { registerRiggingModule } from './weapons/riggingModule.js'import { ConstraintLineRenderer } from './render/constraintLines.js'import { PlacementGhost } from './game/placementGhost.js'import { WeaponSettings, weaponModuleFor } from './weapons/registry.js'++import { FirstSteps } from './ui/firstSteps.js'/*** Client bootstrap: engine/scene -> character customization (live preview)@@@ -234,6 +235,16 @@ async function start(): Promise<void> {guest: identity.token,...(sso ? { bearer: sso } : {}),})++ // Scraplandia's first-steps checklist (not in Salvage Rush or a creator world, whose worlds are something else).++ if (!worldId && content.world.id === 'scrap_island') {++ const firstSteps = new FirstSteps(uiRoot, () => ({++ count: (item) => state.countOf(item),++ level: (skill) => state.skillLevel(skill),++ }))++ state.events.on('inventory', () => firstSteps.update())++ state.events.on('skills', () => firstSteps.update())++ state.events.on('announce', (text) => firstSteps.onAnnounce(String(text)))++ }const roundUi =content.world.id === 'salvage_rush_arena'? await import('./ui/salvageRushHud.js').then(nnew file mode 100644@@@ -0,0 +1,88 @@++// @vitest-environment jsdom++import { beforeEach, describe, expect, it } from 'vitest'++import { FIRST_STEPS, FirstSteps, progress } from './firstSteps.js'++++class MemoryStorage {++ private map = new Map<string, string>()++ getItem(k: string): string | null {++ return this.map.get(k) ?? null++ }++ setItem(k: string, v: string): void {++ this.map.set(k, v)++ }++}++++let items: Record<string, number>++let levels: Record<string, number>++const read = () => ({ count: (i: string) => items[i] ?? 0, level: (s: string) => levels[s] ?? 1 })++let root: HTMLElement++let storage: MemoryStorage++++beforeEach(() => {++ items = {}++ levels = {}++ document.body.innerHTML = '<div id="ui"></div>'++ root = document.getElementById('ui')!++ storage = new MemoryStorage()++})++const steps = () => [...root.querySelectorAll('.first-steps li')]++const current = () => root.querySelector('.first-steps li.current')?.textContent ?? ''++++describe('first steps', () => {++ it('starts on gathering, with the hint for that step only', () => {++ new FirstSteps(root, read, storage as unknown as Storage)++ expect(steps()).toHaveLength(FIRST_STEPS.length)++ expect(current()).toContain('Gather branches and stones')++ expect(current()).toContain('inside every city gate')++ expect(root.querySelectorAll('.first-steps .hint')).toHaveLength(1)++ })++++ it('moves on as the player gathers, crafts and levels, and never goes back', () => {++ const fs = new FirstSteps(root, read, storage as unknown as Storage)++ items = { wood_log: 2, stone: 3 }++ fs.update()++ expect(current()).toContain('Craft a Stone Axe')++ items = { stone_axe: 1 }++ fs.update()++ expect(current()).toContain('Chop an oak')++ levels = { woodcutting: 2 }++ fs.update()++ expect(current()).toContain('Mine at the quarry')++ items = {} // dropped everything: progress stays++ levels = {}++ fs.update()++ expect(current()).toContain('Mine at the quarry')++ expect(root.querySelector('.first-steps-head')?.textContent).toContain('3/6')++ })++++ it('finishes with a haul and then hides', () => {++ const fs = new FirstSteps(root, read, storage as unknown as Storage)++ items = { stone_axe: 1, scrap_metal: 5 }++ levels = { woodcutting: 3, mining: 2 }++ fs.update()++ expect(current()).toContain('Extract a haul')++ fs.onAnnounce('🚁 Extracted! 0 stacks secured · 0 haul points.')++ expect(current()).toContain('Extract a haul')++ fs.onAnnounce('🚁 Extracted! 2 stacks secured · 140 haul points.')++ expect((root.querySelector('.first-steps') as HTMLElement).hidden).toBe(true)++ })++++ it('keeps progress in the browser and stays closed once closed', () => {++ const fs = new FirstSteps(root, read, storage as unknown as Storage)++ items = { wood_log: 1, stone: 1 }++ fs.update()++ root.innerHTML = ''++ new FirstSteps(root, read, storage as unknown as Storage)++ expect(current()).toContain('Craft a Stone Axe')++ ;(root.querySelector('[data-first-steps-close]') as HTMLButtonElement).click()++ expect((root.querySelector('.first-steps') as HTMLElement).hidden).toBe(true)++ root.innerHTML = ''++ new FirstSteps(root, read, storage as unknown as Storage)++ expect((root.querySelector('.first-steps') as HTMLElement).hidden).toBe(true)++ })++++ it('progress() unions what was done with what is done now', () => {++ const view = { count: () => 0, level: (s: string) => (s === 'mining' ? 2 : 1), hauled: false }++ expect(progress(view, { done: ['gather'] })).toEqual(['gather', 'mine'])++ })++})nnew file mode 100644@@@ -0,0 +1,193 @@++/**++ * First steps: a short checklist in the corner for a new scrapper, so the first minutes have a direction. A guest++ * used to arrive with an empty hotbar and nothing to say what Scraplandia is about (owner, 2026-10-19: "so boring").++ *++ * The steps follow Scrap Island (apps/server/src/maps/scrapIsland.ts) and the tool chain: branches and loose stones++ * need no tool → planks → a Stone Axe → an oak in the west forest → a Stone Pickaxe → the quarry → the scrapyard →++ * a haul at the recovery bird. Each step reads what the client already knows (inventory, skill levels, the++ * extraction announce); progress only moves forward and is kept in this browser. The panel can be closed for good.++ */++++export interface StepView {++ /** Items held, by def id. */++ count: (item: string) => number++ /** Skill level (1 when untrained). */++ level: (skill: string) => number++ /** Set once an extraction secured something. */++ hauled: boolean++}++++export interface FirstStep {++ id: string++ title: string++ hint: string++ done: (v: StepView) => boolean++}++++export const FIRST_STEPS: FirstStep[] = [++ {++ id: 'gather',++ title: 'Gather branches and stones',++ hint: 'Piles just inside every city gate. Look at one and press E.',++ done: (v) =>++ (v.count('wood_log') > 0 || v.count('wood_plank') > 0 || v.count('stone_axe') > 0) &&++ (v.count('stone') > 0 || v.count('stone_axe') > 0),++ },++ {++ id: 'axe',++ title: 'Craft a Stone Axe',++ hint: 'Tab → Crafting: turn a log into Wood Planks, then make the axe.',++ done: (v) => v.count('stone_axe') > 0 || v.level('woodcutting') >= 2,++ },++ {++ id: 'chop',++ title: 'Chop an oak',++ hint: 'The forest is west of town. Hold the axe and swing (left click).',++ done: (v) => v.level('woodcutting') >= 2,++ },++ {++ id: 'mine',++ title: 'Mine at the quarry',++ hint: 'South-west of town. A Stone Pickaxe breaks the stone deposits.',++ done: (v) => v.level('mining') >= 2,++ },++ {++ id: 'scrap',++ title: 'Salvage the scrapyard',++ hint: 'East of town, past the gate. Rustjaw thugs hang around out there.',++ done: (v) => v.count('scrap_metal') >= 4 || v.level('scavenging') >= 2,++ },++ {++ id: 'haul',++ title: 'Extract a haul',++ hint: 'When the recovery bird circles, stand in its circle with your valuables. Hauls score on the weekly board.',++ done: (v) => v.hauled,++ },++]++++const STORAGE_KEY = 'scraplandia:first-steps'++++interface Saved {++ done: string[]++ closed?: boolean++ hauled?: boolean++}++++function load(storage: Storage | null): Saved {++ try {++ const raw = storage?.getItem(STORAGE_KEY)++ const saved = raw ? (JSON.parse(raw) as Saved) : null++ if (saved && Array.isArray(saved.done)) return saved++ } catch {++ // unreadable or blocked storage: start fresh++ }++ return { done: [] }++}++++/** Steps done so far, never fewer than before (progress only moves forward). */++export function progress(view: StepView, saved: Saved): string[] {++ const done = new Set(saved.done)++ for (const step of FIRST_STEPS) if (step.done(view)) done.add(step.id)++ return FIRST_STEPS.map((s) => s.id).filter((id) => done.has(id))++}++++export class FirstSteps {++ private readonly el: HTMLElement++ private saved: Saved++++ constructor(++ root: HTMLElement,++ private readonly read: () => Omit<StepView, 'hauled'>,++ private readonly storage: Storage | null = safeStorage(),++ ) {++ this.saved = load(storage)++ this.el = document.createElement('section')++ this.el.className = 'first-steps'++ this.el.setAttribute('aria-label', 'First steps')++ root.appendChild(this.el)++ this.el.addEventListener('click', (e) => {++ if ((e.target as HTMLElement).closest('[data-first-steps-close]')) {++ this.saved.closed = true++ this.save()++ this.render()++ }++ })++ this.render()++ }++++ /** Re-check the steps (inventory or skills changed). */++ update(): void {++ const view = { ...this.read(), hauled: !!this.saved.hauled }++ const done = progress(view, this.saved)++ if (done.length !== this.saved.done.length) {++ this.saved.done = done++ this.save()++ }++ this.render()++ }++++ /** A server announce: an extraction that secured something completes the last step. */++ onAnnounce(text: string): void {++ if (/Extracted! [1-9]/.test(text)) {++ this.saved.hauled = true++ this.update()++ }++ }++++ private save(): void {++ try {++ this.storage?.setItem(STORAGE_KEY, JSON.stringify(this.saved))++ } catch {++ // storage full or blocked: progress lives for this session++ }++ }++++ private render(): void {++ const done = new Set(this.saved.done)++ const allDone = FIRST_STEPS.every((s) => done.has(s.id))++ if (this.saved.closed || allDone) {++ this.el.hidden = true++ return++ }++ this.el.hidden = false++ const current = FIRST_STEPS.find((s) => !done.has(s.id))++ const head = document.createElement('div')++ head.className = 'first-steps-head'++ const title = document.createElement('span')++ title.textContent = `First steps · ${done.size}/${FIRST_STEPS.length}`++ const close = document.createElement('button')++ close.type = 'button'++ close.dataset.firstStepsClose = '1'++ close.setAttribute('aria-label', 'Hide first steps')++ close.textContent = '×'++ head.append(title, close)++ const list = document.createElement('ol')++ for (const step of FIRST_STEPS) {++ const li = document.createElement('li')++ const isDone = done.has(step.id)++ li.className = isDone ? 'done' : step === current ? 'current' : ''++ const mark = document.createElement('span')++ mark.className = 'mark'++ mark.textContent = isDone ? '✓' : step === current ? '›' : '·'++ const text = document.createElement('span')++ text.textContent = step.title++ li.append(mark, text)++ if (step === current) {++ const hint = document.createElement('div')++ hint.className = 'hint'++ hint.textContent = step.hint++ li.appendChild(hint)++ }++ list.appendChild(li)++ }++ this.el.replaceChildren(head, list)++ }++}++++function safeStorage(): Storage | null {++ try {++ return globalThis.localStorage ?? null++ } catch {++ return null++ }++}@@@ -961,6 +961,88 @@ body.ovg-frame-hidden #ov-footer {opacity: 1;}++/* ── First steps (ui/firstSteps.ts) ───────────────────────── */++.first-steps {++ position: absolute;++ top: 56px;++ right: 16px;++ width: min(290px, calc(100vw - 32px));++ padding: 10px 12px 8px;++ border-radius: 10px;++ background: rgba(20, 26, 34, 0.86);++ border: 1px solid rgba(232, 181, 74, 0.55);++ color: #e8e2d4;++ font-size: 13px;++ line-height: 1.35;++ pointer-events: auto;++}++.first-steps[hidden] {++ display: none;++}++.first-steps-head {++ display: flex;++ align-items: center;++ justify-content: space-between;++ margin-bottom: 6px;++ font-weight: 700;++ letter-spacing: 0.04em;++ text-transform: uppercase;++ font-size: 11px;++ color: #e8b54a;++}++.first-steps-head button {++ background: none;++ border: 0;++ color: #b9b2a3;++ font-size: 18px;++ line-height: 1;++ cursor: pointer;++ padding: 0 2px;++}++.first-steps-head button:hover {++ color: #fff;++}++.first-steps ol {++ list-style: none;++ margin: 0;++ padding: 0;++}++.first-steps li {++ padding: 2px 0;++ color: #a8a194;++}++.first-steps li .mark {++ display: inline-block;++ width: 1.2em;++ color: #6f6a60;++}++.first-steps li.done {++ color: #7f9b6e;++}++.first-steps li.done .mark {++ color: #8fc06f;++}++.first-steps li.current {++ color: #fff;++ font-weight: 600;++}++.first-steps li.current .mark {++ color: #e8b54a;++}++.first-steps .hint {++ margin: 2px 0 4px 1.2em;++ font-weight: 400;++ color: #cfc8b8;++ font-size: 12px;++}++@media (max-width: 640px) {++ .first-steps {++ top: auto;++ bottom: 96px;++ font-size: 12px;++ }++}++/* ── Death screen ─────────────────────────────────────────────────── */.death-screen {position: absolute;@@@ -39,6 +39,22 @@ describe('Scrap Island', () => {expect(map.statics.filter((s) => s.decor === 'lamp').length).toBeLessThanOrEqual(12)})++ it('puts branches and loose stones (no tool needed) inside every gate, in the safe zone', () => {++ const city = map.zones.find((zone) => zone.id === 'scrap-city')!++ for (const [gx, gz] of [++ [0, ISLAND.wall],++ [0, -ISLAND.wall],++ [ISLAND.wall, 0],++ [-ISLAND.wall, 0],++ ] as const) {++ const near = map.nodes.filter((n) => Math.hypot(n.pos[0] - gx, n.pos[2] - gz) < 10)++ expect(near.filter((n) => n.node === 'branch_pile').length).toBeGreaterThanOrEqual(2)++ expect(near.filter((n) => n.node === 'loose_stones').length).toBeGreaterThanOrEqual(2)++ for (const n of near)++ expect(Math.max(Math.abs(n.pos[0]), Math.abs(n.pos[2]))).toBeLessThan(city.max[0])++ }++ })++it('is an island: land in the middle, sea at the edges, the city flat', () => {const t = map.terrains[0]!const h = decodeHeights(t.heights)@@@ -600,6 +600,26 @@ export function generateScrapIsland(seed = 20261019): MapFileV2 {color: '#6e4a36',})}++ // Starter piles just inside each gate: fallen branches and loose stones, which need no tool, so a new player's first++ // planks and stone axe are a short walk from spawn.++ for (const [ax, az] of [++ [0, 1],++ [0, -1],++ [1, 0],++ [-1, 0],++ ] as const) {++ const at = (along: number, depth: number): [number, number] =>++ ax === 0 ? [along, az * depth] : [ax * depth, along]++ for (const [along, depth, kind] of [++ [-6.2, 34.5, 'branch_pile'],++ [6.2, 34.5, 'loose_stones'],++ [-6.2, 31.5, 'loose_stones'],++ [6.2, 31.5, 'branch_pile'],++ ] as const) {++ const [x, z] = at(along, depth)++ node(kind, x, z)++ }++ }// Lamps: down each avenue and round the plaza (each one is a point light at night, so not too many).for (const [x, z] of [[5, 16],@@@ -32,6 +32,8 @@ file without `--force`, because that file may hold staff edits.- the farm to the south-east;- the shipwreck beach to the south;- ruins along the dirt roads.++- **Starter piles**: two fallen-branch and two loose-stone piles just inside each gate, which need no tool, so a new player's first planks and Stone Axe are close to spawn.++- **First steps** (`apps/client/src/ui/firstSteps.ts`): a checklist in the corner for a new scrapper. The steps are gather, a Stone Axe, an oak (woodcutting level 2), the quarry (mining level 2), the scrapyard and a haul. Progress only moves forward, it's kept in the browser, and the list can be closed.- **NPCs**: the content's NPC homes fit the layout. Thugs and hares are just outside the walls, wardens andtownsfolk on the plaza.- **World id**: `scrap_island`. Changing the id re-seeds resources and moves characters to spawn on the next boot.nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/client/src/ui/firstSteps.test.ts'++// covers: 'apps/client/src/ui'++// Generated by node test/run.js --index; do not edit.
@@ -18,6 +18,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
@@ -18,6 +18,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added
### Added
+- **First steps.** A new scrapper gets a checklist in the corner:
+ - gather branches and stones (piles just inside every city gate);
+ - craft a Stone Axe;
+ - chop an oak in the west forest;
+ - mine at the quarry;
+ - salvage the scrapyard;
+ - extract a haul.
+
+ Each step says where to go. Progress only moves forward and is kept in the browser, and the list can be closed.
- **Scrap Island, Scraplandia's world (2026-10-19).** Production ran on an empty map document, so a new player
- **Scrap Island, Scraplandia's world (2026-10-19).** Production ran on an empty map document, so a new player
spawned over open water and drowned within seconds. `apps/server/src/maps/scrapIsland.ts` generates the island as an
spawned over open water and drowned within seconds. `apps/server/src/maps/scrapIsland.ts` generates the island as an
ordinary v2 map document, the file the editor saves, so staff keep editing it in `/editor`. It is deterministic.
ordinary v2 map document, the file the editor saves, so staff keep editing it in `/editor`. It is deterministic.
@@ -59,6 +59,7 @@ import { registerRiggingModule } from './weapons/riggingModule.js'
@@ -59,6 +59,7 @@ import { registerRiggingModule } from './weapons/riggingModule.js'
import { ConstraintLineRenderer } from './render/constraintLines.js'
import { ConstraintLineRenderer } from './render/constraintLines.js'
import { PlacementGhost } from './game/placementGhost.js'
import { PlacementGhost } from './game/placementGhost.js'
import { WeaponSettings, weaponModuleFor } from './weapons/registry.js'
import { WeaponSettings, weaponModuleFor } from './weapons/registry.js'
+import { FirstSteps } from './ui/firstSteps.js'
/**
/**
* Client bootstrap: engine/scene -> character customization (live preview)
* Client bootstrap: engine/scene -> character customization (live preview)
@@ -234,6 +235,16 @@ async function start(): Promise<void> {
@@ -234,6 +235,16 @@ async function start(): Promise<void> {
guest: identity.token,
guest: identity.token,
...(sso ? { bearer: sso } : {}),
...(sso ? { bearer: sso } : {}),
})
})
+ // Scraplandia's first-steps checklist (not in Salvage Rush or a creator world, whose worlds are something else).
+ if (!worldId && content.world.id === 'scrap_island') {
+ const firstSteps = new FirstSteps(uiRoot, () => ({
+ count: (item) => state.countOf(item),
+ level: (skill) => state.skillLevel(skill),
+ }))
+ state.events.on('inventory', () => firstSteps.update())
+ state.events.on('skills', () => firstSteps.update())
+ state.events.on('announce', (text) => firstSteps.onAnnounce(String(text)))
+ }
const roundUi =
const roundUi =
content.world.id === 'salvage_rush_arena'
content.world.id === 'salvage_rush_arena'
? await import('./ui/salvageRushHud.js').then(
? await import('./ui/salvageRushHud.js').then(
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+// @vitest-environment jsdom
+import { beforeEach, describe, expect, it } from 'vitest'
+import { FIRST_STEPS, FirstSteps, progress } from './firstSteps.js'
+
+class MemoryStorage {
+ private map = new Map<string, string>()
+ getItem(k: string): string | null {
+ return this.map.get(k) ?? null
+ }
+ setItem(k: string, v: string): void {
+ this.map.set(k, v)
+ }
+}
+
+let items: Record<string, number>
+let levels: Record<string, number>
+const read = () => ({ count: (i: string) => items[i] ?? 0, level: (s: string) => levels[s] ?? 1 })
+let root: HTMLElement
+let storage: MemoryStorage
+
+beforeEach(() => {
+ items = {}
+ levels = {}
+ document.body.innerHTML = '<div id="ui"></div>'
+ root = document.getElementById('ui')!
+ storage = new MemoryStorage()
+})
+const steps = () => [...root.querySelectorAll('.first-steps li')]
+const current = () => root.querySelector('.first-steps li.current')?.textContent ?? ''
+
+describe('first steps', () => {
+ it('starts on gathering, with the hint for that step only', () => {
+ new FirstSteps(root, read, storage as unknown as Storage)
+ expect(steps()).toHaveLength(FIRST_STEPS.length)
+ expect(current()).toContain('Gather branches and stones')
+ expect(current()).toContain('inside every city gate')
+ expect(root.querySelectorAll('.first-steps .hint')).toHaveLength(1)
+ })
+
+ it('moves on as the player gathers, crafts and levels, and never goes back', () => {
+ const fs = new FirstSteps(root, read, storage as unknown as Storage)
+ items = { wood_log: 2, stone: 3 }
+ fs.update()
+ expect(current()).toContain('Craft a Stone Axe')
+ items = { stone_axe: 1 }
+ fs.update()
+ expect(current()).toContain('Chop an oak')
+ levels = { woodcutting: 2 }
+ fs.update()
+ expect(current()).toContain('Mine at the quarry')
+ items = {} // dropped everything: progress stays
+ levels = {}
+ fs.update()
+ expect(current()).toContain('Mine at the quarry')
+ expect(root.querySelector('.first-steps-head')?.textContent).toContain('3/6')
+ })
+
+ it('finishes with a haul and then hides', () => {
+ const fs = new FirstSteps(root, read, storage as unknown as Storage)
+ items = { stone_axe: 1, scrap_metal: 5 }
+ levels = { woodcutting: 3, mining: 2 }
+ fs.update()
+ expect(current()).toContain('Extract a haul')
+ fs.onAnnounce('🚁 Extracted! 0 stacks secured · 0 haul points.')
+ expect(current()).toContain('Extract a haul')
+ fs.onAnnounce('🚁 Extracted! 2 stacks secured · 140 haul points.')
+ expect((root.querySelector('.first-steps') as HTMLElement).hidden).toBe(true)
+ })
+
+ it('keeps progress in the browser and stays closed once closed', () => {
+ const fs = new FirstSteps(root, read, storage as unknown as Storage)
+ items = { wood_log: 1, stone: 1 }
+ fs.update()
+ root.innerHTML = ''
+ new FirstSteps(root, read, storage as unknown as Storage)
+ expect(current()).toContain('Craft a Stone Axe')
+ ;(root.querySelector('[data-first-steps-close]') as HTMLButtonElement).click()
+ expect((root.querySelector('.first-steps') as HTMLElement).hidden).toBe(true)
+ root.innerHTML = ''
+ new FirstSteps(root, read, storage as unknown as Storage)
+ expect((root.querySelector('.first-steps') as HTMLElement).hidden).toBe(true)
+ })
+
+ it('progress() unions what was done with what is done now', () => {
+ const view = { count: () => 0, level: (s: string) => (s === 'mining' ? 2 : 1), hauled: false }
+ expect(progress(view, { done: ['gather'] })).toEqual(['gather', 'mine'])
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,193 @@
@@ -0,0 +1,193 @@
+/**
+ * First steps: a short checklist in the corner for a new scrapper, so the first minutes have a direction. A guest
+ * used to arrive with an empty hotbar and nothing to say what Scraplandia is about (owner, 2026-10-19: "so boring").
+ *
+ * The steps follow Scrap Island (apps/server/src/maps/scrapIsland.ts) and the tool chain: branches and loose stones
+ * need no tool → planks → a Stone Axe → an oak in the west forest → a Stone Pickaxe → the quarry → the scrapyard →
+ * a haul at the recovery bird. Each step reads what the client already knows (inventory, skill levels, the
+ * extraction announce); progress only moves forward and is kept in this browser. The panel can be closed for good.
+ */
+
+export interface StepView {
+ /** Items held, by def id. */
+ count: (item: string) => number
+ /** Skill level (1 when untrained). */
+ level: (skill: string) => number
+ /** Set once an extraction secured something. */
+ hauled: boolean
+}
+
+export interface FirstStep {
+ id: string
+ title: string
+ hint: string
+ done: (v: StepView) => boolean
+}
+
+export const FIRST_STEPS: FirstStep[] = [
+ {
+ id: 'gather',
+ title: 'Gather branches and stones',
+ hint: 'Piles just inside every city gate. Look at one and press E.',
+ done: (v) =>
+ (v.count('wood_log') > 0 || v.count('wood_plank') > 0 || v.count('stone_axe') > 0) &&
+ (v.count('stone') > 0 || v.count('stone_axe') > 0),
+ },
+ {
+ id: 'axe',
+ title: 'Craft a Stone Axe',
+ hint: 'Tab → Crafting: turn a log into Wood Planks, then make the axe.',
+ done: (v) => v.count('stone_axe') > 0 || v.level('woodcutting') >= 2,
+ },
+ {
+ id: 'chop',
+ title: 'Chop an oak',
+ hint: 'The forest is west of town. Hold the axe and swing (left click).',
+ done: (v) => v.level('woodcutting') >= 2,
+ },
+ {
+ id: 'mine',
+ title: 'Mine at the quarry',
+ hint: 'South-west of town. A Stone Pickaxe breaks the stone deposits.',
+ done: (v) => v.level('mining') >= 2,
+ },
+ {
+ id: 'scrap',
+ title: 'Salvage the scrapyard',
+ hint: 'East of town, past the gate. Rustjaw thugs hang around out there.',
+ done: (v) => v.count('scrap_metal') >= 4 || v.level('scavenging') >= 2,
+ },
+ {
+ id: 'haul',
+ title: 'Extract a haul',
+ hint: 'When the recovery bird circles, stand in its circle with your valuables. Hauls score on the weekly board.',
+ done: (v) => v.hauled,
+ },
+]
+
+const STORAGE_KEY = 'scraplandia:first-steps'
+
+interface Saved {
+ done: string[]
+ closed?: boolean
+ hauled?: boolean
+}
+
+function load(storage: Storage | null): Saved {
+ try {
+ const raw = storage?.getItem(STORAGE_KEY)
+ const saved = raw ? (JSON.parse(raw) as Saved) : null
+ if (saved && Array.isArray(saved.done)) return saved
+ } catch {
+ // unreadable or blocked storage: start fresh
+ }
+ return { done: [] }
+}
+
+/** Steps done so far, never fewer than before (progress only moves forward). */
+export function progress(view: StepView, saved: Saved): string[] {
+ const done = new Set(saved.done)
+ for (const step of FIRST_STEPS) if (step.done(view)) done.add(step.id)
+ return FIRST_STEPS.map((s) => s.id).filter((id) => done.has(id))
+}
+
+export class FirstSteps {
+ private readonly el: HTMLElement
+ private saved: Saved
+
+ constructor(
+ root: HTMLElement,
+ private readonly read: () => Omit<StepView, 'hauled'>,
+ private readonly storage: Storage | null = safeStorage(),
+ ) {
+ this.saved = load(storage)
+ this.el = document.createElement('section')
+ this.el.className = 'first-steps'
+ this.el.setAttribute('aria-label', 'First steps')
+ root.appendChild(this.el)
+ this.el.addEventListener('click', (e) => {
+ if ((e.target as HTMLElement).closest('[data-first-steps-close]')) {
+ this.saved.closed = true
+ this.save()
+ this.render()
+ }
+ })
+ this.render()
+ }
+
+ /** Re-check the steps (inventory or skills changed). */
+ update(): void {
+ const view = { ...this.read(), hauled: !!this.saved.hauled }
+ const done = progress(view, this.saved)
+ if (done.length !== this.saved.done.length) {
+ this.saved.done = done
+ this.save()
+ }
+ this.render()
+ }
+
+ /** A server announce: an extraction that secured something completes the last step. */
+ onAnnounce(text: string): void {
+ if (/Extracted! [1-9]/.test(text)) {
+ this.saved.hauled = true
+ this.update()
+ }
+ }
+
+ private save(): void {
+ try {
+ this.storage?.setItem(STORAGE_KEY, JSON.stringify(this.saved))
+ } catch {
+ // storage full or blocked: progress lives for this session
+ }
+ }
+
+ private render(): void {
+ const done = new Set(this.saved.done)
+ const allDone = FIRST_STEPS.every((s) => done.has(s.id))
+ if (this.saved.closed || allDone) {
+ this.el.hidden = true
+ return
+ }
+ this.el.hidden = false
+ const current = FIRST_STEPS.find((s) => !done.has(s.id))
+ const head = document.createElement('div')
+ head.className = 'first-steps-head'
+ const title = document.createElement('span')
+ title.textContent = `First steps · ${done.size}/${FIRST_STEPS.length}`
+ const close = document.createElement('button')
+ close.type = 'button'
+ close.dataset.firstStepsClose = '1'
+ close.setAttribute('aria-label', 'Hide first steps')
+ close.textContent = '×'
+ head.append(title, close)
+ const list = document.createElement('ol')
+ for (const step of FIRST_STEPS) {
+ const li = document.createElement('li')
+ const isDone = done.has(step.id)
+ li.className = isDone ? 'done' : step === current ? 'current' : ''
+ const mark = document.createElement('span')
+ mark.className = 'mark'
+ mark.textContent = isDone ? '✓' : step === current ? '›' : '·'
+ const text = document.createElement('span')
+ text.textContent = step.title
+ li.append(mark, text)
+ if (step === current) {
+ const hint = document.createElement('div')
+ hint.className = 'hint'
+ hint.textContent = step.hint
+ li.appendChild(hint)
+ }
+ list.appendChild(li)
+ }
+ this.el.replaceChildren(head, list)
+ }
+}
+
+function safeStorage(): Storage | null {
+ try {
+ return globalThis.localStorage ?? null
+ } catch {
+ return null
+ }
+}
@@ -961,6 +961,88 @@ body.ovg-frame-hidden #ov-footer {
@@ -961,6 +961,88 @@ body.ovg-frame-hidden #ov-footer {
opacity: 1;
opacity: 1;
}
}
+/* ── First steps (ui/firstSteps.ts) ───────────────────────── */
+.first-steps {
+ position: absolute;
+ top: 56px;
+ right: 16px;
+ width: min(290px, calc(100vw - 32px));
+ padding: 10px 12px 8px;
+ border-radius: 10px;
+ background: rgba(20, 26, 34, 0.86);
+ border: 1px solid rgba(232, 181, 74, 0.55);
+ color: #e8e2d4;
+ font-size: 13px;
+ line-height: 1.35;
+ pointer-events: auto;
+}
+.first-steps[hidden] {
+ display: none;
+}
+.first-steps-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 6px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ font-size: 11px;
+ color: #e8b54a;
+}
+.first-steps-head button {
+ background: none;
+ border: 0;
+ color: #b9b2a3;
+ font-size: 18px;
+ line-height: 1;
+ cursor: pointer;
+ padding: 0 2px;
+}
+.first-steps-head button:hover {
+ color: #fff;
+}
+.first-steps ol {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.first-steps li {
+ padding: 2px 0;
+ color: #a8a194;
+}
+.first-steps li .mark {
+ display: inline-block;
+ width: 1.2em;
+ color: #6f6a60;
+}
+.first-steps li.done {
+ color: #7f9b6e;
+}
+.first-steps li.done .mark {
+ color: #8fc06f;
+}
+.first-steps li.current {
+ color: #fff;
+ font-weight: 600;
+}
+.first-steps li.current .mark {
+ color: #e8b54a;
+}
+.first-steps .hint {
+ margin: 2px 0 4px 1.2em;
+ font-weight: 400;
+ color: #cfc8b8;
+ font-size: 12px;
+}
+@media (max-width: 640px) {
+ .first-steps {
+ top: auto;
+ bottom: 96px;
+ font-size: 12px;
+ }
+}
+
/* ── Death screen ─────────────────────────────────────────────────── */
/* ── Death screen ─────────────────────────────────────────────────── */
.death-screen {
.death-screen {
position: absolute;
position: absolute;
@@ -39,6 +39,22 @@ describe('Scrap Island', () => {
@@ -39,6 +39,22 @@ describe('Scrap Island', () => {
expect(map.statics.filter((s) => s.decor === 'lamp').length).toBeLessThanOrEqual(12)
expect(map.statics.filter((s) => s.decor === 'lamp').length).toBeLessThanOrEqual(12)
})
})
+ it('puts branches and loose stones (no tool needed) inside every gate, in the safe zone', () => {
+ const city = map.zones.find((zone) => zone.id === 'scrap-city')!
+ for (const [gx, gz] of [
+ [0, ISLAND.wall],
+ [0, -ISLAND.wall],
+ [ISLAND.wall, 0],
+ [-ISLAND.wall, 0],
+ ] as const) {
+ const near = map.nodes.filter((n) => Math.hypot(n.pos[0] - gx, n.pos[2] - gz) < 10)
+ expect(near.filter((n) => n.node === 'branch_pile').length).toBeGreaterThanOrEqual(2)
+ expect(near.filter((n) => n.node === 'loose_stones').length).toBeGreaterThanOrEqual(2)
+ for (const n of near)
+ expect(Math.max(Math.abs(n.pos[0]), Math.abs(n.pos[2]))).toBeLessThan(city.max[0])
+ }
+ })
+
it('is an island: land in the middle, sea at the edges, the city flat', () => {
it('is an island: land in the middle, sea at the edges, the city flat', () => {
const t = map.terrains[0]!
const t = map.terrains[0]!
const h = decodeHeights(t.heights)
const h = decodeHeights(t.heights)
@@ -600,6 +600,26 @@ export function generateScrapIsland(seed = 20261019): MapFileV2 {
@@ -600,6 +600,26 @@ export function generateScrapIsland(seed = 20261019): MapFileV2 {
color: '#6e4a36',
color: '#6e4a36',
})
})
}
}
+ // Starter piles just inside each gate: fallen branches and loose stones, which need no tool, so a new player's first
+ // planks and stone axe are a short walk from spawn.
+ for (const [ax, az] of [
+ [0, 1],
+ [0, -1],
+ [1, 0],
+ [-1, 0],
+ ] as const) {
+ const at = (along: number, depth: number): [number, number] =>
+ ax === 0 ? [along, az * depth] : [ax * depth, along]
+ for (const [along, depth, kind] of [
+ [-6.2, 34.5, 'branch_pile'],
+ [6.2, 34.5, 'loose_stones'],
+ [-6.2, 31.5, 'loose_stones'],
+ [6.2, 31.5, 'branch_pile'],
+ ] as const) {
+ const [x, z] = at(along, depth)
+ node(kind, x, z)
+ }
+ }
// Lamps: down each avenue and round the plaza (each one is a point light at night, so not too many).
// Lamps: down each avenue and round the plaza (each one is a point light at night, so not too many).
for (const [x, z] of [
for (const [x, z] of [
[5, 16],
[5, 16],
@@ -32,6 +32,8 @@ file without `--force`, because that file may hold staff edits.
@@ -32,6 +32,8 @@ file without `--force`, because that file may hold staff edits.
- the farm to the south-east;
- the farm to the south-east;
- the shipwreck beach to the south;
- the shipwreck beach to the south;
- ruins along the dirt roads.
- ruins along the dirt roads.
+- **Starter piles**: two fallen-branch and two loose-stone piles just inside each gate, which need no tool, so a new player's first planks and Stone Axe are close to spawn.
+- **First steps** (`apps/client/src/ui/firstSteps.ts`): a checklist in the corner for a new scrapper. The steps are gather, a Stone Axe, an oak (woodcutting level 2), the quarry (mining level 2), the scrapyard and a haul. Progress only moves forward, it's kept in the browser, and the list can be closed.
- **NPCs**: the content's NPC homes fit the layout. Thugs and hares are just outside the walls, wardens and
- **NPCs**: the content's NPC homes fit the layout. Thugs and hares are just outside the walls, wardens and
townsfolk on the plaza.
townsfolk on the plaza.
- **World id**: `scrap_island`. Changing the id re-seeds resources and moves characters to spawn on the next boot.
- **World id**: `scrap_island`. Changing the id re-seeds resources and moves characters to spawn on the next boot.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/client/src/ui/firstSteps.test.ts'
+// covers: 'apps/client/src/ui'
+// Generated by node test/run.js --index; do not edit.