@@ -1008,6 +1224,127 @@ export default function App() {
)}
+ {modal === 'log' && (
+ setModal(null)}
+ >
+ EVERY HORIZON LEAVES A TRACE
+
+ Your flight log.
+
+
+ Your last twelve journeys. Revisit a course, find a better line.
+
+
+
+
+ {number(progress.best)}
+ m
+
+ FURTHEST FLIGHT
+
+
+ {number(progress.bestScore)}
+ BEST SCORE
+
+
+
+ {progress.completed.length}
+ / {MISSIONS.length}
+
+ CHALLENGES
+
+
+ {progress.history.length === 0 ? (
+
+
+
The first page is yours.
+
Finish a flight to start your collection of horizons.
+
+ ) : (
+
+ {progress.history.map((run) => (
+
+
+
+
+
+
+ {number(run.distance)} m
+
+
+ {run.mode === 'daily'
+ ? 'Daily flight'
+ : run.mode === 'zen'
+ ? 'Free flight'
+ : 'Voyage'}{' '}
+ · {run.date} · {formatTime(run.duration)}
+
+
+
+ {number(run.score)}
+ POINTS
+
+
+ startRun({
+ seed: run.seed,
+ mode: run.mode === 'zen' ? 'zen' : 'voyage',
+ })
+ }
+ >
+
+
+
+ ))}
+
+ )}
+
+ Replays use the original course. Your best ghost is kept for your
+ four most recent improved courses. Past daily courses replay as
+ Voyages.
+
+
+ )}
+ {modal === 'share' && (
+ setModal(null)}>
+ A HORIZON WORTH SHARING
+
+ Same dunes.
+ A different story.
+
+
+ Copy this link to challenge a friend to the same course. Your
+ records and ghost stay on your device.
+
+
+ COURSE LINK
+ event.target.select()}
+ />
+
+ setModal(null)}>
+ Back to the sky
+
+
+ )}
+
{modal === 'hangar' && (
+
+
+ {BIOMES[state.biome].short}
+
+ {upcoming.short} {Math.ceil(next - state.distance)} m
+
+
+
+
+
+
+
+ {state.player.shield && (
+
+ Shield ready
+
+ )}
+ {state.player.magnetTime > 0 && (
+
+ Magnet{' '}
+ {Math.ceil(state.player.magnetTime)}s
+
+ )}
+ {ghostDelta !== null && (
+ = 0 ? 'ghost-ahead' : ''}`}>
+
+
+ {ghostDelta >= 0 ? '+' : '−'}
+ {Math.abs(Math.round(ghostDelta))} m
+
+ {ghostDelta >= 0 ? 'ahead' : 'behind'}
+
+ )}
+
+
+
SKY CHAIN
+
+ {[0, 1, 2].map((i) => (
+
+
+
+ ))}
+
+
3 rings → light magnet
+
+ {coach && state.phase === 'running' && (
+
+
+ {cue}
+ {state.diving && DIVING }
+
+ )}
+ >
+ )
+}
+
+export function FlightDebrief({
+ state,
+ trace,
+}: {
+ state: GameState
+ trace: GhostRun | null
+}) {
+ const points = trace?.points ?? []
+ const path = points
+ .map(
+ (point, i) =>
+ `${i === 0 ? 'M' : 'L'}${((point[0] / Math.max(1, state.time)) * 430).toFixed(1)},${(8 + (point[2] / 720) * 40).toFixed(1)}`,
+ )
+ .join(' ')
+ const advice =
+ state.mode === 'zen'
+ ? 'Take that rhythm into Voyage when you’re ready.'
+ : state.hits > 2
+ ? 'Release before the rocks. Save a burst for a tight escape.'
+ : state.perfectLandings === 0
+ ? 'Meet a downhill slope gently for a perfect landing.'
+ : state.skyChains === 0
+ ? 'Link three rings without a miss to call in a light magnet.'
+ : 'Beautiful rhythm. Try this course again against your ghost.'
+ return (
+
+
+
{formatTime(state.time)} IN THE SKY
+
+
+
+
+
+
+
+ {state.perfectLandings} perfect
+
+
+ {state.skyChains} sky chains
+
+
+ {state.maxAirtime.toFixed(1)}s longest glide
+
+
+ {Math.round(state.maxSpeed * 0.36)} km/h top speed
+
+
+
{advice}
+
+ )
+}
diff --git a/projects/sunwake-a6f3/src/components.tsx b/projects/sunwake-a6f3/src/components.tsx
index a1c986dd..f32c65de 100644
--- a/projects/sunwake-a6f3/src/components.tsx
+++ b/projects/sunwake-a6f3/src/components.tsx
@@ -21,6 +21,11 @@ type IconName =
| 'soar'
| 'ring'
| 'restart'
+ | 'shield'
+ | 'magnet'
+ | 'ghost'
+ | 'journal'
+ | 'share'
export function Icon({
name,
@@ -32,6 +37,34 @@ export function Icon({
className?: string
}) {
const paths: Record = {
+ shield: (
+ <>
+
+
+ >
+ ),
+ magnet: (
+ <>
+
+
+ >
+ ),
+ ghost: (
+ <>
+
+
+ >
+ ),
+ journal: (
+ <>
+
+ >
+ ),
+ share: (
+ <>
+
+ >
+ ),
sun: (
<>
diff --git a/projects/sunwake-a6f3/src/game/audio.ts b/projects/sunwake-a6f3/src/game/audio.ts
index acaec308..22838b72 100644
--- a/projects/sunwake-a6f3/src/game/audio.ts
+++ b/projects/sunwake-a6f3/src/game/audio.ts
@@ -182,6 +182,10 @@ export class SunwakeAudio {
this.tone(739.99, 0.8, 0.065, 0.09)
this.tone(1108.73, 0.8, 0.04, 0.17)
break
+ case 'perfect':
+ this.tone(659.25, 0.5, 0.055, 0.08)
+ this.tone(330, 0.45, 0.09)
+ break
case 'landing':
this.tone(220, 0.45, 0.1)
this.tone(440, 0.4, 0.03, 0.025)
@@ -202,6 +206,19 @@ export class SunwakeAudio {
this.tone(frequency, 1.8, 0.065, index * 0.2),
)
break
+ case 'chain':
+ ;[440, 659.25, 880, 1318.5].forEach((note, i) =>
+ this.tone(note, 0.9, 0.065, i * 0.09),
+ )
+ break
+ case 'power':
+ this.tone(554.37, 0.6, 0.07)
+ this.tone(880, 0.7, 0.05, 0.14)
+ break
+ case 'thermal':
+ this.tone(329.63, 0.8, 0.06)
+ this.tone(493.88, 0.9, 0.04, 0.16)
+ break
case 'end':
break
}
diff --git a/projects/sunwake-a6f3/src/game/engine.ts b/projects/sunwake-a6f3/src/game/engine.ts
index b43b220a..29e1461f 100644
--- a/projects/sunwake-a6f3/src/game/engine.ts
+++ b/projects/sunwake-a6f3/src/game/engine.ts
@@ -26,25 +26,69 @@ function noise(seed: number, coordinate: number): number {
return ((value ^ (value >>> 16)) >>> 0) / 4294967296
}
-/** Smooth, analytically continuous dunes shared by simulation and renderer. */
-export function terrain(x: number, seed: number): number {
- const phase = noise(seed, 1) * TAU
+/** Continuous region profiles: long swells, steep dunes, close ripples, and broad bowls. */
+const PROFILES = [
+ [132, 66],
+ [154, 87],
+ [113, 58],
+ [174, 77],
+] as const
+let cachedSeed = NaN
+let cachedPhase = 0
+function phaseFor(seed: number): number {
+ if (seed !== cachedSeed) {
+ cachedSeed = seed
+ cachedPhase = noise(seed, 1) * TAU
+ }
+ return cachedPhase
+}
+function profile(
+ x: number,
+ phase: number,
+ region: number,
+ slope: boolean,
+): number {
+ const [length, height] = PROFILES[region]
+ const swell = x / 2700 + phase
+ const wave = x / length + phase + Math.sin(swell) * 0.65
+ if (slope)
+ return (
+ Math.cos(wave) * (1 / length + (Math.cos(swell) * 0.65) / 2700) * height +
+ (Math.cos(x / 311 + phase * 0.7) * 25) / 311 +
+ (Math.cos(x / 67 + phase * 1.3) * 8) / 67
+ )
return (
523 +
- Math.sin(x / 132 + phase) * 66 +
+ Math.sin(wave) * height +
Math.sin(x / 311 + phase * 0.7) * 25 +
Math.sin(x / 67 + phase * 1.3) * 8
)
}
-
-export function terrainSlope(x: number, seed: number): number {
- const phase = noise(seed, 1) * TAU
+function dune(x: number, seed: number, slope: boolean): number {
+ const phase = phaseFor(seed)
+ const progress = Math.max(0, (x - START_X) / 10000)
+ const region = Math.floor(progress) % 4
+ const t = clamp(((progress % 1) - 0.8) / 0.2, 0, 1)
+ const current = profile(x, phase, region, slope)
+ if (t === 0) return current
+ const next = profile(x, phase, (region + 1) % 4, slope)
+ const blend = t * t * t * (t * (t * 6 - 15) + 10)
+ const result = current + (next - current) * blend
+ if (!slope) return result
+ const derivative = (30 * t * t * (t - 1) * (t - 1)) / 2000
return (
- Math.cos(x / 132 + phase) * (66 / 132) +
- Math.cos(x / 311 + phase * 0.7) * (25 / 311) +
- Math.cos(x / 67 + phase * 1.3) * (8 / 67)
+ result +
+ (profile(x, phase, (region + 1) % 4, false) -
+ profile(x, phase, region, false)) *
+ derivative
)
}
+export function terrain(x: number, seed: number): number {
+ return dune(x, seed, false)
+}
+export function terrainSlope(x: number, seed: number): number {
+ return dune(x, seed, true)
+}
export class SunwakeEngine {
state: GameState
@@ -84,6 +128,18 @@ export class SunwakeEngine {
maxAirtime: 0,
nearMisses: 0,
boosts: 0,
+ hits: 0,
+ perfectLandings: 0,
+ skyChains: 0,
+ ringChain: 0,
+ chainDeadline: 0,
+ magneticSparks: 0,
+ thermalsRidden: 0,
+ maxSpeed: 360,
+ diving: false,
+ flightCue: 'dive',
+ landing: 'none',
+ landingAt: -10,
biome: 0,
reason: '',
shake: 0,
@@ -98,6 +154,8 @@ export class SunwakeEngine {
charge: 25,
invincible: 0,
boostTime: 0,
+ shield: false,
+ magnetTime: 0,
trail: [],
},
entities: [],
@@ -160,12 +218,21 @@ export class SunwakeEngine {
const state = this.state
const player = state.player
state.time += dt
+ state.diving = input.dive
+ player.magnetTime = Math.max(0, player.magnetTime - dt)
+ if (state.time > state.chainDeadline) state.ringChain = 0
state.shake = Math.max(0, state.shake - dt * 3.5)
player.invincible = Math.max(0, player.invincible - dt)
player.boostTime = Math.max(0, player.boostTime - dt)
this.launchCooldown = Math.max(0, this.launchCooldown - dt)
- if (input.boost && !this.lastBoost && player.charge >= 65) this.boost()
+ if (
+ input.boost &&
+ !this.lastBoost &&
+ player.charge >= 65 &&
+ player.boostTime <= 0
+ )
+ this.boost()
const boosting = player.boostTime > 0
const oldSlope = terrainSlope(player.x, state.seed)
const released = this.lastDive && !input.dive
@@ -230,8 +297,14 @@ export class SunwakeEngine {
this.spray(player.x, player.y, 35, '#fae2ad', 240)
}
+ state.maxSpeed = Math.max(state.maxSpeed, player.vx)
+ state.flightCue = player.grounded
+ ? slope > -0.07
+ ? 'dive'
+ : 'release'
+ : 'glide'
this.populate()
- this.collide()
+ this.collide(dt)
this.effects(dt, input.dive)
state.entities = state.entities.filter(
(entity) => entity.x > player.x - 260,
@@ -245,7 +318,11 @@ export class SunwakeEngine {
// The opening stretch is deliberately generous while the pilot learns the sail.
player.energy = Math.max(
0,
- player.energy - dt * (state.time < 8 ? 0.35 : 1.65),
+ player.energy -
+ dt *
+ (state.time < 8
+ ? 0.35
+ : 4.5 + clamp((state.distance - 1800) / 4000, 0, 1) * 2),
)
if (player.energy <= 0) {
state.phase = 'ended'
@@ -276,20 +353,39 @@ export class SunwakeEngine {
const flew = this.airDuration > 0.42
const clean =
flew && ((slope > 0.09 && impact < 590) || Math.abs(impact) < 200)
+ const perfect =
+ clean && slope > 0.12 && Math.abs(impact) < 430 && this.airDuration > 0.65
+ state.landing = perfect
+ ? 'perfect'
+ : clean
+ ? 'clean'
+ : flew
+ ? 'rough'
+ : 'none'
+ if (flew) state.landingAt = state.time
player.y = ground
player.grounded = true
player.vy = slope * player.vx
this.launchCooldown = Math.max(this.launchCooldown, 0.18)
if (clean) {
state.cleanLandings++
+ if (perfect) {
+ state.perfectLandings++
+ this.charge(8)
+ this.scoreBonus += 120 * Math.max(1, state.combo)
+ }
this.addCombo()
- player.charge = Math.min(100, player.charge + 14)
+ this.charge(14)
player.energy = Math.min(100, player.energy + 1.5)
player.vx = Math.min(760, player.vx + 32 + Math.max(0, slope) * 45)
this.scoreBonus += 80 * Math.max(1, state.combo)
this.event(
- 'landing',
- state.combo > 1 ? `Silky landing · ×${state.combo}` : 'Silky landing',
+ perfect ? 'perfect' : 'landing',
+ perfect
+ ? `Perfect landing · ×${state.combo}`
+ : state.combo > 1
+ ? `Silky landing · ×${state.combo}`
+ : 'Silky landing',
)
this.spray(player.x, player.y + 12, 20, '#a2efe0', 160)
} else if (flew) {
@@ -300,6 +396,14 @@ export class SunwakeEngine {
this.airDuration = 0
}
+ private charge(amount: number): void {
+ const player = this.state.player
+ player.charge = Math.min(
+ 100,
+ player.charge + amount * (player.boostTime > 0 ? 0.3 : 1),
+ )
+ }
+
private boost(): void {
const state = this.state
const player = state.player
@@ -341,20 +445,51 @@ export class SunwakeEngine {
r(3 + i) * TAU,
)
}
- const ringX = base + 640
- this.entity(
- 'ring',
- ringX,
- terrain(ringX, state.seed) - (105 + r(10) * 105),
- 30,
- r(11) * TAU,
- )
+ if (index % 6 === 3) {
+ // Three aligned gates invite a deliberate arcing flight across a safe chunk.
+ const xs = [base + 270, base + 440, base + 610]
+ const ceiling = Math.min(...xs.map((x) => terrain(x, state.seed))) - 65
+ xs.forEach((x, i) =>
+ this.entity('ring', x, ceiling - (i === 1 ? 28 : 0), 35, r(11) * TAU),
+ )
+ } else {
+ const ringX = base + 640
+ this.entity(
+ 'ring',
+ ringX,
+ terrain(ringX, state.seed) - (85 + r(10) * 100),
+ 32,
+ r(11) * TAU,
+ )
+ }
+ const powerChunk = index > 1 && (index % 8 === 3 || index % 8 === 5)
+ if (powerChunk) {
+ const x = base + 110
+ this.entity(
+ index % 8 === 3 ? 'shield' : 'magnet',
+ x,
+ terrain(x, state.seed) - 45,
+ 24,
+ r(21) * TAU,
+ )
+ }
+ if (index > 1 && index % 7 === 4) {
+ const x = base + 180
+ this.entity('thermal', x, terrain(x, state.seed) - 115, 55, r(22) * TAU)
+ }
if (index > 0 && index % 4 === 2) {
const x = base + 130
this.entity('sunwell', x, terrain(x, state.seed) - 38, 23, r(12) * TAU)
}
// Obstacles never share a chunk with the first lesson. Every obstacle has an open route.
- if (base > 4100 && index % 4 !== 2 && r(14) > 0.2) {
+ if (
+ base > 4100 &&
+ index % 4 !== 2 &&
+ index % 6 !== 3 &&
+ index % 7 !== 4 &&
+ !powerChunk &&
+ r(14) > 0.12
+ ) {
const x = base + 160
if (r(15) < 0.56) {
this.entity(
@@ -395,14 +530,52 @@ export class SunwakeEngine {
})
}
- private collide(): void {
+ private collide(dt: number): void {
const state = this.state
const player = state.player
for (const entity of state.entities) {
- if (entity.collected || Math.abs(entity.x - player.x) > 110) continue
- const dx = entity.x - player.x
- const dy = entity.y - player.y
- const distance = Math.hypot(dx, dy)
+ if (entity.collected) continue
+ if (
+ entity.kind === 'ring' &&
+ !entity.missed &&
+ entity.x < player.x - 80
+ ) {
+ entity.missed = true
+ state.ringChain = 0
+ }
+ if (Math.abs(entity.x - player.x) > 210) continue
+ if (entity.kind === 'thermal') {
+ if (
+ !state.diving &&
+ Math.abs(entity.x - player.x) < 65 &&
+ player.y > entity.y - 145 &&
+ player.y < entity.y + 135
+ ) {
+ entity.collected = true
+ player.grounded = false
+ player.vy = Math.min(player.vy, -390)
+ player.y -= 2
+ this.launchCooldown = 0.3
+ state.thermalsRidden++
+ this.charge(10)
+ this.scoreBonus += 150
+ this.event('thermal', 'Rising wind · ride the thermal')
+ this.spray(player.x, player.y, 20, '#baf3e1', 180)
+ }
+ continue
+ }
+ let dx = entity.x - player.x
+ let dy = entity.y - player.y
+ let distance = Math.hypot(dx, dy)
+ if (entity.kind === 'spark' && player.magnetTime > 0 && distance < 195) {
+ // Pull faster than the skiff can fly, including during a solar burst.
+ const attraction = Math.min(1, (1750 * dt) / Math.max(1, distance))
+ entity.x -= dx * attraction
+ entity.y -= dy * attraction
+ dx = entity.x - player.x
+ dy = entity.y - player.y
+ distance = Math.hypot(dx, dy)
+ }
const hazard = entity.kind === 'rock' || entity.kind === 'storm'
const reach =
entity.radius + (hazard ? 10 : entity.kind === 'spark' ? 23 : 20)
@@ -412,8 +585,20 @@ export class SunwakeEngine {
entity.collected = true
this.scoreBonus += 100
this.spray(entity.x, entity.y, 20, '#fce2bd', 220)
+ } else if (
+ player.invincible <= 0 &&
+ state.time > 8 &&
+ player.shield
+ ) {
+ entity.collected = true
+ player.shield = false
+ player.invincible = 1.5
+ this.event('power', 'Sun shield saved your flow')
+ this.spray(player.x, player.y, 32, '#aef5dc', 220)
} else if (player.invincible <= 0 && state.time > 8) {
entity.collected = true
+ state.hits++
+ state.ringChain = 0
player.invincible = 1.8
player.energy = Math.max(
state.mode === 'zen' ? 100 : 0,
@@ -425,9 +610,11 @@ export class SunwakeEngine {
state.shake = 0.72
this.event(
'hit',
- entity.kind === 'rock'
- ? 'Rough sand · −25 sunlight'
- : 'Storm brushed · −25 sunlight',
+ state.mode === 'zen'
+ ? 'A bump in the breeze · keep flying'
+ : entity.kind === 'rock'
+ ? 'Rough sand · −25 sunlight'
+ : 'Storm brushed · −25 sunlight',
)
this.spray(player.x, player.y, 24, '#ee927e', 210)
}
@@ -436,21 +623,42 @@ export class SunwakeEngine {
const multiplier = Math.max(1, state.combo)
if (entity.kind === 'spark') {
state.sparks++
- player.energy = Math.min(100, player.energy + 0.55)
- player.charge = Math.min(100, player.charge + 2)
+ if (player.magnetTime > 0) state.magneticSparks++
+ player.energy = Math.min(100, player.energy + 0.38)
+ this.charge(2)
this.scoreBonus += 15 * multiplier
this.spray(entity.x, entity.y, 5, '#ffecaf', 85)
} else if (entity.kind === 'ring') {
state.rings++
this.addCombo()
player.energy = Math.min(100, player.energy + 5)
- player.charge = Math.min(100, player.charge + 15)
+ this.charge(15)
this.scoreBonus += 120 * Math.max(1, state.combo)
this.event('ring', `Sun ring · ×${Math.max(1, state.combo)}`)
this.spray(entity.x, entity.y, 22, '#ffdea0', 180)
+ state.ringChain++
+ state.chainDeadline = state.time + 9
+ if (state.ringChain >= 3) {
+ state.ringChain = 0
+ state.skyChains++
+ player.magnetTime = Math.max(player.magnetTime, 8)
+ player.energy = Math.min(100, player.energy + 12)
+ this.charge(25)
+ this.scoreBonus += 600 * Math.max(1, state.combo)
+ this.event('chain', 'Sky chain! · light magnet + bonus')
+ this.spray(player.x, player.y, 40, '#cdefff', 260)
+ }
+ } else if (entity.kind === 'shield') {
+ player.shield = true
+ this.event('power', 'Sun shield · one free hit')
+ this.spray(entity.x, entity.y, 24, '#b1f2d2', 160)
+ } else if (entity.kind === 'magnet') {
+ player.magnetTime = 10
+ this.event('power', 'Light magnet · 10 seconds')
+ this.spray(entity.x, entity.y, 24, '#c9c2ff', 160)
} else {
player.energy = Math.min(100, player.energy + 20)
- player.charge = Math.min(100, player.charge + 12)
+ this.charge(12)
this.scoreBonus += 100
this.event('spark', 'Sunwell · +20 sunlight')
this.spray(entity.x, entity.y, 26, '#a4eee0', 180)
@@ -464,7 +672,7 @@ export class SunwakeEngine {
) {
entity.nearMissed = true
state.nearMisses++
- player.charge = Math.min(100, player.charge + 7)
+ this.charge(7)
this.scoreBonus += 60 * Math.max(1, state.combo)
this.event('near', 'Close call · +7 charge')
}
diff --git a/projects/sunwake-a6f3/src/game/input.ts b/projects/sunwake-a6f3/src/game/input.ts
new file mode 100644
index 00000000..f7ccf3aa
--- /dev/null
+++ b/projects/sunwake-a6f3/src/game/input.ts
@@ -0,0 +1,31 @@
+import type { GameInput } from './types'
+
+/** Independent input sources prevent one released finger/key from cancelling another. */
+export class FlightInput {
+ private dive = new Set()
+ private boost = new Set()
+ private burstUntil = 0
+ set(action: keyof GameInput, source: string, pressed: boolean): void {
+ const held = action === 'dive' ? this.dive : this.boost
+ if (pressed) held.add(source)
+ else held.delete(source)
+ }
+ release(source: string): void {
+ this.dive.delete(source)
+ this.boost.delete(source)
+ }
+ pulse(now: number): void {
+ this.burstUntil = now + 140
+ }
+ value(now: number): GameInput {
+ return {
+ dive: this.dive.size > 0,
+ boost: this.boost.size > 0 || now < this.burstUntil,
+ }
+ }
+ clear(): void {
+ this.dive.clear()
+ this.boost.clear()
+ this.burstUntil = 0
+ }
+}
diff --git a/projects/sunwake-a6f3/src/game/progress.ts b/projects/sunwake-a6f3/src/game/progress.ts
index de244586..91938a56 100644
--- a/projects/sunwake-a6f3/src/game/progress.ts
+++ b/projects/sunwake-a6f3/src/game/progress.ts
@@ -1,4 +1,18 @@
-import type { GameState, ShipId } from './types'
+import type { GameMode, GameState, ShipId } from './types'
+
+export interface FlightLog {
+ id: string
+ date: string
+ mode: GameMode
+ seed: number
+ distance: number
+ score: number
+ duration: number
+ sparks: number
+ rings: number
+ perfect: number
+ chains: number
+}
export interface Progress {
version: 1
@@ -12,6 +26,9 @@ export interface Progress {
completed: string[]
daily: { date: string; best: number }
sound: boolean
+ history: FlightLog[]
+ coach: boolean
+ ghost: boolean
}
export interface Mission {
@@ -28,6 +45,10 @@ export interface Mission {
| 'bestCombo'
| 'boosts'
| 'nearMisses'
+ | 'perfectLandings'
+ | 'skyChains'
+ | 'thermalsRidden'
+ | 'magneticSparks'
target: number
}
@@ -160,6 +181,41 @@ export const MISSIONS: Mission[] = [
},
]
+MISSIONS.push(
+ {
+ id: 'sky-weaver',
+ title: 'Sky weaver',
+ description: 'Link 3 rings without missing one',
+ kind: 'skyChains',
+ target: 1,
+ reward: 75,
+ },
+ {
+ id: 'wind-rider',
+ title: 'Wind rider',
+ description: 'Ride 3 thermals in one flight',
+ kind: 'thermalsRidden',
+ target: 3,
+ reward: 75,
+ },
+ {
+ id: 'golden-touch',
+ title: 'Golden touch',
+ description: 'Make 5 perfect landings in one flight',
+ kind: 'perfectLandings',
+ target: 5,
+ reward: 100,
+ },
+ {
+ id: 'magnetic-personality',
+ title: 'Magnetic personality',
+ description: 'Attract 30 sparks in one flight',
+ kind: 'magneticSparks',
+ target: 30,
+ reward: 100,
+ },
+)
+
const SAVE_KEY = 'sunwake.progress.v1'
const MAX_VALUE = 1_000_000_000_000
const today = () => new Date().toISOString().slice(0, 10)
@@ -196,7 +252,67 @@ export function freshProgress(): Progress {
completed: [],
daily: { date: today(), best: 0 },
sound: false,
+ history: [],
+ coach: true,
+ ghost: true,
+ }
+}
+
+function readHistory(raw: unknown): FlightLog[] {
+ if (!Array.isArray(raw)) return []
+ return raw
+ .filter((item): item is FlightLog => {
+ if (!item || typeof item !== 'object') return false
+ const row = item as FlightLog
+ return (
+ typeof row.id === 'string' &&
+ row.id.length < 80 &&
+ validDate(row.date) &&
+ ['voyage', 'daily', 'zen'].includes(row.mode) &&
+ Number.isInteger(row.seed) &&
+ row.seed > 0 &&
+ row.seed <= 0xffffffff &&
+ [
+ 'distance',
+ 'score',
+ 'duration',
+ 'sparks',
+ 'rings',
+ 'perfect',
+ 'chains',
+ ].every((key) => {
+ const n = row[key as keyof FlightLog]
+ return (
+ typeof n === 'number' &&
+ Number.isFinite(n) &&
+ n >= 0 &&
+ n <= MAX_VALUE
+ )
+ })
+ )
+ })
+ .slice(0, 12)
+ .map((row) => ({ ...row }))
+}
+function historyEntry(
+ progress: Progress,
+ state: GameState,
+ date: string,
+): FlightLog[] {
+ const entry: FlightLog = {
+ id: `${date}-${progress.totalRuns + 1}-${state.seed}`,
+ date,
+ mode: state.mode,
+ seed: state.seed,
+ distance: count(state.distance),
+ score: count(state.score),
+ duration: count(state.time),
+ sparks: count(state.sparks),
+ rings: count(state.rings),
+ perfect: count(state.perfectLandings),
+ chains: count(state.skyChains),
}
+ return [entry, ...progress.history].slice(0, 12)
}
export function loadProgress(): Progress {
@@ -245,6 +361,9 @@ export function loadProgress(): Progress {
: 'sol',
completed,
sound: data.sound === true,
+ coach: data.coach !== false,
+ ghost: data.ghost !== false,
+ history: readHistory(data.history),
daily: {
date,
best:
@@ -286,10 +405,13 @@ export function settleRun(
} {
if (state.phase !== 'ended')
return { progress, earned: 0, completed: [], newBest: false }
+ const runDate = validDate(date) ? date : today()
+ const history = historyEntry(progress, state, runDate)
if (state.mode === 'zen') {
return {
progress: {
...progress,
+ history,
totalDistance: count(progress.totalDistance + count(state.distance)),
totalRuns: count(progress.totalRuns + 1),
},
@@ -309,10 +431,10 @@ export function settleRun(
count(state.sparks) +
completed.reduce((sum, mission) => sum + mission.reward, 0)
const distance = count(state.distance)
- const runDate = validDate(date) ? date : today()
const dailyBest = progress.daily.date === runDate ? progress.daily.best : 0
const next: Progress = {
...progress,
+ history,
bank: count(progress.bank + earned),
best: Math.max(progress.best, distance),
bestScore: Math.max(progress.bestScore, count(state.score)),
@@ -335,3 +457,8 @@ export function settleRun(
newBest: distance > progress.best,
}
}
+
+export const formatTime = (seconds: number) =>
+ `${Math.floor(seconds / 60)}:${Math.floor(seconds % 60)
+ .toString()
+ .padStart(2, '0')}`
diff --git a/projects/sunwake-a6f3/src/game/renderer.ts b/projects/sunwake-a6f3/src/game/renderer.ts
index d743b250..f45729cc 100644
--- a/projects/sunwake-a6f3/src/game/renderer.ts
+++ b/projects/sunwake-a6f3/src/game/renderer.ts
@@ -1,4 +1,5 @@
import { terrain } from './engine'
+import type { GhostPosition } from './replay'
import { WORLD_HEIGHT } from './types'
import type { Entity, GameState, ShipId } from './types'
@@ -238,7 +239,7 @@ function mountains(
const offset = camera * (0.065 + layer * 0.07)
ctx.beginPath()
ctx.moveTo(-30, 740)
- for (let x = -30; x <= w + 30; x += 15) {
+ for (let x = -30; x <= w + 30; x += 22) {
const pointX = x + offset
const jagged =
layer === 0
@@ -364,7 +365,7 @@ function ground(
line++
) {
ctx.beginPath()
- for (let x = -24; x <= w + 24; x += 15) {
+ for (let x = -24; x <= w + 24; x += 22) {
const worldX = x + camera
const depth = 12 + line * 17 + line * line * 1.55
const y =
@@ -425,7 +426,71 @@ function collectible(
const pulse = Math.sin(time * 3 + phase)
ctx.save()
ctx.translate(x, y)
- if (kind === 'spark') {
+ if (kind === 'shield' || kind === 'magnet') {
+ ctx.fillStyle = kind === 'shield' ? '#72d9c7' : '#c0a7ed'
+ ctx.strokeStyle = '#fff5df'
+ ctx.lineWidth = 1.8
+ ctx.beginPath()
+ for (let i = 0; i < 6; i++) {
+ const angle = (Math.PI / 3) * i - Math.PI / 2
+ const px = Math.cos(angle) * 27,
+ py = Math.sin(angle) * 27
+ if (i === 0) ctx.moveTo(px, py)
+ else ctx.lineTo(px, py)
+ }
+ ctx.closePath()
+ ctx.fill()
+ ctx.stroke()
+ ctx.strokeStyle = kind === 'shield' ? '#376d69' : '#6b508c'
+ ctx.lineWidth = 2.5
+ ctx.beginPath()
+ if (kind === 'shield') {
+ ctx.moveTo(0, -13)
+ ctx.lineTo(11, -8)
+ ctx.lineTo(9, 6)
+ ctx.quadraticCurveTo(5, 12, 0, 16)
+ ctx.quadraticCurveTo(-8, 11, -10, 4)
+ ctx.lineTo(-11, -8)
+ ctx.closePath()
+ ctx.moveTo(-5, 0)
+ ctx.lineTo(-1, 5)
+ ctx.lineTo(6, -5)
+ } else {
+ ctx.moveTo(-9, -12)
+ ctx.lineTo(-9, 3)
+ ctx.bezierCurveTo(-9, 17, 9, 17, 9, 3)
+ ctx.lineTo(9, -12)
+ ctx.moveTo(-13, -5)
+ ctx.lineTo(-5, -5)
+ ctx.moveTo(5, -5)
+ ctx.lineTo(13, -5)
+ }
+ ctx.stroke()
+ ctx.globalAlpha = 0.3
+ circle(ctx, 0, 0, 35 + pulse * 2)
+ ctx.stroke()
+ } else if (kind === 'thermal') {
+ const glow = ctx.createLinearGradient(0, -145, 0, 115)
+ glow.addColorStop(0, '#c6fff000')
+ glow.addColorStop(0.5, '#c6fff027')
+ glow.addColorStop(1, '#c6fff065')
+ ctx.fillStyle = glow
+ ctx.fillRect(-48, -145, 96, 260)
+ ctx.strokeStyle = '#dbffde'
+ ctx.lineWidth = 2
+ for (let i = 0; i < 5; i++) {
+ const yy = 105 - mod(time * 55 + i * 50, 250)
+ ctx.globalAlpha = (yy + 145) / 330
+ ctx.beginPath()
+ ctx.moveTo(-15, yy + 10)
+ ctx.quadraticCurveTo(0, yy - 14, 15, yy + 10)
+ ctx.stroke()
+ }
+ ctx.globalAlpha = 0.75
+ ctx.beginPath()
+ ctx.ellipse(0, 116, 48, 9, 0, 0, TAU)
+ ctx.stroke()
+ } else if (kind === 'spark') {
const glow = ctx.createRadialGradient(0, 0, 1, 0, 0, 24)
glow.addColorStop(0, '#fff0b34a')
glow.addColorStop(1, '#fff0b300')
@@ -794,6 +859,7 @@ function player(
camera: number,
time: number,
palette: string[],
+ reducedMotion: boolean,
) {
const craft = state.player
const charge = Math.min(1, Math.max(0, craft.charge / 100))
@@ -841,6 +907,34 @@ function player(
ctx.quadraticCurveTo(-79, 21, -20, 15)
ctx.fill()
}
+ if (craft.shield) {
+ ctx.strokeStyle = '#bdffdf'
+ ctx.fillStyle = '#94e1d318'
+ ctx.lineWidth = 2
+ ctx.beginPath()
+ ctx.ellipse(0, -22, 48, 59, 0, 0, TAU)
+ ctx.fill()
+ ctx.stroke()
+ }
+ if (craft.magnetTime > 0) {
+ ctx.strokeStyle = '#e7d1ff'
+ ctx.lineWidth = 1.5
+ ctx.globalAlpha = 0.5
+ for (let i = 0; i < 2; i++) {
+ ctx.beginPath()
+ ctx.ellipse(
+ 0,
+ -22,
+ 57 + i * 15,
+ 31 + i * 12,
+ -0.3,
+ time + i * Math.PI,
+ time + i * Math.PI + 2.8,
+ )
+ ctx.stroke()
+ }
+ ctx.globalAlpha = 1
+ }
if (craft.invincible > 0 || craft.boostTime > 0) {
ctx.strokeStyle = palette[11]
ctx.globalAlpha =
@@ -851,7 +945,8 @@ function player(
ctx.stroke()
ctx.globalAlpha = 1
}
- if (craft.invincible > 0 && Math.sin(time * 23) > 0.45) ctx.globalAlpha = 0.55
+ if (craft.invincible > 0 && (reducedMotion || Math.sin(time * 12) > 0.45))
+ ctx.globalAlpha = 0.55
drawShip(ctx, state.ship, time, palette, charge)
ctx.restore()
@@ -874,6 +969,106 @@ function player(
}
}
+function drawGhost(
+ ctx: CanvasRenderingContext2D,
+ point: GhostPosition,
+ camera: number,
+ scale: number,
+) {
+ const x = point.x - camera
+ if (point.finished || x < -80) return
+ ctx.save()
+ ctx.translate(x, point.y)
+ ctx.rotate(point.angle)
+ ctx.strokeStyle = '#fff9e3a6'
+ ctx.fillStyle = '#fff9e312'
+ ctx.lineWidth = 1.6
+ ctx.setLineDash([4, 4])
+ ctx.beginPath()
+ ctx.moveTo(-35, 3)
+ ctx.quadraticCurveTo(0, 22, 35, -1)
+ ctx.lineTo(-35, 3)
+ ctx.moveTo(3, 2)
+ ctx.lineTo(3, -67)
+ ctx.lineTo(31, -25)
+ ctx.lineTo(3, -13)
+ ctx.fill()
+ ctx.stroke()
+ ctx.setLineDash([])
+ ctx.beginPath()
+ ctx.moveTo(-12, -23)
+ ctx.lineTo(-15, -7)
+ ctx.lineTo(-6, 2)
+ ctx.moveTo(-11, -19)
+ ctx.lineTo(3, -17)
+ ctx.stroke()
+ circle(ctx, -12, -29, 5)
+ ctx.stroke()
+ ctx.rotate(-point.angle)
+ ctx.fillStyle = '#fff9e3c9'
+ ctx.font = `600 ${Math.max(10, 8 / scale)}px sans-serif`
+ ctx.textAlign = 'center'
+ ctx.fillText('YOUR GHOST', 0, -87)
+ ctx.restore()
+}
+function regionAtmosphere(
+ ctx: CanvasRenderingContext2D,
+ state: GameState,
+ w: number,
+ camera: number,
+ time: number,
+ palette: string[],
+) {
+ ctx.save()
+ if (state.biome === 2) {
+ // Slow, suspended fragments make the violet reach feel lighter than the dunes.
+ for (let i = 0; i < 6; i++) {
+ const x = mod(i * 320 - camera * 0.11, w + 320) - 100
+ const y = 220 + random(i + 81) * 120 + Math.sin(time * 0.3 + i) * 4
+ ctx.fillStyle = palette[4]
+ ctx.globalAlpha = 0.33
+ ctx.beginPath()
+ ctx.moveTo(x - 29, y)
+ ctx.lineTo(x + 26, y - 7)
+ ctx.lineTo(x + 5, y + 44)
+ ctx.closePath()
+ ctx.fill()
+ ctx.strokeStyle = '#f3d6f7'
+ ctx.globalAlpha = 0.18
+ ctx.lineWidth = 0.8
+ ctx.beginPath()
+ ctx.ellipse(x, y - 7, 42, 9, -0.18, 0, TAU)
+ ctx.stroke()
+ }
+ }
+ if (state.biome === 3) {
+ for (let ribbon = 0; ribbon < 3; ribbon++) {
+ const glow = ctx.createLinearGradient(0, 70, 0, 285)
+ glow.addColorStop(0, '#b7f8e200')
+ glow.addColorStop(0.6, '#b7f8e21c')
+ glow.addColorStop(1, '#d8bdf200')
+ ctx.fillStyle = glow
+ ctx.beginPath()
+ ctx.moveTo(-20, 30)
+ for (let x = -20; x <= w + 30; x += 25)
+ ctx.lineTo(
+ x,
+ 120 + ribbon * 40 + Math.sin(x * 0.004 + time * 0.12 + ribbon) * 40,
+ )
+ for (let x = w + 30; x >= -20; x -= 25)
+ ctx.lineTo(
+ x,
+ 190 +
+ ribbon * 40 +
+ Math.sin(x * 0.004 + time * 0.12 + ribbon + 0.5) * 55,
+ )
+ ctx.closePath()
+ ctx.fill()
+ }
+ }
+ ctx.restore()
+}
+
/** Draw in CSS pixels without changing the caller's device-pixel-ratio transform. */
export function renderWorld(
ctx: CanvasRenderingContext2D,
@@ -883,6 +1078,7 @@ export function renderWorld(
ambientTime: number,
reducedMotion: boolean,
personalBest = 0,
+ ghost: GhostPosition | null = null,
): void {
if (width <= 0 || height <= 0) return
const ready = state.phase === 'ready'
@@ -896,7 +1092,7 @@ export function renderWorld(
const top = -verticalOffset
const bottom = height / scale - verticalOffset
const camera = state.player.x - w * (ready ? 0.64 : 0.25)
- const time = reducedMotion ? state.time : ambientTime
+ const time = reducedMotion ? 0 : ready ? ambientTime : state.time
const palette = colors(state.distance)
ctx.save()
@@ -904,6 +1100,7 @@ export function renderWorld(
ctx.translate(0, verticalOffset)
ctx.lineJoin = 'round'
sky(ctx, w, camera, time, palette, ready, state.distance, top, bottom)
+ regionAtmosphere(ctx, state, w, camera, time, palette)
mountains(ctx, w, camera, palette)
ctx.save()
@@ -938,7 +1135,8 @@ export function renderWorld(
ctx.fill()
}
ctx.globalAlpha = 1
- player(ctx, state, camera, time, palette)
+ if (ghost && !ready) drawGhost(ctx, ghost, camera, scale)
+ player(ctx, state, camera, time, palette, reducedMotion)
ctx.restore()
if (!reducedMotion && state.phase === 'running' && state.player.vx > 500) {
diff --git a/projects/sunwake-a6f3/src/game/replay.ts b/projects/sunwake-a6f3/src/game/replay.ts
new file mode 100644
index 00000000..ae894fe2
--- /dev/null
+++ b/projects/sunwake-a6f3/src/game/replay.ts
@@ -0,0 +1,179 @@
+import type { GameMode, GameState, ShipId } from './types'
+
+// Version the course rules so an old link never silently becomes a different route.
+export const COURSE_VERSION = 2
+export const GHOST_KEY = 'sunwake.ghosts.v2'
+const LIMIT = 960
+const MAX_RUNS = 4
+export type GhostPoint = [time: number, x: number, y: number, angle: number]
+export interface GhostRun {
+ version: 2
+ seed: number
+ ship: ShipId
+ distance: number
+ duration: number
+ points: GhostPoint[]
+}
+export interface GhostPosition {
+ x: number
+ y: number
+ angle: number
+ finished: boolean
+}
+
+export function courseFromHash(hash: string): number | null {
+ const match = /^#\/course\/2\/([0-9a-z]{1,7})$/i.exec(hash)
+ if (!match) return null
+ const seed = parseInt(match[1], 36)
+ return Number.isInteger(seed) && seed > 0 && seed <= 0xffffffff ? seed : null
+}
+export function courseHash(seed: number): string {
+ return `#/course/${COURSE_VERSION}/${(seed >>> 0).toString(36)}`
+}
+
+/** Compact an arbitrarily long flight by progressively reducing the sample rate. */
+export class FlightRecorder {
+ private points: GhostPoint[] = []
+ private interval = 0.2
+ record(state: GameState): void {
+ const last = this.points.at(-1)
+ const roundedTime = +state.time.toFixed(3)
+ if (last && state.time - last[0] < this.interval && state.phase !== 'ended')
+ return
+ if (last && roundedTime <= last[0]) return
+ this.points.push([
+ roundedTime,
+ +state.player.x.toFixed(2),
+ +state.player.y.toFixed(2),
+ +state.player.rotation.toFixed(3),
+ ])
+ if (this.points.length >= LIMIT) {
+ const lastIndex = this.points.length - 1
+ this.points = this.points.filter(
+ (_, index) => index % 2 === 0 || index === lastIndex,
+ )
+ this.interval *= 2
+ }
+ }
+ finish(state: GameState): GhostRun {
+ this.record(state)
+ return {
+ version: COURSE_VERSION,
+ seed: state.seed,
+ ship: state.ship,
+ distance: state.distance,
+ duration: state.time,
+ points: this.points.map((point) => [...point]),
+ }
+ }
+}
+
+function validGhost(raw: unknown): raw is GhostRun {
+ if (!raw || typeof raw !== 'object') return false
+ const value = raw as GhostRun
+ if (
+ value.version !== COURSE_VERSION ||
+ !Number.isInteger(value.seed) ||
+ value.seed < 1 ||
+ value.seed > 0xffffffff ||
+ !['sol', 'manta', 'comet'].includes(value.ship)
+ )
+ return false
+ if (
+ !Number.isFinite(value.distance) ||
+ value.distance < 0 ||
+ value.distance > 1e7 ||
+ !Number.isFinite(value.duration) ||
+ value.duration <= 0 ||
+ value.duration > 86400
+ )
+ return false
+ if (
+ !Array.isArray(value.points) ||
+ value.points.length < 2 ||
+ value.points.length > LIMIT
+ )
+ return false
+ let previous = -1
+ for (const point of value.points) {
+ if (
+ !Array.isArray(point) ||
+ point.length !== 4 ||
+ !point.every(Number.isFinite)
+ )
+ return false
+ if (
+ point[0] <= previous ||
+ point[0] < 0 ||
+ point[0] > value.duration + 0.01 ||
+ point[1] < 150 ||
+ point[1] > 1e8 ||
+ point[2] < 0 ||
+ point[2] > 760 ||
+ Math.abs(point[3]) > Math.PI
+ )
+ return false
+ previous = point[0]
+ }
+ return true
+}
+
+export class GhostLibrary {
+ private runs: GhostRun[] = []
+ constructor() {
+ try {
+ const text = localStorage.getItem(GHOST_KEY)
+ if (!text || text.length > 600000) return
+ const raw: unknown = JSON.parse(text)
+ if (Array.isArray(raw))
+ this.runs = raw.filter(validGhost).slice(0, MAX_RUNS)
+ } catch {
+ /* Flights still work when storage is unavailable. */
+ }
+ }
+ get(seed: number): GhostRun | null {
+ return this.runs.find((run) => run.seed === seed) ?? null
+ }
+ remember(run: GhostRun, mode: GameMode): boolean {
+ if (mode === 'zen' || !validGhost(run)) return false
+ const previous = this.get(run.seed)
+ if (previous && previous.distance >= run.distance) return false
+ this.runs = [
+ run,
+ ...this.runs.filter((item) => item.seed !== run.seed),
+ ].slice(0, MAX_RUNS)
+ try {
+ localStorage.setItem(GHOST_KEY, JSON.stringify(this.runs))
+ } catch {
+ /* Retain the in-memory ghost. */
+ }
+ return true
+ }
+}
+
+export function ghostAt(
+ run: GhostRun | null,
+ time: number,
+): GhostPosition | null {
+ if (!run || run.points.length < 2) return null
+ const points = run.points
+ let low = 0,
+ high = points.length - 1
+ while (low + 1 < high) {
+ const middle = (low + high) >>> 1
+ if (points[middle][0] <= time) low = middle
+ else high = middle
+ }
+ const a = points[low],
+ b = points[high]
+ const t = Math.max(
+ 0,
+ Math.min(1, (time - a[0]) / Math.max(0.001, b[0] - a[0])),
+ )
+ return {
+ x: a[1] + (b[1] - a[1]) * t,
+ y: a[2] + (b[2] - a[2]) * t,
+ angle: a[3] + (b[3] - a[3]) * t,
+ finished: time > run.duration,
+ }
+}
diff --git a/projects/sunwake-a6f3/src/game/types.ts b/projects/sunwake-a6f3/src/game/types.ts
index b421fe94..ac40523b 100644
--- a/projects/sunwake-a6f3/src/game/types.ts
+++ b/projects/sunwake-a6f3/src/game/types.ts
@@ -1,9 +1,28 @@
export type GamePhase = 'ready' | 'running' | 'paused' | 'ended'
export type GameMode = 'voyage' | 'daily' | 'zen'
export type ShipId = 'sol' | 'manta' | 'comet'
-export type EntityKind = 'spark' | 'ring' | 'storm' | 'rock' | 'sunwell'
+export type EntityKind =
+ | 'spark'
+ | 'ring'
+ | 'storm'
+ | 'rock'
+ | 'sunwell'
+ | 'shield'
+ | 'magnet'
+ | 'thermal'
export type EventKind =
- 'spark' | 'ring' | 'landing' | 'hit' | 'boost' | 'near' | 'biome' | 'end'
+ | 'spark'
+ | 'ring'
+ | 'landing'
+ | 'hit'
+ | 'boost'
+ | 'near'
+ | 'biome'
+ | 'end'
+ | 'power'
+ | 'chain'
+ | 'perfect'
+ | 'thermal'
export interface Player {
x: number
@@ -16,6 +35,8 @@ export interface Player {
charge: number
invincible: number
boostTime: number
+ shield: boolean
+ magnetTime: number
trail: { x: number; y: number; life: number }[]
}
@@ -28,6 +49,7 @@ export interface Entity {
collected: boolean
nearMissed?: boolean
phase: number
+ missed?: boolean
}
export interface Particle {
@@ -65,6 +87,18 @@ export interface GameState {
maxAirtime: number
nearMisses: number
boosts: number
+ hits: number
+ perfectLandings: number
+ skyChains: number
+ ringChain: number
+ chainDeadline: number
+ magneticSparks: number
+ thermalsRidden: number
+ maxSpeed: number
+ diving: boolean
+ flightCue: 'dive' | 'release' | 'glide'
+ landing: 'none' | 'rough' | 'clean' | 'perfect'
+ landingAt: number
biome: number
player: Player
entities: Entity[]
diff --git a/projects/sunwake-a6f3/tests/progress.test.mjs b/projects/sunwake-a6f3/tests/progress.test.mjs
index 57a3c05a..6e2a720c 100644
--- a/projects/sunwake-a6f3/tests/progress.test.mjs
+++ b/projects/sunwake-a6f3/tests/progress.test.mjs
@@ -20,7 +20,7 @@ after(() => {
})
const run = (overrides = {}) => ({
- phase: 'ended', mode: 'voyage', distance: 1000, score: 4000, sparks: 20,
+ phase: 'ended', mode: 'voyage', seed: 42, time: 30, perfectLandings: 0, skyChains: 0, distance: 1000, score: 4000, sparks: 20,
rings: 10, cleanLandings: 8, maxAirtime: 7, bestCombo: 10, boosts: 6, nearMisses: 6,
...overrides,
})
@@ -117,7 +117,9 @@ test('free flight tracks practice distance without awarding sparks, missions, or
}
const before = structuredClone(progress)
const result = settleRun(progress, run({ mode: 'zen', distance: 100000, sparks: 20000, score: 900000 }), '2026-09-25')
- assert.deepEqual(result.progress, { ...progress, totalDistance: 102000, totalRuns: 4 })
+ assert.deepEqual(result.progress, { ...progress, totalDistance: 102000, totalRuns: 4, history: result.progress.history })
+ assert.equal(result.progress.history[0].mode, 'zen')
+ assert.equal(result.progress.history[0].distance, 100000)
assert.equal(result.earned, 0)
assert.deepEqual(result.completed, [])
assert.equal(result.newBest, false)
@@ -151,3 +153,24 @@ test('daily terrain seeds are repeatable, date-dependent unsigned integers', ()
assert.notEqual(dailySeed('2026-09-24'), dailySeed('2026-09-25'))
assert.equal(dailySeed('2026-09-24') >>> 0, dailySeed('2026-09-24'))
})
+
+
+test('old saves retain earned progress and receive new preference/history defaults', () => {
+ storage.set(key, JSON.stringify({ version: 1, bank: 160, best: 2400, owned: ['sol', 'manta'], selected: 'manta', completed: ['first-light'] }))
+ const saved = loadProgress()
+ assert.equal(saved.bank, 160)
+ assert.equal(saved.best, 2400)
+ assert.equal(saved.selected, 'manta')
+ assert.equal(saved.coach, true)
+ assert.equal(saved.ghost, true)
+ assert.deepEqual(saved.history, [])
+})
+
+test('flight history is bounded, ordered, reloadable, and rejects corrupt rows', () => {
+ let progress = freshProgress()
+ for (let i = 0; i < 20; i++) progress = settleRun(progress, run({ seed: i + 1, distance: 100 + i }), '2026-09-25').progress
+ assert.equal(progress.history.length, 12)
+ assert.equal(progress.history[0].seed, 20)
+ saveProgress({ ...progress, history: [{ bad: true }, ...progress.history] })
+ assert.deepEqual(loadProgress().history, progress.history)
+})
diff --git a/projects/sunwake-a6f3/tests/replay.test.mjs b/projects/sunwake-a6f3/tests/replay.test.mjs
new file mode 100644
index 00000000..b56a4515
--- /dev/null
+++ b/projects/sunwake-a6f3/tests/replay.test.mjs
@@ -0,0 +1,90 @@
+import assert from 'node:assert/strict'
+import { beforeEach, test } from 'node:test'
+import { FlightRecorder, GhostLibrary, courseFromHash, courseHash, ghostAt, GHOST_KEY } from '../src/game/replay.ts'
+import { FlightInput } from '../src/game/input.ts'
+let storage
+beforeEach(() => {
+ storage = new Map()
+ Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: { getItem: k => storage.get(k) ?? null, setItem: (k, v) => storage.set(k, v) } })
+})
+const sample = (time, phase = 'running', seed = 42) => ({ seed, mode: 'voyage', ship: 'sol', time, distance: time * 40, phase, player: { x: 200 + time * 400, y: 480 + Math.sin(time) * 50, rotation: Math.sin(time) * .4 } })
+const record = (seconds = 10, seed = 42) => {
+ const recorder = new FlightRecorder()
+ for (let frame = 0; frame <= seconds * 60; frame++) recorder.record(sample(frame / 60, 'running', seed))
+ return recorder.finish(sample(seconds, 'ended', seed))
+}
+
+test('course links round trip only supported versions and valid unsigned seeds', () => {
+ for (const seed of [1, 42, 0xffffffff]) assert.equal(courseFromHash(courseHash(seed)), seed)
+ for (const hash of ['#/course/1/42', '#/course/2/0', '#/course/2/zzzzzzz', '#/course/2/42?bad', '#/course/2/