openvibers/openvibe.games Commits
Scraplandia's first steps: drink at the shore, cook a trail stew, hammer sheet metal and sell at Goose's post between the quarry and the haul (thirst and hunger ran out with nothing saying how to drink or eat, and the town's stations went unmentioned); waypoints point at props (#130)
Changes
@@@ -68,6 +68,8 @@ export class InteractionController {lastTargetWasPlayer = false/** Hook: player pressed E on a trading post (entity id passed along). */onShopOpen: ((targetId: string) => void) | null = null++ /** When this client last asked to drink (ms), so the server's consume result can be told apart from eating. */++ lastDrinkAt = 0/** Rigging tool: first selected endpoint (highlight + prompt read this). */riggingFirst: RiggingPick | null = null/** Ghost preview pose supplier (wired by main; null = no valid ghost). */@@@ -237,7 +239,10 @@ export class InteractionController {}const target = this.aim()if (!target) {-- if (this.standingInWater()) this.connection.send({ t: 'drink' })++ if (this.standingInWater()) {++ this.lastDrinkAt = Date.now()++ this.connection.send({ t: 'drink' })++ }break}this.lastTargetWasPlayer = target.kind === 'player'@@@ -62,7 +62,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'++import { FirstSteps, towardOf } from './ui/firstSteps.js'import { Waypoint, pickTarget, type NodeSighting } from './ui/waypoint.js'/**@@@ -241,6 +241,7 @@ async function start(): Promise<void> {})// Scraplandia's first-steps checklist (not in Salvage Rush or a creator world, whose worlds are something else).let updateWaypoint: (() => void) | null = null++ let firstStepsRef: FirstSteps | null = nullif (!worldId && content.world.id === 'scrap_island') {const firstSteps = new FirstSteps(uiRoot, () => ({count: (item) => state.countOf(item),@@@ -249,6 +250,7 @@ async function start(): Promise<void> {state.events.on('inventory', () => firstSteps.update())state.events.on('skills', () => firstSteps.update())state.events.on('announce', (text) => firstSteps.onAnnounce(String(text)))++ firstStepsRef = firstSteps// The waypoint toward the current step's target (refreshed from the frame loop).const waypoint = new Waypoint(uiRoot)updateWaypoint = () => {@@@ -263,24 +265,32 @@ async function start(): Promise<void> {bird ? { label: 'Recovery bird', x: bird[0], z: bird[1] } : null,)}-- if (!step.toward) return waypoint.update(from, player.viewYaw, null)++ const toward = towardOf(step, firstSteps.view())++ if (!toward) return waypoint.update(from, player.viewYaw, null)// Gathering: point at whichever pile gives what is still missing.const hasWood = state.countOf('wood_log') > 0 || state.countOf('wood_plank') > 0const hasStone = state.countOf('stone') > 0const prefer = step.id === 'gather' ? (hasStone ? ['branch_pile'] : hasWood ? ['loose_stones'] : []) : []const sightings: NodeSighting[] = []++ // Props the step names (the campfire, the workbench, the trading post) count as sightings too.++ const props = new Set(toward.props ?? [])for (const e of state.entities.values()) {-- if (e.kind !== 'resource' || !e.def) continue-- sightings.push({-- def: e.def,-- name: content.nodeType(e.def)?.name ?? e.def,-- x: e.pos[0],-- z: e.pos[2],-- remaining: e.remaining ?? 1,-- })++ if (!e.def) continue++ if (e.kind === 'resource') {++ sightings.push({++ def: e.def,++ name: content.nodeType(e.def)?.name ?? e.def,++ x: e.pos[0],++ z: e.pos[2],++ remaining: e.remaining ?? 1,++ })++ } else if (e.kind === 'prop' && props.has(e.def)) {++ sightings.push({ def: e.def, name: content.item(e.def)?.name ?? e.def, x: e.pos[0], z: e.pos[2], remaining: 1 })++ }}const zones = getMapOverride(content.world)?.zones ?? []-- waypoint.update(from, player.viewYaw, pickTarget(from, { ...step.toward, prefer }, sightings, zones))++ const wanted = { nodes: [...(toward.nodes ?? []), ...props], ...(toward.zone ? { zone: toward.zone } : {}), prefer }++ waypoint.update(from, player.viewYaw, pickTarget(from, wanted, sightings, zones))}}const roundUi =@@@ -470,6 +480,8 @@ async function start(): Promise<void> {input.onWheel = (delta) => interact.onWheel(delta)state.events.on('actionResult', (r) => {++ // A drink the server accepted (eating answers the same action, so only right after this client asked to drink).++ if (r.action === 'consume' && r.ok && Date.now() - interact.lastDrinkAt < 3000) firstStepsRef?.onDrink()if (r.action === 'use' && r.ok && interact.lastTargetWasPlayer) {interact.lastTargetWasPlayer = falsehud.flashHitmarker()@@@ -1,6 +1,6 @@// @vitest-environment jsdomimport { beforeEach, describe, expect, it } from 'vitest'--import { FIRST_STEPS, FirstSteps, progress } from './firstSteps.js'++import { FIRST_STEPS, FirstSteps, progress, towardOf, type StepView } from './firstSteps.js'class MemoryStorage {private map = new Map<string, string>()@@@ -55,14 +55,31 @@ describe('first steps', () => {levels = {}fs.update()expect(current()).toContain('Mine at the quarry')-- expect(root.querySelector('.first-steps-head')?.textContent).toContain('4/7')++ expect(root.querySelector('.first-steps-head')?.textContent).toContain(++ `4/${FIRST_STEPS.length}`,++ )})-- it('finishes with a haul and then hides', () => {++ it('then water, food, scrap, the workbench and the trading post, and finishes with a haul', () => {const fs = new FirstSteps(root, read, storage as unknown as Storage)-- items = { stone_axe: 1, scrap_metal: 5 }++ items = { stone_axe: 1, stone_pickaxe: 1 }levels = { woodcutting: 3, mining: 2 }fs.update()++ expect(current()).toContain('Drink at the shore')++ expect(current()).toContain('shallow water and press E')++ fs.onDrink()++ expect(current()).toContain('Cook a Trail Stew')++ items = { trail_stew: 1 }++ fs.update()++ expect(current()).toContain('Salvage the scrapyard')++ items = { scrap_metal: 5 }++ fs.update()++ expect(current()).toContain('Hammer Sheet Metal')++ items = { sheet_metal: 1 }++ fs.update()++ expect(current()).toContain("Sell at Goose's Trading Post")++ items = { coin: 3 }++ fs.update()expect(current()).toContain('Extract a haul')fs.onAnnounce('🚁 Extracted! 0 stacks secured · 0 haul points.')expect(current()).toContain('Extract a haul')@@@ -85,9 +102,30 @@ describe('first steps', () => {})it('progress() unions what was done with what is done now', () => {-- const view = { count: () => 0, level: (s: string) => (s === 'mining' ? 2 : 1), hauled: false }++ const view = {++ count: () => 0,++ level: (s: string) => (s === 'mining' ? 2 : 1),++ hauled: false,++ drank: false,++ }expect(progress(view, { done: ['gather'] })).toEqual(['gather', 'pickaxe', 'mine'])})++++ it('keeps a scrapper who already made a haul past the steps added since', () => {++ const view = { count: () => 0, level: () => 1, hauled: false, drank: false }++ const before = ['gather', 'axe', 'pickaxe', 'chop', 'mine', 'scrap', 'haul']++ expect(progress(view, { done: before })).toEqual(FIRST_STEPS.map((s) => s.id))++ storage.setItem('scraplandia:first-steps', JSON.stringify({ done: before, hauled: true }))++ new FirstSteps(root, read, storage as unknown as Storage).update()++ expect((root.querySelector('.first-steps') as HTMLElement).hidden).toBe(true)++ })++++ it('remembers a drink across a reload', () => {++ const fs = new FirstSteps(root, read, storage as unknown as Storage)++ fs.onDrink()++ root.innerHTML = ''++ expect(new FirstSteps(root, read, storage as unknown as Storage).view().drank).toBe(true)++ })})describe('the waypoint hooks', () => {@@@ -123,17 +161,47 @@ describe('the waypoint hooks', () => {count: (i) => (i === 'stone_pickaxe' ? 1 : 0),level: () => 1,hauled: false,++ drank: false,}),).toBe(true)-- expect(pickaxe.done({ count: () => 0, level: () => 1, hauled: false })).toBe(false)++ expect(pickaxe.done({ count: () => 0, level: () => 1, hauled: false, drank: false })).toBe(++ false,++ )expect(FIRST_STEPS[0]?.hint).toMatch(/five stones/)})it('gives the outdoor steps somewhere to point', () => {-- const toward = Object.fromEntries(FIRST_STEPS.map((s) => [s.id, s.toward]))++ const view: StepView = { count: () => 0, level: () => 1, hauled: false, drank: false }++ const toward = Object.fromEntries(FIRST_STEPS.map((s) => [s.id, towardOf(s, view)]))expect(toward.gather?.nodes).toEqual(['branch_pile', 'loose_stones'])expect(toward.chop).toEqual({ nodes: ['oak_tree'], zone: 'forest' })expect(toward.mine?.zone).toBe('quarry')expect(toward.scrap?.zone).toBe('scrapyard')++ // The town's stations are props: the workbench, the trading post, and the campfire once there are berries.++ expect(toward.stew).toEqual({ nodes: ['berry_bush'] })++ const stew = FIRST_STEPS.find((s) => s.id === 'stew')!++ expect(towardOf(stew, { ...view, count: (i) => (i === 'berries' ? 3 : 0) })).toEqual({++ props: ['campfire'],++ })++ expect(toward.sheet).toEqual({ props: ['workbench'] })++ expect(toward.trade).toEqual({ props: ['merchant_stall'] })++ expect(toward.drink).toBeUndefined()++ })++++ it('puts water and food before the scrapyard, and the workbench and trading post before the haul', () => {++ const ids = FIRST_STEPS.map((s) => s.id)++ expect(ids).toEqual([++ 'gather',++ 'axe',++ 'pickaxe',++ 'chop',++ 'mine',++ 'drink',++ 'stew',++ 'scrap',++ 'sheet',++ 'trade',++ 'haul',++ ])})})@@@ -3,9 +3,11 @@* 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 and a Stone Pickaxe → an oak in the west forest → 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.++ * need no tool → planks → a Stone Axe and a Stone Pickaxe → an oak in the west forest → the quarry → water and food++ * (thirst empties in 30 minutes and hunger in 40, and nothing said how to drink or eat) → the scrapyard → the town's++ * workbench and Goose's Trading Post (both stood in town unmentioned) → a haul at the recovery bird. Each step reads++ * what the client already knows (inventory, skill levels, a drink the server confirmed, the extraction announce);++ * progress only moves forward and is kept in this browser. The panel can be closed for good.*/export interface StepView {@@@ -15,6 +17,15 @@ export interface StepView {level: (skill: string) => number/** Set once an extraction secured something. */hauled: boolean++ /** Set once the server confirmed a drink of water. */++ drank: boolean++}++++/** What the waypoint points at: nodes of these types with something left, props of these kinds, else a zone. */++export interface Toward {++ nodes?: string[]++ props?: string[]++ zone?: string}export interface FirstStep {@@@ -23,10 +34,11 @@ export interface FirstStep {hint: stringdone: (v: StepView) => boolean/**-- * Where the waypoint points (ui/waypoint.ts): the nearest node of these types that has something left, else the-- * map zone with this id (a landmark). `items` narrows the nodes to the ones yielding what the player still lacks.++ * Where the waypoint points (ui/waypoint.ts): the nearest node of these types that has something left (or prop of++ * these kinds: a campfire, the workbench, the trading post), else the map zone with this id (a landmark). A++ * function picks by what the player holds, e.g. berry bushes until there are enough berries, then the campfire.*/-- toward?: { nodes?: string[]; zone?: string }++ toward?: Toward | ((v: StepView) => Toward)}export const FIRST_STEPS: FirstStep[] = [@@@ -67,6 +79,19 @@ export const FIRST_STEPS: FirstStep[] = [done: (v) => v.level('mining') >= 2,toward: { nodes: ['stone_deposit'], zone: 'quarry' },},++ {++ id: 'drink',++ title: 'Drink at the shore',++ hint: 'Your H2O bar runs dry in about half an hour. Wade into shallow water and press E with nothing in front of you.',++ done: (v) => v.drank,++ },++ {++ id: 'stew',++ title: 'Cook a Trail Stew',++ hint: 'Food lasts about 40 minutes. Pick 3 berries from the bushes (E), then at the campfire in town: Tab → Crafting, 3 berries and a log.',++ done: (v) => v.count('trail_stew') > 0,++ toward: (v) => (v.count('berries') >= 3 ? { props: ['campfire'] } : { nodes: ['berry_bush'] }),++ },{id: 'scrap',title: 'Salvage the scrapyard',@@@ -74,6 +99,20 @@ export const FIRST_STEPS: FirstStep[] = [done: (v) => v.count('scrap_metal') >= 4 || v.level('scavenging') >= 2,toward: { nodes: ['scrap_pile'], zone: 'scrapyard' },},++ {++ id: 'sheet',++ title: 'Hammer Sheet Metal',++ hint: 'At the workbench in town, Tab → Crafting: 3 scrap make a sheet, the start of metal walls, water tanks and a generator.',++ done: (v) => v.count('sheet_metal') > 0,++ toward: { props: ['workbench'] },++ },++ {++ id: 'trade',++ title: "Sell at Goose's Trading Post",++ hint: "The goose's stall in town buys stone, logs, planks and scrap for bottle caps, and sells seeds, rope, stew and bandages. Look at it and press E.",++ done: (v) => v.count('coin') > 0,++ toward: { props: ['merchant_stall'] },++ },{id: 'haul',title: 'Extract a haul',@@@ -88,6 +127,7 @@ interface Saved {done: string[]closed?: booleanhauled?: boolean++ drank?: boolean}function load(storage: Storage | null): Saved {@@@ -103,18 +143,25 @@ function load(storage: Storage | null): Saved {/** Steps done so far, never fewer than before (progress only moves forward). */export function progress(view: StepView, saved: Saved): string[] {++ // A scrapper who already made a haul is past the whole list, the steps added since included.++ if (saved.done.includes('haul')) return FIRST_STEPS.map((s) => s.id)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))}++/** Where a step's waypoint points for what the player holds now (no target: undefined). */++export function towardOf(step: FirstStep, view: StepView): Toward | undefined {++ return typeof step.toward === 'function' ? step.toward(view) : step.toward++}++export class FirstSteps {private readonly el: HTMLElementprivate saved: Savedconstructor(root: HTMLElement,-- private readonly read: () => Omit<StepView, 'hauled'>,++ private readonly read: () => Omit<StepView, 'hauled' | 'drank'>,private readonly storage: Storage | null = safeStorage(),) {this.saved = load(storage)@@@ -134,7 +181,7 @@ export class FirstSteps {/** Re-check the steps (inventory or skills changed). */update(): void {-- const view = { ...this.read(), hauled: !!this.saved.hauled }++ const view = this.view()const done = progress(view, this.saved)if (done.length !== this.saved.done.length) {this.saved.done = done@@@ -143,6 +190,18 @@ export class FirstSteps {this.render()}++ /** What the steps read: the client's state and the flags this browser keeps. */++ view(): StepView {++ return { ...this.read(), hauled: !!this.saved.hauled, drank: !!this.saved.drank }++ }++++ /** The server confirmed a drink of water. */++ onDrink(): void {++ if (this.saved.drank) return++ this.saved.drank = true++ this.update()++ }++/** The step being worked on, or null when all are done or the panel was closed. */current(): FirstStep | null {if (this.saved.closed) return null
@@ -68,6 +68,8 @@ export class InteractionController {
@@ -68,6 +68,8 @@ export class InteractionController {
lastTargetWasPlayer = false
lastTargetWasPlayer = false
/** Hook: player pressed E on a trading post (entity id passed along). */
/** Hook: player pressed E on a trading post (entity id passed along). */
onShopOpen: ((targetId: string) => void) | null = null
onShopOpen: ((targetId: string) => void) | null = null
+ /** When this client last asked to drink (ms), so the server's consume result can be told apart from eating. */
+ lastDrinkAt = 0
/** Rigging tool: first selected endpoint (highlight + prompt read this). */
/** Rigging tool: first selected endpoint (highlight + prompt read this). */
riggingFirst: RiggingPick | null = null
riggingFirst: RiggingPick | null = null
/** Ghost preview pose supplier (wired by main; null = no valid ghost). */
/** Ghost preview pose supplier (wired by main; null = no valid ghost). */
@@ -237,7 +239,10 @@ export class InteractionController {
@@ -237,7 +239,10 @@ export class InteractionController {
}
}
const target = this.aim()
const target = this.aim()
if (!target) {
if (!target) {
- if (this.standingInWater()) this.connection.send({ t: 'drink' })
+ if (this.standingInWater()) {
+ this.lastDrinkAt = Date.now()
+ this.connection.send({ t: 'drink' })
+ }
break
break
}
}
this.lastTargetWasPlayer = target.kind === 'player'
this.lastTargetWasPlayer = target.kind === 'player'
@@ -62,7 +62,7 @@ import { registerRiggingModule } from './weapons/riggingModule.js'
@@ -62,7 +62,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'
+import { FirstSteps, towardOf } from './ui/firstSteps.js'
import { Waypoint, pickTarget, type NodeSighting } from './ui/waypoint.js'
import { Waypoint, pickTarget, type NodeSighting } from './ui/waypoint.js'
/**
/**
@@ -241,6 +241,7 @@ async function start(): Promise<void> {
@@ -241,6 +241,7 @@ async function start(): Promise<void> {
})
})
// Scraplandia's first-steps checklist (not in Salvage Rush or a creator world, whose worlds are something else).
// Scraplandia's first-steps checklist (not in Salvage Rush or a creator world, whose worlds are something else).
let updateWaypoint: (() => void) | null = null
let updateWaypoint: (() => void) | null = null
+ let firstStepsRef: FirstSteps | null = null
if (!worldId && content.world.id === 'scrap_island') {
if (!worldId && content.world.id === 'scrap_island') {
const firstSteps = new FirstSteps(uiRoot, () => ({
const firstSteps = new FirstSteps(uiRoot, () => ({
count: (item) => state.countOf(item),
count: (item) => state.countOf(item),
@@ -249,6 +250,7 @@ async function start(): Promise<void> {
@@ -249,6 +250,7 @@ async function start(): Promise<void> {
state.events.on('inventory', () => firstSteps.update())
state.events.on('inventory', () => firstSteps.update())
state.events.on('skills', () => firstSteps.update())
state.events.on('skills', () => firstSteps.update())
state.events.on('announce', (text) => firstSteps.onAnnounce(String(text)))
state.events.on('announce', (text) => firstSteps.onAnnounce(String(text)))
+ firstStepsRef = firstSteps
// The waypoint toward the current step's target (refreshed from the frame loop).
// The waypoint toward the current step's target (refreshed from the frame loop).
const waypoint = new Waypoint(uiRoot)
const waypoint = new Waypoint(uiRoot)
updateWaypoint = () => {
updateWaypoint = () => {
@@ -263,24 +265,32 @@ async function start(): Promise<void> {
@@ -263,24 +265,32 @@ async function start(): Promise<void> {
bird ? { label: 'Recovery bird', x: bird[0], z: bird[1] } : null,
bird ? { label: 'Recovery bird', x: bird[0], z: bird[1] } : null,
)
)
}
}
- if (!step.toward) return waypoint.update(from, player.viewYaw, null)
+ const toward = towardOf(step, firstSteps.view())
+ if (!toward) return waypoint.update(from, player.viewYaw, null)
// Gathering: point at whichever pile gives what is still missing.
// Gathering: point at whichever pile gives what is still missing.
const hasWood = state.countOf('wood_log') > 0 || state.countOf('wood_plank') > 0
const hasWood = state.countOf('wood_log') > 0 || state.countOf('wood_plank') > 0
const hasStone = state.countOf('stone') > 0
const hasStone = state.countOf('stone') > 0
const prefer = step.id === 'gather' ? (hasStone ? ['branch_pile'] : hasWood ? ['loose_stones'] : []) : []
const prefer = step.id === 'gather' ? (hasStone ? ['branch_pile'] : hasWood ? ['loose_stones'] : []) : []
const sightings: NodeSighting[] = []
const sightings: NodeSighting[] = []
+ // Props the step names (the campfire, the workbench, the trading post) count as sightings too.
+ const props = new Set(toward.props ?? [])
for (const e of state.entities.values()) {
for (const e of state.entities.values()) {
- if (e.kind !== 'resource' || !e.def) continue
- sightings.push({
- def: e.def,
- name: content.nodeType(e.def)?.name ?? e.def,
- x: e.pos[0],
- z: e.pos[2],
- remaining: e.remaining ?? 1,
- })
+ if (!e.def) continue
+ if (e.kind === 'resource') {
+ sightings.push({
+ def: e.def,
+ name: content.nodeType(e.def)?.name ?? e.def,
+ x: e.pos[0],
+ z: e.pos[2],
+ remaining: e.remaining ?? 1,
+ })
+ } else if (e.kind === 'prop' && props.has(e.def)) {
+ sightings.push({ def: e.def, name: content.item(e.def)?.name ?? e.def, x: e.pos[0], z: e.pos[2], remaining: 1 })
+ }
}
}
const zones = getMapOverride(content.world)?.zones ?? []
const zones = getMapOverride(content.world)?.zones ?? []
- waypoint.update(from, player.viewYaw, pickTarget(from, { ...step.toward, prefer }, sightings, zones))
+ const wanted = { nodes: [...(toward.nodes ?? []), ...props], ...(toward.zone ? { zone: toward.zone } : {}), prefer }
+ waypoint.update(from, player.viewYaw, pickTarget(from, wanted, sightings, zones))
}
}
}
}
const roundUi =
const roundUi =
@@ -470,6 +480,8 @@ async function start(): Promise<void> {
@@ -470,6 +480,8 @@ async function start(): Promise<void> {
input.onWheel = (delta) => interact.onWheel(delta)
input.onWheel = (delta) => interact.onWheel(delta)
state.events.on('actionResult', (r) => {
state.events.on('actionResult', (r) => {
+ // A drink the server accepted (eating answers the same action, so only right after this client asked to drink).
+ if (r.action === 'consume' && r.ok && Date.now() - interact.lastDrinkAt < 3000) firstStepsRef?.onDrink()
if (r.action === 'use' && r.ok && interact.lastTargetWasPlayer) {
if (r.action === 'use' && r.ok && interact.lastTargetWasPlayer) {
interact.lastTargetWasPlayer = false
interact.lastTargetWasPlayer = false
hud.flashHitmarker()
hud.flashHitmarker()
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
-import { FIRST_STEPS, FirstSteps, progress } from './firstSteps.js'
+import { FIRST_STEPS, FirstSteps, progress, towardOf, type StepView } from './firstSteps.js'
class MemoryStorage {
class MemoryStorage {
private map = new Map<string, string>()
private map = new Map<string, string>()
@@ -55,14 +55,31 @@ describe('first steps', () => {
@@ -55,14 +55,31 @@ describe('first steps', () => {
levels = {}
levels = {}
fs.update()
fs.update()
expect(current()).toContain('Mine at the quarry')
expect(current()).toContain('Mine at the quarry')
- expect(root.querySelector('.first-steps-head')?.textContent).toContain('4/7')
+ expect(root.querySelector('.first-steps-head')?.textContent).toContain(
+ `4/${FIRST_STEPS.length}`,
+ )
})
})
- it('finishes with a haul and then hides', () => {
+ it('then water, food, scrap, the workbench and the trading post, and finishes with a haul', () => {
const fs = new FirstSteps(root, read, storage as unknown as Storage)
const fs = new FirstSteps(root, read, storage as unknown as Storage)
- items = { stone_axe: 1, scrap_metal: 5 }
+ items = { stone_axe: 1, stone_pickaxe: 1 }
levels = { woodcutting: 3, mining: 2 }
levels = { woodcutting: 3, mining: 2 }
fs.update()
fs.update()
+ expect(current()).toContain('Drink at the shore')
+ expect(current()).toContain('shallow water and press E')
+ fs.onDrink()
+ expect(current()).toContain('Cook a Trail Stew')
+ items = { trail_stew: 1 }
+ fs.update()
+ expect(current()).toContain('Salvage the scrapyard')
+ items = { scrap_metal: 5 }
+ fs.update()
+ expect(current()).toContain('Hammer Sheet Metal')
+ items = { sheet_metal: 1 }
+ fs.update()
+ expect(current()).toContain("Sell at Goose's Trading Post")
+ items = { coin: 3 }
+ fs.update()
expect(current()).toContain('Extract a haul')
expect(current()).toContain('Extract a haul')
fs.onAnnounce('🚁 Extracted! 0 stacks secured · 0 haul points.')
fs.onAnnounce('🚁 Extracted! 0 stacks secured · 0 haul points.')
expect(current()).toContain('Extract a haul')
expect(current()).toContain('Extract a haul')
@@ -85,9 +102,30 @@ describe('first steps', () => {
@@ -85,9 +102,30 @@ describe('first steps', () => {
})
})
it('progress() unions what was done with what is done now', () => {
it('progress() unions what was done with what is done now', () => {
- const view = { count: () => 0, level: (s: string) => (s === 'mining' ? 2 : 1), hauled: false }
+ const view = {
+ count: () => 0,
+ level: (s: string) => (s === 'mining' ? 2 : 1),
+ hauled: false,
+ drank: false,
+ }
expect(progress(view, { done: ['gather'] })).toEqual(['gather', 'pickaxe', 'mine'])
expect(progress(view, { done: ['gather'] })).toEqual(['gather', 'pickaxe', 'mine'])
})
})
+
+ it('keeps a scrapper who already made a haul past the steps added since', () => {
+ const view = { count: () => 0, level: () => 1, hauled: false, drank: false }
+ const before = ['gather', 'axe', 'pickaxe', 'chop', 'mine', 'scrap', 'haul']
+ expect(progress(view, { done: before })).toEqual(FIRST_STEPS.map((s) => s.id))
+ storage.setItem('scraplandia:first-steps', JSON.stringify({ done: before, hauled: true }))
+ new FirstSteps(root, read, storage as unknown as Storage).update()
+ expect((root.querySelector('.first-steps') as HTMLElement).hidden).toBe(true)
+ })
+
+ it('remembers a drink across a reload', () => {
+ const fs = new FirstSteps(root, read, storage as unknown as Storage)
+ fs.onDrink()
+ root.innerHTML = ''
+ expect(new FirstSteps(root, read, storage as unknown as Storage).view().drank).toBe(true)
+ })
})
})
describe('the waypoint hooks', () => {
describe('the waypoint hooks', () => {
@@ -123,17 +161,47 @@ describe('the waypoint hooks', () => {
@@ -123,17 +161,47 @@ describe('the waypoint hooks', () => {
count: (i) => (i === 'stone_pickaxe' ? 1 : 0),
count: (i) => (i === 'stone_pickaxe' ? 1 : 0),
level: () => 1,
level: () => 1,
hauled: false,
hauled: false,
+ drank: false,
}),
}),
).toBe(true)
).toBe(true)
- expect(pickaxe.done({ count: () => 0, level: () => 1, hauled: false })).toBe(false)
+ expect(pickaxe.done({ count: () => 0, level: () => 1, hauled: false, drank: false })).toBe(
+ false,
+ )
expect(FIRST_STEPS[0]?.hint).toMatch(/five stones/)
expect(FIRST_STEPS[0]?.hint).toMatch(/five stones/)
})
})
it('gives the outdoor steps somewhere to point', () => {
it('gives the outdoor steps somewhere to point', () => {
- const toward = Object.fromEntries(FIRST_STEPS.map((s) => [s.id, s.toward]))
+ const view: StepView = { count: () => 0, level: () => 1, hauled: false, drank: false }
+ const toward = Object.fromEntries(FIRST_STEPS.map((s) => [s.id, towardOf(s, view)]))
expect(toward.gather?.nodes).toEqual(['branch_pile', 'loose_stones'])
expect(toward.gather?.nodes).toEqual(['branch_pile', 'loose_stones'])
expect(toward.chop).toEqual({ nodes: ['oak_tree'], zone: 'forest' })
expect(toward.chop).toEqual({ nodes: ['oak_tree'], zone: 'forest' })
expect(toward.mine?.zone).toBe('quarry')
expect(toward.mine?.zone).toBe('quarry')
expect(toward.scrap?.zone).toBe('scrapyard')
expect(toward.scrap?.zone).toBe('scrapyard')
+ // The town's stations are props: the workbench, the trading post, and the campfire once there are berries.
+ expect(toward.stew).toEqual({ nodes: ['berry_bush'] })
+ const stew = FIRST_STEPS.find((s) => s.id === 'stew')!
+ expect(towardOf(stew, { ...view, count: (i) => (i === 'berries' ? 3 : 0) })).toEqual({
+ props: ['campfire'],
+ })
+ expect(toward.sheet).toEqual({ props: ['workbench'] })
+ expect(toward.trade).toEqual({ props: ['merchant_stall'] })
+ expect(toward.drink).toBeUndefined()
+ })
+
+ it('puts water and food before the scrapyard, and the workbench and trading post before the haul', () => {
+ const ids = FIRST_STEPS.map((s) => s.id)
+ expect(ids).toEqual([
+ 'gather',
+ 'axe',
+ 'pickaxe',
+ 'chop',
+ 'mine',
+ 'drink',
+ 'stew',
+ 'scrap',
+ 'sheet',
+ 'trade',
+ 'haul',
+ ])
})
})
})
})
@@ -3,9 +3,11 @@
@@ -3,9 +3,11 @@
* used to arrive with an empty hotbar and nothing to say what Scraplandia is about (owner, 2026-10-19: "so boring").
* 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
* 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 and a Stone Pickaxe → an oak in the west forest → 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.
+ * need no tool → planks → a Stone Axe and a Stone Pickaxe → an oak in the west forest → the quarry → water and food
+ * (thirst empties in 30 minutes and hunger in 40, and nothing said how to drink or eat) → the scrapyard → the town's
+ * workbench and Goose's Trading Post (both stood in town unmentioned) → a haul at the recovery bird. Each step reads
+ * what the client already knows (inventory, skill levels, a drink the server confirmed, the extraction announce);
+ * progress only moves forward and is kept in this browser. The panel can be closed for good.
*/
*/
export interface StepView {
export interface StepView {
@@ -15,6 +17,15 @@ export interface StepView {
@@ -15,6 +17,15 @@ export interface StepView {
level: (skill: string) => number
level: (skill: string) => number
/** Set once an extraction secured something. */
/** Set once an extraction secured something. */
hauled: boolean
hauled: boolean
+ /** Set once the server confirmed a drink of water. */
+ drank: boolean
+}
+
+/** What the waypoint points at: nodes of these types with something left, props of these kinds, else a zone. */
+export interface Toward {
+ nodes?: string[]
+ props?: string[]
+ zone?: string
}
}
export interface FirstStep {
export interface FirstStep {
@@ -23,10 +34,11 @@ export interface FirstStep {
@@ -23,10 +34,11 @@ export interface FirstStep {
hint: string
hint: string
done: (v: StepView) => boolean
done: (v: StepView) => boolean
/**
/**
- * Where the waypoint points (ui/waypoint.ts): the nearest node of these types that has something left, else the
- * map zone with this id (a landmark). `items` narrows the nodes to the ones yielding what the player still lacks.
+ * Where the waypoint points (ui/waypoint.ts): the nearest node of these types that has something left (or prop of
+ * these kinds: a campfire, the workbench, the trading post), else the map zone with this id (a landmark). A
+ * function picks by what the player holds, e.g. berry bushes until there are enough berries, then the campfire.
*/
*/
- toward?: { nodes?: string[]; zone?: string }
+ toward?: Toward | ((v: StepView) => Toward)
}
}
export const FIRST_STEPS: FirstStep[] = [
export const FIRST_STEPS: FirstStep[] = [
@@ -67,6 +79,19 @@ export const FIRST_STEPS: FirstStep[] = [
@@ -67,6 +79,19 @@ export const FIRST_STEPS: FirstStep[] = [
done: (v) => v.level('mining') >= 2,
done: (v) => v.level('mining') >= 2,
toward: { nodes: ['stone_deposit'], zone: 'quarry' },
toward: { nodes: ['stone_deposit'], zone: 'quarry' },
},
},
+ {
+ id: 'drink',
+ title: 'Drink at the shore',
+ hint: 'Your H2O bar runs dry in about half an hour. Wade into shallow water and press E with nothing in front of you.',
+ done: (v) => v.drank,
+ },
+ {
+ id: 'stew',
+ title: 'Cook a Trail Stew',
+ hint: 'Food lasts about 40 minutes. Pick 3 berries from the bushes (E), then at the campfire in town: Tab → Crafting, 3 berries and a log.',
+ done: (v) => v.count('trail_stew') > 0,
+ toward: (v) => (v.count('berries') >= 3 ? { props: ['campfire'] } : { nodes: ['berry_bush'] }),
+ },
{
{
id: 'scrap',
id: 'scrap',
title: 'Salvage the scrapyard',
title: 'Salvage the scrapyard',
@@ -74,6 +99,20 @@ export const FIRST_STEPS: FirstStep[] = [
@@ -74,6 +99,20 @@ export const FIRST_STEPS: FirstStep[] = [
done: (v) => v.count('scrap_metal') >= 4 || v.level('scavenging') >= 2,
done: (v) => v.count('scrap_metal') >= 4 || v.level('scavenging') >= 2,
toward: { nodes: ['scrap_pile'], zone: 'scrapyard' },
toward: { nodes: ['scrap_pile'], zone: 'scrapyard' },
},
},
+ {
+ id: 'sheet',
+ title: 'Hammer Sheet Metal',
+ hint: 'At the workbench in town, Tab → Crafting: 3 scrap make a sheet, the start of metal walls, water tanks and a generator.',
+ done: (v) => v.count('sheet_metal') > 0,
+ toward: { props: ['workbench'] },
+ },
+ {
+ id: 'trade',
+ title: "Sell at Goose's Trading Post",
+ hint: "The goose's stall in town buys stone, logs, planks and scrap for bottle caps, and sells seeds, rope, stew and bandages. Look at it and press E.",
+ done: (v) => v.count('coin') > 0,
+ toward: { props: ['merchant_stall'] },
+ },
{
{
id: 'haul',
id: 'haul',
title: 'Extract a haul',
title: 'Extract a haul',
@@ -88,6 +127,7 @@ interface Saved {
@@ -88,6 +127,7 @@ interface Saved {
done: string[]
done: string[]
closed?: boolean
closed?: boolean
hauled?: boolean
hauled?: boolean
+ drank?: boolean
}
}
function load(storage: Storage | null): Saved {
function load(storage: Storage | null): Saved {
@@ -103,18 +143,25 @@ function load(storage: Storage | null): Saved {
@@ -103,18 +143,25 @@ function load(storage: Storage | null): Saved {
/** Steps done so far, never fewer than before (progress only moves forward). */
/** Steps done so far, never fewer than before (progress only moves forward). */
export function progress(view: StepView, saved: Saved): string[] {
export function progress(view: StepView, saved: Saved): string[] {
+ // A scrapper who already made a haul is past the whole list, the steps added since included.
+ if (saved.done.includes('haul')) return FIRST_STEPS.map((s) => s.id)
const done = new Set(saved.done)
const done = new Set(saved.done)
for (const step of FIRST_STEPS) if (step.done(view)) done.add(step.id)
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))
return FIRST_STEPS.map((s) => s.id).filter((id) => done.has(id))
}
}
+/** Where a step's waypoint points for what the player holds now (no target: undefined). */
+export function towardOf(step: FirstStep, view: StepView): Toward | undefined {
+ return typeof step.toward === 'function' ? step.toward(view) : step.toward
+}
+
export class FirstSteps {
export class FirstSteps {
private readonly el: HTMLElement
private readonly el: HTMLElement
private saved: Saved
private saved: Saved
constructor(
constructor(
root: HTMLElement,
root: HTMLElement,
- private readonly read: () => Omit<StepView, 'hauled'>,
+ private readonly read: () => Omit<StepView, 'hauled' | 'drank'>,
private readonly storage: Storage | null = safeStorage(),
private readonly storage: Storage | null = safeStorage(),
) {
) {
this.saved = load(storage)
this.saved = load(storage)
@@ -134,7 +181,7 @@ export class FirstSteps {
@@ -134,7 +181,7 @@ export class FirstSteps {
/** Re-check the steps (inventory or skills changed). */
/** Re-check the steps (inventory or skills changed). */
update(): void {
update(): void {
- const view = { ...this.read(), hauled: !!this.saved.hauled }
+ const view = this.view()
const done = progress(view, this.saved)
const done = progress(view, this.saved)
if (done.length !== this.saved.done.length) {
if (done.length !== this.saved.done.length) {
this.saved.done = done
this.saved.done = done
@@ -143,6 +190,18 @@ export class FirstSteps {
@@ -143,6 +190,18 @@ export class FirstSteps {
this.render()
this.render()
}
}
+ /** What the steps read: the client's state and the flags this browser keeps. */
+ view(): StepView {
+ return { ...this.read(), hauled: !!this.saved.hauled, drank: !!this.saved.drank }
+ }
+
+ /** The server confirmed a drink of water. */
+ onDrink(): void {
+ if (this.saved.drank) return
+ this.saved.drank = true
+ this.update()
+ }
+
/** The step being worked on, or null when all are done or the panel was closed. */
/** The step being worked on, or null when all are done or the panel was closed. */
current(): FirstStep | null {
current(): FirstStep | null {
if (this.saved.closed) return null
if (this.saved.closed) return null