From bb302b0ac7cc4d4cbb65c55baa9a8ded37cecef4 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 20 Sep 2026 23:48:27 +0000 Subject: [PATCH] Clarify upgrade tiers and salvage recovery --- index.html | 1 - src/game-v4.js | 92 +++++++++--- src/mobile-hud.js | 18 +-- src/progression/modules.js | 153 +++++++++++++++++--- src/progression/ship-loadout.js | 136 ++++++++++++++++-- src/shipyard-ui.js | 244 +++++++++++++++++++++++++------- tests/progression.test.mjs | 73 ++++++++-- tests/wiring.test.mjs | 25 ++-- 8 files changed, 611 insertions(+), 131 deletions(-) diff --git a/index.html b/index.html index a1f7473..c6f66ac 100644 --- a/index.html +++ b/index.html @@ -133,7 +133,6 @@ - diff --git a/src/game-v4.js b/src/game-v4.js index d6fc565..eed66ee 100644 --- a/src/game-v4.js +++ b/src/game-v4.js @@ -35,9 +35,12 @@ import { collectSalvage, createStarterProgression, deriveShipStats, + describeInventoryEntry, installModule, marketForPort, salvageForPirate, + scrapInventoryModule, + upgradePathForSlot, } from './progression/ship-loadout.js'; import { MODULE_CATALOG } from './progression/modules.js'; import { @@ -491,11 +494,23 @@ class GameScene extends Phaser.Scene { const salvage = wreck.getData?.('salvage'); if (!salvage) return; + const hullBefore = this.playerHull; + const repairAvailable = Math.max(0, salvage.repair || 0); + this.playerHull = Math.min( + this.playerStats?.maxHull || PLAYER_MAX_HULL, + this.playerHull + repairAvailable, + ); + const repairedHull = Math.max(0, this.playerHull - hullBefore); + this.progression = collectSalvage( this.progression, salvage, this.playerStats, ); + this.progression.lastSalvage = { + ...this.progression.lastSalvage, + repairedHull, + }; wreck.destroy(); const moduleName = salvage.moduleId @@ -504,14 +519,14 @@ class GameScene extends Phaser.Scene { const moduleCollected = this.progression.lastSalvage?.moduleCollected; const moduleText = moduleName ? moduleCollected - ? `\nSALVAGED: ${moduleName}` - : `\nHOLD FULL: ${moduleName}` + ? `\nMODULE STORED: ${moduleName}\nFIT OR SCRAP AT SHIPYARD` + : `\nSALVAGE HOLD FULL: ${moduleName}` : ''; this.status.setText( - `SALVAGE +¢${salvage.credits} +${salvage.scrap} SCRAP${moduleText}`, + `SALVAGE +¢${salvage.credits} +${salvage.scrap} SCRAP +${repairedHull} HULL${moduleText}`, ); - this.time.delayedCall(1400, () => { + this.time.delayedCall(2200, () => { if (this.gameState === 'playing') this.status.setText(''); }); } @@ -541,6 +556,7 @@ class GameScene extends Phaser.Scene { || Phaser.Input.Keyboard.JustDown(this.keys.closeShipyard); const purchaseRequested = this.pendingMobileActions.delete('purchase-module'); const mobileFit = this.pendingMobileActions.delete('fit-salvage'); + const scrapSalvage = this.pendingMobileActions.delete('scrap-salvage'); if (closeRequested && this.shipyardOpen) { this.shipyardOpen = false; @@ -592,25 +608,46 @@ class GameScene extends Phaser.Scene { } if (mobileFit || Phaser.Input.Keyboard.JustDown(this.keys.fitSalvage)) { - const moduleId = this.progression.inventory?.[0]; - if (!moduleId) { - this.status.setText('NO SALVAGED MODULES IN HOLD'); + if (!this.shipyardOpen || !docked) { + this.status.setText('FIT RECOVERED MODULES AT THE SHIPYARD'); } else { - const previousMaxHull = this.playerStats.maxHull; - this.progression = installModule(this.progression, moduleId); - this.applyProgressionStats(previousMaxHull); - const module = MODULE_CATALOG[moduleId]; - this.status.setText(`FITTED SALVAGE\n${module?.name || moduleId}`); - } - if (this.shipyardOpen) { + const salvageEntry = this.progression.inventory?.[0]; + const moduleId = typeof salvageEntry === 'string' + ? salvageEntry + : salvageEntry?.moduleId; + if (!moduleId) { + this.status.setText('SALVAGE HOLD IS EMPTY'); + } else { + const previousMaxHull = this.playerStats.maxHull; + this.progression = installModule(this.progression, moduleId); + this.applyProgressionStats(previousMaxHull); + const module = MODULE_CATALOG[moduleId]; + this.status.setText(`FITTED ${module?.name || moduleId}`); + } window.dispatchEvent(new CustomEvent('airship:shipyard-state', { detail: this.buildHudState(), })); } - this.time.delayedCall(1400, () => { - if (this.gameState === 'playing') this.status.setText(''); - }); } + + if (scrapSalvage) { + if (!this.shipyardOpen || !docked) { + this.status.setText('SCRAP RECOVERED MODULES AT THE SHIPYARD'); + } else { + const beforeScrap = this.progression.scrap || 0; + this.progression = scrapInventoryModule(this.progression, 0); + const gained = Math.max(0, (this.progression.scrap || 0) - beforeScrap); + this.status.setText( + this.progression.scrapped + ? `SALVAGED MODULE SCRAPPED +${gained} SCRAP` + : 'SALVAGE HOLD IS EMPTY', + ); + window.dispatchEvent(new CustomEvent('airship:shipyard-state', { + detail: this.buildHudState(), + })); + } + } + } createEnvironmentPhysics() { @@ -1014,6 +1051,7 @@ class GameScene extends Phaser.Scene { moduleId: null, scrap: Math.max(1, Math.round(cargoValue / 70)), credits: Math.max(15, Math.round(cargoValue * 0.45)), + repair: 0, condition: null, }; this.spawnCargoWreck(x, y, cargoSalvage); @@ -1661,8 +1699,14 @@ class GameScene extends Phaser.Scene { const marketUpgrade = docked ? bestAffordableMarketUpgrade(this.progression, portState.port) : null; - const salvageModuleId = this.progression?.inventory?.[0] || null; - const salvageModule = salvageModuleId ? MODULE_CATALOG[salvageModuleId] : null; + const salvageEntry = this.progression?.inventory?.[0] || null; + const salvageItem = describeInventoryEntry(salvageEntry); + const upgradePaths = Object.fromEntries( + ['engine', 'hull', 'cargo', 'rudder'].map((slot) => [ + slot, + upgradePathForSlot(this.progression.loadout, slot, marketStock), + ]), + ); return { gameState: this.gameState, @@ -1711,16 +1755,20 @@ class GameScene extends Phaser.Scene { id: module.id, name: module.name, slot: module.slot, + tier: module.tier ?? null, price: module.price, + effectSummary: module.effectSummary || '', })), + upgradePaths, marketUpgrade: marketUpgrade ? { id: marketUpgrade.id, name: marketUpgrade.name, price: marketUpgrade.price, } : null, - salvageModule: salvageModule ? { - id: salvageModule.id, - name: salvageModule.name, + salvageItem, + salvageModule: salvageItem ? { + id: salvageItem.moduleId, + name: salvageItem.name, } : null, }, world: { diff --git a/src/mobile-hud.js b/src/mobile-hud.js index aca9b4f..f8167d2 100644 --- a/src/mobile-hud.js +++ b/src/mobile-hud.js @@ -425,7 +425,7 @@ if (!isDesktopPointer.matches) { } #mobile-controls.controller-active .mobile-control.buy { - margin-left: -16%; + margin-left: 0; } #mobile-controls.controller-active .mobile-control.fit { @@ -551,7 +551,7 @@ if (!isDesktopPointer.matches) { } #mobile-controls.controller-active .mobile-control.buy { - margin-left: -86px; + margin-left: 0; } #mobile-controls.controller-active .mobile-control.fit { @@ -701,7 +701,6 @@ if (!isDesktopPointer.matches) { [...hud.querySelectorAll('[data-steam]')].map((el) => [el.dataset.steam, el]), ); const buyButton = root.querySelector('.mobile-control.buy'); - const fitButton = root.querySelector('.mobile-control.fit'); const radarContacts = radarPiece.querySelector('#steam-radar-contacts'); @@ -805,15 +804,10 @@ if (!isDesktopPointer.matches) { if (buyButton) { buyButton.hidden = !progression.docked; buyButton.disabled = !progression.docked; - buyButton.textContent = 'SHIPYARD'; - } - if (fitButton) { - const module = progression.salvageModule; - fitButton.hidden = !module; - fitButton.disabled = !module; - fitButton.textContent = module - ? `FIT\n${module.name}` - : 'FIT'; + const salvageCount = progression.inventoryCount || 0; + buyButton.textContent = salvageCount > 0 + ? `SHIPYARD\nSALVAGE ${salvageCount}` + : 'SHIPYARD'; } const statusText = String(state.status || '').trim(); diff --git a/src/progression/modules.js b/src/progression/modules.js index d4366c5..47d999e 100644 --- a/src/progression/modules.js +++ b/src/progression/modules.js @@ -16,34 +16,52 @@ export const SHIP_BASE_STATS = { export const MODULE_CATALOG = { 'stock-engine': { id: 'stock-engine', - name: 'Stock Twin Propellers', + name: 'Stock Engine', slot: 'engine', + tier: 0, rarity: 'common', price: 0, + effectSummary: 'Standard speed and acceleration', modifiers: {}, }, 'tuned-propellers': { id: 'tuned-propellers', - name: 'Engine Lv 1', + name: 'Engine Mk I', slot: 'engine', + tier: 1, rarity: 'improved', price: 800, + effectSummary: '+20 speed • +10 acceleration', modifiers: { maxSpeed: 20, acceleration: 10 }, }, 'corsair-overdrive': { id: 'corsair-overdrive', - name: 'Corsair Overdrive', + name: 'Engine Mk II', slot: 'engine', + tier: 2, rarity: 'rare', - price: 690, - modifiers: { maxSpeed: 34, acceleration: 18, turnRate: -5 }, + price: 1400, + effectSummary: '+38 speed • +20 acceleration • -2 handling', + modifiers: { maxSpeed: 38, acceleration: 20, turnRate: -2 }, }, + 'storm-turbines': { + id: 'storm-turbines', + name: 'Engine Mk III', + slot: 'engine', + tier: 3, + rarity: 'epic', + price: 2200, + effectSummary: '+55 speed • +32 acceleration • -4 handling', + modifiers: { maxSpeed: 55, acceleration: 32, turnRate: -4 }, + }, + 'standard-broadside': { id: 'standard-broadside', name: 'Standard Broadside', slot: 'weapon', rarity: 'common', price: 0, + effectSummary: 'Standard cannon battery', modifiers: {}, }, 'long-nine-cannons': { @@ -52,6 +70,7 @@ export const MODULE_CATALOG = { slot: 'weapon', rarity: 'improved', price: 360, + effectSummary: '+4 cannon damage • slower reload', modifiers: { cannonDamage: 4, reloadMs: 70 }, }, 'quicklock-breeches': { @@ -60,64 +79,160 @@ export const MODULE_CATALOG = { slot: 'weapon', rarity: 'rare', price: 620, + effectSummary: '+2 cannon damage • faster reload', modifiers: { cannonDamage: 2, reloadMs: -180 }, }, + 'timber-hull': { id: 'timber-hull', - name: 'Timber Hull', + name: 'Stock Hull', slot: 'hull', + tier: 0, rarity: 'common', price: 0, + effectSummary: 'Standard hull protection', modifiers: {}, }, 'reinforced-keel': { id: 'reinforced-keel', - name: 'Hull Plating Lv 1', + name: 'Hull Plating Mk I', slot: 'hull', + tier: 1, rarity: 'improved', price: 600, + effectSummary: '+28 hull • -7 speed', modifiers: { maxHull: 28, maxSpeed: -7 }, }, + 'armored-envelope': { + id: 'armored-envelope', + name: 'Hull Plating Mk II', + slot: 'hull', + tier: 2, + rarity: 'rare', + price: 1200, + effectSummary: '+55 hull • -9 speed', + modifiers: { maxHull: 55, maxSpeed: -9 }, + }, + 'ironclad-frame': { + id: 'ironclad-frame', + name: 'Hull Plating Mk III', + slot: 'hull', + tier: 3, + rarity: 'epic', + price: 2000, + effectSummary: '+85 hull • -12 speed', + modifiers: { maxHull: 85, maxSpeed: -12 }, + }, + + 'brass-compass': { + id: 'brass-compass', + name: 'Brass Compass', + slot: 'navigation', + rarity: 'common', + price: 0, + effectSummary: 'Standard navigation gear', + modifiers: {}, + }, 'stormglass-nav': { id: 'stormglass-nav', name: 'Stormglass Navigator', slot: 'navigation', rarity: 'rare', price: 560, + effectSummary: 'Reduced wind drift • +240 radar range', modifiers: { windExposure: -0.22, radarRange: 240 }, }, - 'brass-compass': { - id: 'brass-compass', - name: 'Brass Compass', - slot: 'navigation', - rarity: 'common', - price: 0, - modifiers: {}, - }, 'salvage-rig': { id: 'salvage-rig', name: 'Deck Salvage Rig', slot: 'utility', rarity: 'improved', price: 330, + effectSummary: '+2 salvage hold capacity', modifiers: { salvageCapacity: 2 }, }, + + 'standard-hold': { + id: 'standard-hold', + name: 'Stock Cargo Hold', + slot: 'cargo', + tier: 0, + rarity: 'common', + price: 0, + effectSummary: 'Standard cargo capacity', + modifiers: {}, + }, 'expanded-hold': { id: 'expanded-hold', - name: 'Cargo Hold Lv 1', + name: 'Cargo Hold Mk I', slot: 'cargo', + tier: 1, rarity: 'improved', price: 600, + effectSummary: '+4 cargo • -4 speed', modifiers: { cargoCapacity: 4, maxSpeed: -4 }, }, + 'merchant-hold': { + id: 'merchant-hold', + name: 'Cargo Hold Mk II', + slot: 'cargo', + tier: 2, + rarity: 'rare', + price: 1100, + effectSummary: '+8 cargo • +1 salvage slot • -6 speed', + modifiers: { cargoCapacity: 8, salvageCapacity: 1, maxSpeed: -6 }, + }, + 'deep-hold': { + id: 'deep-hold', + name: 'Cargo Hold Mk III', + slot: 'cargo', + tier: 3, + rarity: 'epic', + price: 1800, + effectSummary: '+12 cargo • +2 salvage slots • -9 speed', + modifiers: { cargoCapacity: 12, salvageCapacity: 2, maxSpeed: -9 }, + }, + + 'stock-rudder': { + id: 'stock-rudder', + name: 'Stock Rudder', + slot: 'rudder', + tier: 0, + rarity: 'common', + price: 0, + effectSummary: 'Standard handling', + modifiers: {}, + }, 'reinforced-rudder': { id: 'reinforced-rudder', - name: 'Rudder Lv 1', + name: 'Rudder Mk I', slot: 'rudder', + tier: 1, rarity: 'improved', price: 400, + effectSummary: '+12 handling', modifiers: { turnRate: 12 }, }, + 'balanced-rudder': { + id: 'balanced-rudder', + name: 'Rudder Mk II', + slot: 'rudder', + tier: 2, + rarity: 'rare', + price: 850, + effectSummary: '+22 handling • +6 braking', + modifiers: { turnRate: 22, brake: 6 }, + }, + 'gyro-rudder': { + id: 'gyro-rudder', + name: 'Rudder Mk III', + slot: 'rudder', + tier: 3, + rarity: 'epic', + price: 1500, + effectSummary: '+32 handling • +10 braking', + modifiers: { turnRate: 32, brake: 10 }, + }, }; export const STARTER_LOADOUT = { @@ -126,8 +241,8 @@ export const STARTER_LOADOUT = { hull: 'timber-hull', navigation: 'brass-compass', utility: null, - cargo: null, - rudder: null, + cargo: 'standard-hold', + rudder: 'stock-rudder', }; export const purchasableModules = () => Object.values(MODULE_CATALOG) diff --git a/src/progression/ship-loadout.js b/src/progression/ship-loadout.js index 33a524b..c095416 100644 --- a/src/progression/ship-loadout.js +++ b/src/progression/ship-loadout.js @@ -7,6 +7,11 @@ import { import { createSeededRandom } from '../world/seeded-random.js'; const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); +const romanTier = (tier) => ['Stock', 'Mk I', 'Mk II', 'Mk III'][tier] || `Mk ${tier}`; +const inventoryModuleId = (entry) => typeof entry === 'string' ? entry : entry?.moduleId || null; +const inventoryCondition = (entry) => typeof entry === 'string' + ? 1 + : clamp(Number(entry?.condition) || 1, 0.05, 1); export const createStarterProgression = () => ({ credits: 650, @@ -39,18 +44,82 @@ export const deriveShipStats = (loadout = STARTER_LOADOUT) => { return stats; }; +export const upgradePathForSlot = (loadout = STARTER_LOADOUT, slot, stock = []) => { + const path = Object.values(MODULE_CATALOG) + .filter((module) => module.slot === slot && Number.isInteger(module.tier)) + .sort((a, b) => a.tier - b.tier); + + const currentId = loadout?.[slot] || path[0]?.id || null; + const current = MODULE_CATALOG[currentId] || path[0] || null; + const currentTier = Number.isInteger(current?.tier) ? current.tier : 0; + const next = path.find((module) => module.tier === currentTier + 1) || null; + const stockIds = new Set((stock || []).map(({ id }) => id)); + + return { + slot, + maxTier: path.at(-1)?.tier || currentTier, + current: current ? { + id: current.id, + name: current.name, + tier: currentTier, + tierLabel: romanTier(currentTier), + effectSummary: current.effectSummary || '', + } : null, + next: next ? { + id: next.id, + name: next.name, + tier: next.tier, + tierLabel: romanTier(next.tier), + price: next.price, + effectSummary: next.effectSummary || '', + stocked: stockIds.has(next.id), + } : null, + }; +}; + +export const describeInventoryEntry = (entry) => { + const moduleId = inventoryModuleId(entry); + const module = MODULE_CATALOG[moduleId]; + if (!module) return null; + const condition = inventoryCondition(entry); + const scrapValue = Math.max( + 1, + Math.round(Math.max(100, module.price || 100) / 300 * (0.55 + condition)), + ); + + return { + moduleId, + name: module.name, + slot: module.slot, + tier: module.tier ?? null, + tierLabel: Number.isInteger(module.tier) ? romanTier(module.tier) : null, + effectSummary: module.effectSummary || '', + condition, + conditionPercent: Math.round(condition * 100), + scrapValue, + source: typeof entry === 'string' ? 'legacy' : entry?.source || 'salvage', + }; +}; + export const installModule = (progression, moduleId) => { const module = MODULE_CATALOG[moduleId]; if (!module) return { ...progression, installed: false, reason: 'unknown-module' }; + if (progression.loadout?.[module.slot] === moduleId) { + return { ...progression, installed: false, reason: 'already-installed' }; + } const inventory = [...(progression.inventory || [])]; - const inventoryIndex = inventory.indexOf(moduleId); + const inventoryIndex = inventory.findIndex((entry) => inventoryModuleId(entry) === moduleId); + const installedEntry = inventoryIndex >= 0 ? inventory[inventoryIndex] : null; if (inventoryIndex >= 0) inventory.splice(inventoryIndex, 1); const loadout = { ...(progression.loadout || STARTER_LOADOUT) }; const replaced = loadout[module.slot] || null; loadout[module.slot] = moduleId; - if (replaced && MODULE_CATALOG[replaced]?.price > 0) inventory.push(replaced); + + if (replaced && MODULE_CATALOG[replaced]?.price > 0) { + inventory.push({ moduleId: replaced, condition: 1, source: 'removed' }); + } return { ...progression, @@ -59,22 +128,35 @@ export const installModule = (progression, moduleId) => { installed: true, replaced, installedModule: moduleId, + installedCondition: installedEntry ? inventoryCondition(installedEntry) : 1, }; }; export const buyAndInstallModule = (progression, moduleId) => { const module = MODULE_CATALOG[moduleId]; if (!module || module.price <= 0) return { ...progression, purchased: false, reason: 'not-for-sale' }; + + if (Number.isInteger(module.tier)) { + const current = MODULE_CATALOG[progression.loadout?.[module.slot]]; + const currentTier = Number.isInteger(current?.tier) ? current.tier : 0; + if (module.tier !== currentTier + 1) { + return { ...progression, purchased: false, reason: 'tier-locked' }; + } + } + if ((progression.credits || 0) < module.price) { return { ...progression, purchased: false, reason: 'insufficient-credits' }; } - const purchased = { + const loadout = { ...(progression.loadout || STARTER_LOADOUT), [module.slot]: moduleId }; + return { ...progression, credits: progression.credits - module.price, - inventory: [...(progression.inventory || []), moduleId], + loadout, + purchased: true, + installed: true, + installedModule: moduleId, }; - return { ...installModule(purchased, moduleId), purchased: true }; }; export const marketForPort = (port) => { @@ -86,7 +168,10 @@ export const marketForPort = (port) => { return true; }); - return stock.sort((a, b) => a.price - b.price); + return stock.sort((a, b) => { + if (a.slot !== b.slot) return a.slot.localeCompare(b.slot); + return (a.tier ?? 99) - (b.tier ?? 99) || a.price - b.price; + }); }; export const salvageForPirate = ({ @@ -97,17 +182,23 @@ export const salvageForPirate = ({ } = {}) => { const random = createSeededRandom(`${seed}:${classKey}:${wave}:${sequence}`); const chance = classKey === 'captain' ? 0.86 : classKey === 'frigate' ? 0.62 : 0.42; - const modulePool = purchasableModules(); + const tierCap = classKey === 'captain' ? 3 : classKey === 'frigate' ? 2 : 1; + const modulePool = purchasableModules() + .filter((module) => !Number.isInteger(module.tier) || module.tier <= tierCap); const module = random() < chance ? modulePool[Math.floor(random() * modulePool.length)] : null; const scrap = 1 + Math.floor(random() * (classKey === 'captain' ? 8 : classKey === 'frigate' ? 5 : 3)); const credits = 20 + Math.floor(random() * (35 + wave * 8)); + const repairBase = classKey === 'captain' ? 10 : classKey === 'frigate' ? 7 : 4; + const repairRange = classKey === 'captain' ? 9 : classKey === 'frigate' ? 7 : 5; + const repair = repairBase + Math.floor(random() * repairRange) + Math.min(4, Math.floor(wave / 3)); return { moduleId: module?.id || null, scrap, credits, + repair, condition: module ? clamp(0.28 + random() * 0.67, 0.28, 0.95) : null, }; }; @@ -117,7 +208,11 @@ export const collectSalvage = (progression, salvage, stats = deriveShipStats(pro let moduleCollected = false; if (salvage?.moduleId && inventory.length < stats.salvageCapacity) { - inventory.push(salvage.moduleId); + inventory.push({ + moduleId: salvage.moduleId, + condition: salvage.condition ?? 1, + source: 'salvage', + }); moduleCollected = true; } @@ -130,11 +225,34 @@ export const collectSalvage = (progression, salvage, stats = deriveShipStats(pro }; }; +export const scrapInventoryModule = (progression, index = 0) => { + const inventory = [...(progression.inventory || [])]; + const entry = inventory[index]; + const details = describeInventoryEntry(entry); + if (!entry || !details) return { ...progression, scrapped: false, reason: 'empty-hold' }; + + inventory.splice(index, 1); + return { + ...progression, + inventory, + scrap: (progression.scrap || 0) + details.scrapValue, + scrapped: true, + lastScrap: details, + }; +}; + export const bestAffordableMarketUpgrade = (progression, port) => { const stats = deriveShipStats(progression.loadout); const candidates = marketForPort(port) + .filter((module) => { + if (!Number.isInteger(module.tier)) { + return progression.loadout?.[module.slot] !== module.id; + } + const current = MODULE_CATALOG[progression.loadout?.[module.slot]]; + const currentTier = Number.isInteger(current?.tier) ? current.tier : 0; + return module.tier === currentTier + 1; + }) .filter((module) => module.price <= (progression.credits || 0)) - .filter((module) => progression.loadout?.[module.slot] !== module.id) .map((module) => { const next = deriveShipStats({ ...progression.loadout, [module.slot]: module.id }); const score = (next.maxHull - stats.maxHull) * 0.7 diff --git a/src/shipyard-ui.js b/src/shipyard-ui.js index e57ccd0..ef3f187 100644 --- a/src/shipyard-ui.js +++ b/src/shipyard-ui.js @@ -6,10 +6,10 @@ import activeUrl from './assets/shipyard-ui/active.png?url'; import closeButtonUrl from './assets/shipyard-ui/close_button.png?url'; const UPGRADE_SLOTS = [ - { id: 'tuned-propellers', label: 'Engine Lv 1' }, - { id: 'reinforced-keel', label: 'Hull Plating Lv 1' }, - { id: 'expanded-hold', label: 'Cargo Hold Lv 1' }, - { id: 'reinforced-rudder', label: 'Rudder Lv 1' }, + { slot: 'engine', label: 'ENGINE' }, + { slot: 'hull', label: 'HULL' }, + { slot: 'cargo', label: 'CARGO' }, + { slot: 'rudder', label: 'RUDDER' }, ]; const dispatchAction = (action, extra = {}) => { @@ -58,9 +58,15 @@ overlay.innerHTML = `
Ship upgrades
- ${UPGRADE_SLOTS.map(({ id, label }) => ` - `).join('')}
@@ -71,7 +77,18 @@ overlay.innerHTML = `
@@ -228,20 +245,52 @@ style.textContent = ` .shipyard-upgrade-button:not(:disabled):active { background: rgba(255, 214, 120, .12); } .shipyard-upgrade-button:disabled { cursor: default; } - .shipyard-upgrade-state { + .shipyard-upgrade-copy { position: absolute; - left: 50%; - bottom: 3px; - transform: translateX(-50%); - padding: 2px 5px; + left: 1px; + right: 1px; + bottom: 1px; + min-height: 49%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + padding: 4px 3px; + border-radius: 0 0 7px 7px; + background: linear-gradient(180deg, rgba(3, 18, 24, .9), rgba(2, 12, 17, .98)); + color: #ead49c; + text-align: center; + pointer-events: none; + } + + .shipyard-upgrade-copy strong { + color: #fff0bd; + font: 700 clamp(8px, 1.05vw, 12px)/1 Georgia, serif; + letter-spacing: .06em; + } + + .shipyard-upgrade-copy [data-upgrade="current"] { + color: #a9d9b0; + font: 700 clamp(7px, .9vw, 10px)/1 system-ui, sans-serif; + } + + .shipyard-upgrade-copy [data-upgrade="next"], + .shipyard-upgrade-copy [data-upgrade="effect"] { + font: 600 clamp(6px, .78vw, 9px)/1.05 system-ui, sans-serif; + } + + .shipyard-upgrade-state { + margin-top: 2px; + padding: 3px 6px; border-radius: 999px; - background: rgba(4, 16, 21, .88); + background: rgba(118, 70, 22, .55); color: #ffe4a1; - font: 700 clamp(8px, 1vw, 11px)/1 system-ui, sans-serif; + font: 700 clamp(7px, .85vw, 10px)/1 system-ui, sans-serif; white-space: nowrap; } - .shipyard-upgrade-button.installed .shipyard-upgrade-state { color: #aef0b8; } + .shipyard-upgrade-button.maxed .shipyard-upgrade-state { color: #aef0b8; } .shipyard-upgrade-button.locked .shipyard-upgrade-state { color: #d8a8a0; } .shipyard-active-plate { @@ -260,32 +309,77 @@ style.textContent = ` .shipyard-footer { display: flex; - align-items: center; + flex-direction: column; + align-items: stretch; justify-content: center; - gap: 12px; - min-height: 72px; + gap: 8px; + min-height: 118px; margin-top: 56px; } + .shipyard-salvage-panel { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + width: min(720px, 96%); + margin: 0 auto; + padding: 10px 12px; + border: 1px solid rgba(184, 129, 49, .62); + border-radius: 10px; + background: rgba(3, 20, 27, .9); + } + + .shipyard-salvage-copy { + display: grid; + gap: 3px; + min-width: 0; + font: 600 11px/1.15 system-ui, sans-serif; + } + + .shipyard-salvage-copy strong { + color: #fff0bd; + font-family: Georgia, serif; + letter-spacing: .08em; + } + + .shipyard-salvage-copy [data-shipyard="salvage-name"] { color: #bde9c3; } + .shipyard-salvage-copy [data-shipyard="salvage-meta"] { color: #e6d29b; } + .shipyard-salvage-copy [data-shipyard="salvage-effect"] { color: #a9bdc6; } + + .shipyard-salvage-actions { + display: flex; + gap: 7px; + flex-wrap: wrap; + justify-content: flex-end; + } + .shipyard-salvage { - min-width: 150px; - min-height: 44px; - padding: 7px 14px; + min-width: 112px; + min-height: 42px; + padding: 7px 12px; border: 1px solid #b88131; border-radius: 9px; background: linear-gradient(#174e43, #0d302a); color: #e6f5d0; - font: 700 12px/1.15 Georgia, serif; + font: 700 11px/1.15 Georgia, serif; touch-action: manipulation; } + .shipyard-scrap { + background: linear-gradient(#553728, #342219); + color: #f0d2a4; + } + .shipyard-salvage:disabled { opacity: .35; } .shipyard-message { min-height: 1.2em; - max-width: 50%; + width: min(720px, 96%); + margin: 0 auto; color: #fff0bd; font-size: 12px; + text-align: center; } body.shipyard-open #mobile-controls { visibility: hidden; } @@ -340,8 +434,21 @@ style.textContent = ` gap: 7px; } - .shipyard-salvage { min-width: 120px; min-height: 40px; font-size: 10px; } - .shipyard-message { max-width: 56%; font-size: 10px; } + .shipyard-salvage-panel { + grid-template-columns: 1fr; + width: 98%; + padding: 8px; + } + + .shipyard-salvage-copy { font-size: 9px; } + .shipyard-salvage-actions { justify-content: stretch; } + .shipyard-salvage { + flex: 1 1 44%; + min-width: 0; + min-height: 40px; + font-size: 10px; + } + .shipyard-message { width: 98%; font-size: 10px; } } @media (orientation: portrait) and (max-width: 760px) { @@ -368,21 +475,18 @@ const fields = Object.fromEntries( [...overlay.querySelectorAll('[data-shipyard]')].map((el) => [el.dataset.shipyard, el]), ); const closeButton = overlay.querySelector('.shipyard-close'); -const fitButton = overlay.querySelector('.shipyard-salvage'); +const fitButton = overlay.querySelector('[data-shipyard="fit"]'); +const scrapButton = overlay.querySelector('[data-shipyard="scrap"]'); const upgradeButtons = [...overlay.querySelectorAll('.shipyard-upgrade-button')]; let latestState = null; -const findStock = (id) => - latestState?.progression?.marketStock?.find?.((module) => module.id === id) || null; - const render = (state) => { if (!state?.progression) return; latestState = state; const progression = state.progression; const stats = progression.shipStats || {}; - const loadout = progression.loadout || {}; fields.port.textContent = progression.nearestPort || '--'; fields.credits.textContent = progression.credits ?? 0; @@ -395,27 +499,66 @@ const render = (state) => { fields.cargo.textContent = `Cargo ${stats.cargoCapacity ?? 4}`; for (const button of upgradeButtons) { - const id = button.dataset.moduleId; - const stock = findStock(id); - const installed = Object.values(loadout).includes(id); - const stateLabel = button.querySelector('.shipyard-upgrade-state'); + const slot = button.dataset.upgradeSlot; + const path = progression.upgradePaths?.[slot] || null; + const current = path?.current || null; + const next = path?.next || null; + const currentText = button.querySelector('[data-upgrade="current"]'); + const nextText = button.querySelector('[data-upgrade="next"]'); + const effectText = button.querySelector('[data-upgrade="effect"]'); + const stateLabel = button.querySelector('[data-upgrade="state"]'); - button.classList.toggle('installed', installed); - button.classList.toggle('locked', !stock && !installed); - button.disabled = installed || !stock || progression.credits < (stock?.price || Infinity); + button.dataset.moduleId = next?.id || ''; + button.classList.toggle('maxed', !next); + button.classList.toggle('locked', Boolean(next && !next.stocked)); - stateLabel.textContent = installed - ? 'INSTALLED' - : !stock - ? 'NOT STOCKED' - : progression.credits < stock.price - ? `NEED ¢${stock.price}` - : 'TAP TO BUY'; + if (currentText) { + currentText.textContent = `CURRENT: ${current?.tierLabel || 'STOCK'}`; + } + if (nextText) { + nextText.textContent = next + ? `NEXT: ${next.tierLabel} • ¢${next.price}` + : `MAX: ${current?.tierLabel || 'MAX'}`; + } + if (effectText) { + effectText.textContent = next?.effectSummary || 'Upgrade path complete'; + } + + button.disabled = !next || !next.stocked || progression.credits < (next?.price || Infinity); + stateLabel.textContent = !next + ? 'MAX TIER' + : !next.stocked + ? 'NOT STOCKED HERE' + : progression.credits < next.price + ? `NEED ¢${next.price}` + : 'TAP TO UPGRADE'; + button.setAttribute( + 'aria-label', + next ? `Upgrade ${slot} to ${next.tierLabel} for ${next.price} coins` : `${slot} at maximum tier`, + ); + } + + const salvage = progression.salvageItem; + if (salvage) { + fields['salvage-name'].textContent = salvage.name; + fields['salvage-meta'].textContent = + `${String(salvage.slot || 'module').toUpperCase()} • ${salvage.conditionPercent}% CONDITION`; + fields['salvage-effect'].textContent = + `${salvage.effectSummary || 'Recovered equipment'} • Fit it or convert it to scrap.`; + fitButton.disabled = false; + fitButton.textContent = 'FIT MODULE'; + scrapButton.disabled = false; + scrapButton.textContent = `SCRAP +${salvage.scrapValue}`; + } else { + fields['salvage-name'].textContent = 'EMPTY'; + fields['salvage-meta'].textContent = 'Pirate wrecks repair hull immediately on pickup.'; + fields['salvage-effect'].textContent = 'Coin and scrap are banked instantly; recovered modules appear here.'; + fitButton.disabled = true; + fitButton.textContent = 'FIT MODULE'; + scrapButton.disabled = true; + scrapButton.textContent = 'SCRAP MODULE'; } - const salvage = progression.salvageModule; - fitButton.disabled = !salvage; - fitButton.textContent = salvage ? `FIT ${salvage.name}` : 'FIT SALVAGE'; fields.message.textContent = String(state.status || '').replace(/\n/g, ' '); }; @@ -454,6 +597,11 @@ fitButton.addEventListener('click', () => { dispatchAction('fit-salvage'); }); +scrapButton.addEventListener('click', () => { + if (scrapButton.disabled) return; + dispatchAction('scrap-salvage'); +}); + window.addEventListener('airship:shipyard-open', (event) => show(event.detail)); window.addEventListener('airship:shipyard-state', (event) => render(event.detail)); window.addEventListener('airship:shipyard-close', hide); diff --git a/tests/progression.test.mjs b/tests/progression.test.mjs index 4bdfc24..e6769e2 100644 --- a/tests/progression.test.mjs +++ b/tests/progression.test.mjs @@ -3,12 +3,15 @@ import assert from 'node:assert/strict'; import { bestAffordableMarketUpgrade, buyAndInstallModule, - marketForPort, collectSalvage, createStarterProgression, deriveShipStats, + describeInventoryEntry, installModule, + marketForPort, salvageForPirate, + scrapInventoryModule, + upgradePathForSlot, } from '../src/progression/ship-loadout.js'; test('starter loadout preserves current ship combat and handling values', () => { @@ -19,20 +22,48 @@ test('starter loadout preserves current ship combat and handling values', () => assert.equal(stats.turnRate, 76); assert.equal(stats.reloadMs, 850); assert.equal(stats.cannonDamage, 16); + assert.equal(stats.cargoCapacity, 4); +}); + +test('engine upgrades advance one tier at a time', () => { + const starter = { ...createStarterProgression(), credits: 10000 }; + const mk1 = buyAndInstallModule(starter, 'tuned-propellers'); + assert.equal(mk1.purchased, true); + assert.equal(mk1.loadout.engine, 'tuned-propellers'); + + const skip = buyAndInstallModule(starter, 'storm-turbines'); + assert.equal(skip.purchased, false); + assert.equal(skip.reason, 'tier-locked'); + + const mk2 = buyAndInstallModule(mk1, 'corsair-overdrive'); + assert.equal(mk2.purchased, true); + assert.equal(mk2.loadout.engine, 'corsair-overdrive'); +}); + +test('upgrade path exposes current and next tier with price and effect', () => { + const starter = createStarterProgression(); + const stock = marketForPort({ type: 'trade' }); + const path = upgradePathForSlot(starter.loadout, 'engine', stock); + assert.equal(path.current.tierLabel, 'Stock'); + assert.equal(path.next.tierLabel, 'Mk I'); + assert.equal(path.next.price, 800); + assert.match(path.next.effectSummary, /speed/); + assert.equal(path.next.stocked, true); }); test('installed modules derive meaningful ship stat changes', () => { const progression = installModule(createStarterProgression(), 'corsair-overdrive'); const stats = deriveShipStats(progression.loadout); - assert.equal(stats.maxSpeed, 214); - assert.equal(stats.acceleration, 110); - assert.equal(stats.turnRate, 71); + assert.equal(stats.maxSpeed, 218); + assert.equal(stats.acceleration, 112); + assert.equal(stats.turnRate, 74); }); test('port purchase deducts credits and installs the module', () => { const starter = createStarterProgression(); const result = buyAndInstallModule(starter, 'tuned-propellers'); assert.equal(result.purchased, false); + const funded = { ...starter, credits: 1000 }; const fundedResult = buyAndInstallModule(funded, 'tuned-propellers'); assert.equal(fundedResult.purchased, true); @@ -40,34 +71,58 @@ test('port purchase deducts credits and installs the module', () => { assert.equal(fundedResult.loadout.engine, 'tuned-propellers'); }); -test('pirate salvage is deterministic and collectible within salvage capacity', () => { +test('pirate salvage includes deterministic field repair and collectible module condition', () => { const salvageA = salvageForPirate({ seed: 'WORLD', classKey: 'captain', wave: 4, sequence: 2 }); const salvageB = salvageForPirate({ seed: 'WORLD', classKey: 'captain', wave: 4, sequence: 2 }); assert.deepEqual(salvageA, salvageB); + assert.ok(salvageA.repair > 0); const starter = createStarterProgression(); const collected = collectSalvage(starter, salvageA); assert.ok(collected.credits >= starter.credits); assert.ok(collected.scrap >= starter.scrap); + + if (salvageA.moduleId) { + const details = describeInventoryEntry(collected.inventory[0]); + assert.ok(details); + assert.ok(details.conditionPercent >= 28); + assert.ok(details.scrapValue >= 1); + } }); -test('market can select an affordable upgrade for a port', () => { +test('recovered modules can be converted into scrap', () => { + const starter = createStarterProgression(); + const collected = collectSalvage(starter, { + moduleId: 'reinforced-rudder', + condition: 0.5, + credits: 0, + scrap: 0, + repair: 6, + }); + const result = scrapInventoryModule(collected, 0); + assert.equal(result.scrapped, true); + assert.equal(result.inventory.length, 0); + assert.ok(result.scrap > collected.scrap); +}); + +test('market selects only a valid next-tier affordable upgrade', () => { const starter = createStarterProgression(); const upgrade = bestAffordableMarketUpgrade(starter, { type: 'military' }); assert.ok(upgrade); assert.ok(upgrade.price <= starter.credits); + if (Number.isInteger(upgrade.tier)) assert.equal(upgrade.tier, 1); }); - -test('rudder is stocked at every port type so the shipyard card is actionable', () => { +test('rudder is stocked at every port type so its upgrade path remains actionable', () => { const portTypes = ['industrial', 'military', 'fuel', 'trade']; for (const type of portTypes) { const stock = marketForPort({ type }); assert.ok(stock.some(({ id }) => id === 'reinforced-rudder')); + assert.ok(stock.some(({ id }) => id === 'balanced-rudder')); } }); -test('rudder tier matches shipyard art and improves handling', () => { +test('rudder tier improves handling', () => { const starter = { ...createStarterProgression(), credits: 650 }; const result = buyAndInstallModule(starter, 'reinforced-rudder'); const stats = deriveShipStats(result.loadout); diff --git a/tests/wiring.test.mjs b/tests/wiring.test.mjs index 379c1f4..762b65c 100644 --- a/tests/wiring.test.mjs +++ b/tests/wiring.test.mjs @@ -263,7 +263,7 @@ test('environment physics adds wind, soft island collision, turbulence, and proj assert.match(source, /windSpeed: this\.environmentWind\?\.strength/); }); -test('mobile controls expose native buy and salvage-fit actions', async () => { +test('mobile controls route salvage management through the docked shipyard', async () => { const [game, hud, index] = await Promise.all([ read('src/game-v4.js'), read('src/mobile-hud.js'), @@ -271,19 +271,13 @@ test('mobile controls expose native buy and salvage-fit actions', async () => { ]); assert.match(index, /data-action="open-shipyard"/); - assert.match(index, /data-action="fit-salvage"/); + assert.doesNotMatch(index, /data-action="fit-salvage"/); assert.match(index, /CustomEvent\('airship:action'/); - assert.match(game, /addEventListener\('airship:action', this\.handleMobileAction\)/); assert.match(game, /this\.pendingMobileActions\.delete\('open-shipyard'\)/); assert.match(game, /this\.pendingMobileActions\.delete\('fit-salvage'\)/); - assert.match(game, /marketUpgrade: marketUpgrade \? \{/); - assert.match(game, /salvageModule: salvageModule \? \{/); - assert.match(hud, /root\.querySelector\('\.mobile-control\.buy'\)/); - assert.match(hud, /root\.querySelector\('\.mobile-control\.fit'\)/); - assert.match(hud, /buyButton\.hidden = !progression\.docked/); - assert.match(hud, /fitButton\.hidden = !module/); - assert.match(hud, /buyButton\.disabled = !progression\.docked/); - assert.match(hud, /fitButton\.disabled = !module/); + assert.match(game, /this\.pendingMobileActions\.delete\('scrap-salvage'\)/); + assert.match(hud, /buyButton\.textContent = salvageCount > 0/); + assert.doesNotMatch(hud, /const fitButton = root\.querySelector/); }); test('shipyard overlay uses supplied component art and explicit module purchases', async () => { @@ -301,6 +295,11 @@ test('shipyard overlay uses supplied component art and explicit module purchases assert.match(shipyard, /shipyard-ui\/upgrades\.png\?url/); assert.match(shipyard, /airship:shipyard-open/); assert.match(shipyard, /dispatchAction\('purchase-module', \{ moduleId \}\)/); + assert.match(shipyard, /data-upgrade-slot/); + assert.match(shipyard, /CURRENT:/); + assert.match(shipyard, /NEXT:/); + assert.match(shipyard, /dispatchAction\('scrap-salvage'\)/); + assert.match(shipyard, /Pirate wrecks repair hull immediately on pickup/); assert.doesNotMatch(shipyard, /data-shipyard="active"/); assert.match(shipyard, /min-height: calc\(100dvh - 8px\)/); assert.match(game, /marketForPort\(port\)/); @@ -345,6 +344,10 @@ test('procedural world and ship progression are wired into live gameplay', async assert.match(source, /for \(const territory of this\.world\.pirateTerritories\)/); assert.match(source, /this\.salvageWrecks = this\.physics\.add\.group\(\)/); assert.match(source, /collectSalvageWreck\(/); + assert.match(source, /this\.playerHull = Math\.min\([\s\S]*?this\.playerHull \+ repairAvailable/); + assert.match(source, /MODULE STORED:[\s\S]*?FIT OR SCRAP AT SHIPYARD/); + assert.match(source, /upgradePathForSlot\(/); + assert.match(source, /describeInventoryEntry\(/); assert.match(source, /bestAffordableMarketUpgrade\(/); assert.match(source, /buyAndInstallModule\(/); assert.match(source, /installModule\(this\.progression, moduleId\)/);