Clarify upgrade tiers and salvage recovery

This commit is contained in:
Admin 2026-09-20 23:48:27 +00:00
parent 0316acf702
commit bb302b0ac7
8 changed files with 611 additions and 131 deletions

View File

@ -133,7 +133,6 @@
<button class="mobile-control port" data-code="KeyQ" data-key="q" data-keycode="81" aria-label="Fire port broadside">PORT</button> <button class="mobile-control port" data-code="KeyQ" data-key="q" data-keycode="81" aria-label="Fire port broadside">PORT</button>
<button class="mobile-control starboard" data-code="KeyE" data-key="e" data-keycode="69" aria-label="Fire starboard broadside">STARBOARD</button> <button class="mobile-control starboard" data-code="KeyE" data-key="e" data-keycode="69" aria-label="Fire starboard broadside">STARBOARD</button>
<button class="mobile-control ship-action buy" data-action="open-shipyard" aria-label="Open shipyard">SHIPYARD</button> <button class="mobile-control ship-action buy" data-action="open-shipyard" aria-label="Open shipyard">SHIPYARD</button>
<button class="mobile-control ship-action fit" data-action="fit-salvage" aria-label="Fit salvaged module">FIT</button>
<button class="mobile-control restart" data-code="KeyR" data-key="r" data-keycode="82" aria-label="Restart">↻</button> <button class="mobile-control restart" data-code="KeyR" data-key="r" data-keycode="82" aria-label="Restart">↻</button>
</div> </div>

View File

@ -35,9 +35,12 @@ import {
collectSalvage, collectSalvage,
createStarterProgression, createStarterProgression,
deriveShipStats, deriveShipStats,
describeInventoryEntry,
installModule, installModule,
marketForPort, marketForPort,
salvageForPirate, salvageForPirate,
scrapInventoryModule,
upgradePathForSlot,
} from './progression/ship-loadout.js'; } from './progression/ship-loadout.js';
import { MODULE_CATALOG } from './progression/modules.js'; import { MODULE_CATALOG } from './progression/modules.js';
import { import {
@ -491,11 +494,23 @@ class GameScene extends Phaser.Scene {
const salvage = wreck.getData?.('salvage'); const salvage = wreck.getData?.('salvage');
if (!salvage) return; 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 = collectSalvage(
this.progression, this.progression,
salvage, salvage,
this.playerStats, this.playerStats,
); );
this.progression.lastSalvage = {
...this.progression.lastSalvage,
repairedHull,
};
wreck.destroy(); wreck.destroy();
const moduleName = salvage.moduleId const moduleName = salvage.moduleId
@ -504,14 +519,14 @@ class GameScene extends Phaser.Scene {
const moduleCollected = this.progression.lastSalvage?.moduleCollected; const moduleCollected = this.progression.lastSalvage?.moduleCollected;
const moduleText = moduleName const moduleText = moduleName
? moduleCollected ? moduleCollected
? `\nSALVAGED: ${moduleName}` ? `\nMODULE STORED: ${moduleName}\nFIT OR SCRAP AT SHIPYARD`
: `\nHOLD FULL: ${moduleName}` : `\nSALVAGE HOLD FULL: ${moduleName}`
: ''; : '';
this.status.setText( 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(''); if (this.gameState === 'playing') this.status.setText('');
}); });
} }
@ -541,6 +556,7 @@ class GameScene extends Phaser.Scene {
|| Phaser.Input.Keyboard.JustDown(this.keys.closeShipyard); || Phaser.Input.Keyboard.JustDown(this.keys.closeShipyard);
const purchaseRequested = this.pendingMobileActions.delete('purchase-module'); const purchaseRequested = this.pendingMobileActions.delete('purchase-module');
const mobileFit = this.pendingMobileActions.delete('fit-salvage'); const mobileFit = this.pendingMobileActions.delete('fit-salvage');
const scrapSalvage = this.pendingMobileActions.delete('scrap-salvage');
if (closeRequested && this.shipyardOpen) { if (closeRequested && this.shipyardOpen) {
this.shipyardOpen = false; this.shipyardOpen = false;
@ -592,25 +608,46 @@ class GameScene extends Phaser.Scene {
} }
if (mobileFit || Phaser.Input.Keyboard.JustDown(this.keys.fitSalvage)) { if (mobileFit || Phaser.Input.Keyboard.JustDown(this.keys.fitSalvage)) {
const moduleId = this.progression.inventory?.[0]; if (!this.shipyardOpen || !docked) {
this.status.setText('FIT RECOVERED MODULES AT THE SHIPYARD');
} else {
const salvageEntry = this.progression.inventory?.[0];
const moduleId = typeof salvageEntry === 'string'
? salvageEntry
: salvageEntry?.moduleId;
if (!moduleId) { if (!moduleId) {
this.status.setText('NO SALVAGED MODULES IN HOLD'); this.status.setText('SALVAGE HOLD IS EMPTY');
} else { } else {
const previousMaxHull = this.playerStats.maxHull; const previousMaxHull = this.playerStats.maxHull;
this.progression = installModule(this.progression, moduleId); this.progression = installModule(this.progression, moduleId);
this.applyProgressionStats(previousMaxHull); this.applyProgressionStats(previousMaxHull);
const module = MODULE_CATALOG[moduleId]; const module = MODULE_CATALOG[moduleId];
this.status.setText(`FITTED SALVAGE\n${module?.name || moduleId}`); this.status.setText(`FITTED ${module?.name || moduleId}`);
} }
if (this.shipyardOpen) {
window.dispatchEvent(new CustomEvent('airship:shipyard-state', { window.dispatchEvent(new CustomEvent('airship:shipyard-state', {
detail: this.buildHudState(), 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() { createEnvironmentPhysics() {
@ -1014,6 +1051,7 @@ class GameScene extends Phaser.Scene {
moduleId: null, moduleId: null,
scrap: Math.max(1, Math.round(cargoValue / 70)), scrap: Math.max(1, Math.round(cargoValue / 70)),
credits: Math.max(15, Math.round(cargoValue * 0.45)), credits: Math.max(15, Math.round(cargoValue * 0.45)),
repair: 0,
condition: null, condition: null,
}; };
this.spawnCargoWreck(x, y, cargoSalvage); this.spawnCargoWreck(x, y, cargoSalvage);
@ -1661,8 +1699,14 @@ class GameScene extends Phaser.Scene {
const marketUpgrade = docked const marketUpgrade = docked
? bestAffordableMarketUpgrade(this.progression, portState.port) ? bestAffordableMarketUpgrade(this.progression, portState.port)
: null; : null;
const salvageModuleId = this.progression?.inventory?.[0] || null; const salvageEntry = this.progression?.inventory?.[0] || null;
const salvageModule = salvageModuleId ? MODULE_CATALOG[salvageModuleId] : null; const salvageItem = describeInventoryEntry(salvageEntry);
const upgradePaths = Object.fromEntries(
['engine', 'hull', 'cargo', 'rudder'].map((slot) => [
slot,
upgradePathForSlot(this.progression.loadout, slot, marketStock),
]),
);
return { return {
gameState: this.gameState, gameState: this.gameState,
@ -1711,16 +1755,20 @@ class GameScene extends Phaser.Scene {
id: module.id, id: module.id,
name: module.name, name: module.name,
slot: module.slot, slot: module.slot,
tier: module.tier ?? null,
price: module.price, price: module.price,
effectSummary: module.effectSummary || '',
})), })),
upgradePaths,
marketUpgrade: marketUpgrade ? { marketUpgrade: marketUpgrade ? {
id: marketUpgrade.id, id: marketUpgrade.id,
name: marketUpgrade.name, name: marketUpgrade.name,
price: marketUpgrade.price, price: marketUpgrade.price,
} : null, } : null,
salvageModule: salvageModule ? { salvageItem,
id: salvageModule.id, salvageModule: salvageItem ? {
name: salvageModule.name, id: salvageItem.moduleId,
name: salvageItem.name,
} : null, } : null,
}, },
world: { world: {

View File

@ -425,7 +425,7 @@ if (!isDesktopPointer.matches) {
} }
#mobile-controls.controller-active .mobile-control.buy { #mobile-controls.controller-active .mobile-control.buy {
margin-left: -16%; margin-left: 0;
} }
#mobile-controls.controller-active .mobile-control.fit { #mobile-controls.controller-active .mobile-control.fit {
@ -551,7 +551,7 @@ if (!isDesktopPointer.matches) {
} }
#mobile-controls.controller-active .mobile-control.buy { #mobile-controls.controller-active .mobile-control.buy {
margin-left: -86px; margin-left: 0;
} }
#mobile-controls.controller-active .mobile-control.fit { #mobile-controls.controller-active .mobile-control.fit {
@ -701,7 +701,6 @@ if (!isDesktopPointer.matches) {
[...hud.querySelectorAll('[data-steam]')].map((el) => [el.dataset.steam, el]), [...hud.querySelectorAll('[data-steam]')].map((el) => [el.dataset.steam, el]),
); );
const buyButton = root.querySelector('.mobile-control.buy'); const buyButton = root.querySelector('.mobile-control.buy');
const fitButton = root.querySelector('.mobile-control.fit');
const radarContacts = radarPiece.querySelector('#steam-radar-contacts'); const radarContacts = radarPiece.querySelector('#steam-radar-contacts');
@ -805,15 +804,10 @@ if (!isDesktopPointer.matches) {
if (buyButton) { if (buyButton) {
buyButton.hidden = !progression.docked; buyButton.hidden = !progression.docked;
buyButton.disabled = !progression.docked; buyButton.disabled = !progression.docked;
buyButton.textContent = 'SHIPYARD'; const salvageCount = progression.inventoryCount || 0;
} buyButton.textContent = salvageCount > 0
if (fitButton) { ? `SHIPYARD\nSALVAGE ${salvageCount}`
const module = progression.salvageModule; : 'SHIPYARD';
fitButton.hidden = !module;
fitButton.disabled = !module;
fitButton.textContent = module
? `FIT\n${module.name}`
: 'FIT';
} }
const statusText = String(state.status || '').trim(); const statusText = String(state.status || '').trim();

View File

@ -16,34 +16,52 @@ export const SHIP_BASE_STATS = {
export const MODULE_CATALOG = { export const MODULE_CATALOG = {
'stock-engine': { 'stock-engine': {
id: 'stock-engine', id: 'stock-engine',
name: 'Stock Twin Propellers', name: 'Stock Engine',
slot: 'engine', slot: 'engine',
tier: 0,
rarity: 'common', rarity: 'common',
price: 0, price: 0,
effectSummary: 'Standard speed and acceleration',
modifiers: {}, modifiers: {},
}, },
'tuned-propellers': { 'tuned-propellers': {
id: 'tuned-propellers', id: 'tuned-propellers',
name: 'Engine Lv 1', name: 'Engine Mk I',
slot: 'engine', slot: 'engine',
tier: 1,
rarity: 'improved', rarity: 'improved',
price: 800, price: 800,
effectSummary: '+20 speed • +10 acceleration',
modifiers: { maxSpeed: 20, acceleration: 10 }, modifiers: { maxSpeed: 20, acceleration: 10 },
}, },
'corsair-overdrive': { 'corsair-overdrive': {
id: 'corsair-overdrive', id: 'corsair-overdrive',
name: 'Corsair Overdrive', name: 'Engine Mk II',
slot: 'engine', slot: 'engine',
tier: 2,
rarity: 'rare', rarity: 'rare',
price: 690, price: 1400,
modifiers: { maxSpeed: 34, acceleration: 18, turnRate: -5 }, 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': { 'standard-broadside': {
id: 'standard-broadside', id: 'standard-broadside',
name: 'Standard Broadside', name: 'Standard Broadside',
slot: 'weapon', slot: 'weapon',
rarity: 'common', rarity: 'common',
price: 0, price: 0,
effectSummary: 'Standard cannon battery',
modifiers: {}, modifiers: {},
}, },
'long-nine-cannons': { 'long-nine-cannons': {
@ -52,6 +70,7 @@ export const MODULE_CATALOG = {
slot: 'weapon', slot: 'weapon',
rarity: 'improved', rarity: 'improved',
price: 360, price: 360,
effectSummary: '+4 cannon damage • slower reload',
modifiers: { cannonDamage: 4, reloadMs: 70 }, modifiers: { cannonDamage: 4, reloadMs: 70 },
}, },
'quicklock-breeches': { 'quicklock-breeches': {
@ -60,64 +79,160 @@ export const MODULE_CATALOG = {
slot: 'weapon', slot: 'weapon',
rarity: 'rare', rarity: 'rare',
price: 620, price: 620,
effectSummary: '+2 cannon damage • faster reload',
modifiers: { cannonDamage: 2, reloadMs: -180 }, modifiers: { cannonDamage: 2, reloadMs: -180 },
}, },
'timber-hull': { 'timber-hull': {
id: 'timber-hull', id: 'timber-hull',
name: 'Timber Hull', name: 'Stock Hull',
slot: 'hull', slot: 'hull',
tier: 0,
rarity: 'common', rarity: 'common',
price: 0, price: 0,
effectSummary: 'Standard hull protection',
modifiers: {}, modifiers: {},
}, },
'reinforced-keel': { 'reinforced-keel': {
id: 'reinforced-keel', id: 'reinforced-keel',
name: 'Hull Plating Lv 1', name: 'Hull Plating Mk I',
slot: 'hull', slot: 'hull',
tier: 1,
rarity: 'improved', rarity: 'improved',
price: 600, price: 600,
effectSummary: '+28 hull • -7 speed',
modifiers: { maxHull: 28, maxSpeed: -7 }, 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': { 'stormglass-nav': {
id: 'stormglass-nav', id: 'stormglass-nav',
name: 'Stormglass Navigator', name: 'Stormglass Navigator',
slot: 'navigation', slot: 'navigation',
rarity: 'rare', rarity: 'rare',
price: 560, price: 560,
effectSummary: 'Reduced wind drift • +240 radar range',
modifiers: { windExposure: -0.22, radarRange: 240 }, modifiers: { windExposure: -0.22, radarRange: 240 },
}, },
'brass-compass': {
id: 'brass-compass',
name: 'Brass Compass',
slot: 'navigation',
rarity: 'common',
price: 0,
modifiers: {},
},
'salvage-rig': { 'salvage-rig': {
id: 'salvage-rig', id: 'salvage-rig',
name: 'Deck Salvage Rig', name: 'Deck Salvage Rig',
slot: 'utility', slot: 'utility',
rarity: 'improved', rarity: 'improved',
price: 330, price: 330,
effectSummary: '+2 salvage hold capacity',
modifiers: { salvageCapacity: 2 }, 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': { 'expanded-hold': {
id: 'expanded-hold', id: 'expanded-hold',
name: 'Cargo Hold Lv 1', name: 'Cargo Hold Mk I',
slot: 'cargo', slot: 'cargo',
tier: 1,
rarity: 'improved', rarity: 'improved',
price: 600, price: 600,
effectSummary: '+4 cargo • -4 speed',
modifiers: { cargoCapacity: 4, maxSpeed: -4 }, 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': { 'reinforced-rudder': {
id: 'reinforced-rudder', id: 'reinforced-rudder',
name: 'Rudder Lv 1', name: 'Rudder Mk I',
slot: 'rudder', slot: 'rudder',
tier: 1,
rarity: 'improved', rarity: 'improved',
price: 400, price: 400,
effectSummary: '+12 handling',
modifiers: { turnRate: 12 }, 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 = { export const STARTER_LOADOUT = {
@ -126,8 +241,8 @@ export const STARTER_LOADOUT = {
hull: 'timber-hull', hull: 'timber-hull',
navigation: 'brass-compass', navigation: 'brass-compass',
utility: null, utility: null,
cargo: null, cargo: 'standard-hold',
rudder: null, rudder: 'stock-rudder',
}; };
export const purchasableModules = () => Object.values(MODULE_CATALOG) export const purchasableModules = () => Object.values(MODULE_CATALOG)

View File

@ -7,6 +7,11 @@ import {
import { createSeededRandom } from '../world/seeded-random.js'; import { createSeededRandom } from '../world/seeded-random.js';
const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); 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 = () => ({ export const createStarterProgression = () => ({
credits: 650, credits: 650,
@ -39,18 +44,82 @@ export const deriveShipStats = (loadout = STARTER_LOADOUT) => {
return stats; 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) => { export const installModule = (progression, moduleId) => {
const module = MODULE_CATALOG[moduleId]; const module = MODULE_CATALOG[moduleId];
if (!module) return { ...progression, installed: false, reason: 'unknown-module' }; 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 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); if (inventoryIndex >= 0) inventory.splice(inventoryIndex, 1);
const loadout = { ...(progression.loadout || STARTER_LOADOUT) }; const loadout = { ...(progression.loadout || STARTER_LOADOUT) };
const replaced = loadout[module.slot] || null; const replaced = loadout[module.slot] || null;
loadout[module.slot] = moduleId; 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 { return {
...progression, ...progression,
@ -59,22 +128,35 @@ export const installModule = (progression, moduleId) => {
installed: true, installed: true,
replaced, replaced,
installedModule: moduleId, installedModule: moduleId,
installedCondition: installedEntry ? inventoryCondition(installedEntry) : 1,
}; };
}; };
export const buyAndInstallModule = (progression, moduleId) => { export const buyAndInstallModule = (progression, moduleId) => {
const module = MODULE_CATALOG[moduleId]; const module = MODULE_CATALOG[moduleId];
if (!module || module.price <= 0) return { ...progression, purchased: false, reason: 'not-for-sale' }; 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) { if ((progression.credits || 0) < module.price) {
return { ...progression, purchased: false, reason: 'insufficient-credits' }; return { ...progression, purchased: false, reason: 'insufficient-credits' };
} }
const purchased = { const loadout = { ...(progression.loadout || STARTER_LOADOUT), [module.slot]: moduleId };
return {
...progression, ...progression,
credits: progression.credits - module.price, 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) => { export const marketForPort = (port) => {
@ -86,7 +168,10 @@ export const marketForPort = (port) => {
return true; 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 = ({ export const salvageForPirate = ({
@ -97,17 +182,23 @@ export const salvageForPirate = ({
} = {}) => { } = {}) => {
const random = createSeededRandom(`${seed}:${classKey}:${wave}:${sequence}`); const random = createSeededRandom(`${seed}:${classKey}:${wave}:${sequence}`);
const chance = classKey === 'captain' ? 0.86 : classKey === 'frigate' ? 0.62 : 0.42; 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 const module = random() < chance
? modulePool[Math.floor(random() * modulePool.length)] ? modulePool[Math.floor(random() * modulePool.length)]
: null; : null;
const scrap = 1 + Math.floor(random() * (classKey === 'captain' ? 8 : classKey === 'frigate' ? 5 : 3)); const scrap = 1 + Math.floor(random() * (classKey === 'captain' ? 8 : classKey === 'frigate' ? 5 : 3));
const credits = 20 + Math.floor(random() * (35 + wave * 8)); 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 { return {
moduleId: module?.id || null, moduleId: module?.id || null,
scrap, scrap,
credits, credits,
repair,
condition: module ? clamp(0.28 + random() * 0.67, 0.28, 0.95) : null, 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; let moduleCollected = false;
if (salvage?.moduleId && inventory.length < stats.salvageCapacity) { if (salvage?.moduleId && inventory.length < stats.salvageCapacity) {
inventory.push(salvage.moduleId); inventory.push({
moduleId: salvage.moduleId,
condition: salvage.condition ?? 1,
source: 'salvage',
});
moduleCollected = true; 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) => { export const bestAffordableMarketUpgrade = (progression, port) => {
const stats = deriveShipStats(progression.loadout); const stats = deriveShipStats(progression.loadout);
const candidates = marketForPort(port) 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) => module.price <= (progression.credits || 0))
.filter((module) => progression.loadout?.[module.slot] !== module.id)
.map((module) => { .map((module) => {
const next = deriveShipStats({ ...progression.loadout, [module.slot]: module.id }); const next = deriveShipStats({ ...progression.loadout, [module.slot]: module.id });
const score = (next.maxHull - stats.maxHull) * 0.7 const score = (next.maxHull - stats.maxHull) * 0.7

View File

@ -6,10 +6,10 @@ import activeUrl from './assets/shipyard-ui/active.png?url';
import closeButtonUrl from './assets/shipyard-ui/close_button.png?url'; import closeButtonUrl from './assets/shipyard-ui/close_button.png?url';
const UPGRADE_SLOTS = [ const UPGRADE_SLOTS = [
{ id: 'tuned-propellers', label: 'Engine Lv 1' }, { slot: 'engine', label: 'ENGINE' },
{ id: 'reinforced-keel', label: 'Hull Plating Lv 1' }, { slot: 'hull', label: 'HULL' },
{ id: 'expanded-hold', label: 'Cargo Hold Lv 1' }, { slot: 'cargo', label: 'CARGO' },
{ id: 'reinforced-rudder', label: 'Rudder Lv 1' }, { slot: 'rudder', label: 'RUDDER' },
]; ];
const dispatchAction = (action, extra = {}) => { const dispatchAction = (action, extra = {}) => {
@ -58,9 +58,15 @@ overlay.innerHTML = `
<div class="shipyard-upgrade-wrap"> <div class="shipyard-upgrade-wrap">
<img class="shipyard-upgrades" src="${upgradesUrl}" alt="Ship upgrades" draggable="false" /> <img class="shipyard-upgrades" src="${upgradesUrl}" alt="Ship upgrades" draggable="false" />
<div class="shipyard-upgrade-hotspots"> <div class="shipyard-upgrade-hotspots">
${UPGRADE_SLOTS.map(({ id, label }) => ` ${UPGRADE_SLOTS.map(({ slot, label }) => `
<button class="shipyard-upgrade-button" type="button" data-module-id="${id}" aria-label="Buy ${label}"> <button class="shipyard-upgrade-button" type="button" data-upgrade-slot="${slot}" aria-label="Upgrade ${label}">
<span class="shipyard-upgrade-state"></span> <span class="shipyard-upgrade-copy">
<strong>${label}</strong>
<span data-upgrade="current">CURRENT STOCK</span>
<span data-upgrade="next">NEXT MK I</span>
<span data-upgrade="effect"></span>
<span class="shipyard-upgrade-state" data-upgrade="state"></span>
</span>
</button> </button>
`).join('')} `).join('')}
</div> </div>
@ -71,7 +77,18 @@ overlay.innerHTML = `
</div> </div>
<footer class="shipyard-footer"> <footer class="shipyard-footer">
<button class="shipyard-salvage" type="button" data-shipyard="fit">FIT SALVAGE</button> <div class="shipyard-salvage-panel">
<div class="shipyard-salvage-copy">
<strong>SALVAGE HOLD</strong>
<span data-shipyard="salvage-name">EMPTY</span>
<span data-shipyard="salvage-meta">Pirate wrecks repair hull immediately.</span>
<span data-shipyard="salvage-effect">Recovered modules are stored here to fit or scrap.</span>
</div>
<div class="shipyard-salvage-actions">
<button class="shipyard-salvage" type="button" data-shipyard="fit">FIT MODULE</button>
<button class="shipyard-salvage shipyard-scrap" type="button" data-shipyard="scrap">SCRAP MODULE</button>
</div>
</div>
<span class="shipyard-message" data-shipyard="message"></span> <span class="shipyard-message" data-shipyard="message"></span>
</footer> </footer>
</section> </section>
@ -228,20 +245,52 @@ style.textContent = `
.shipyard-upgrade-button:not(:disabled):active { background: rgba(255, 214, 120, .12); } .shipyard-upgrade-button:not(:disabled):active { background: rgba(255, 214, 120, .12); }
.shipyard-upgrade-button:disabled { cursor: default; } .shipyard-upgrade-button:disabled { cursor: default; }
.shipyard-upgrade-state { .shipyard-upgrade-copy {
position: absolute; position: absolute;
left: 50%; left: 1px;
bottom: 3px; right: 1px;
transform: translateX(-50%); bottom: 1px;
padding: 2px 5px; 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; border-radius: 999px;
background: rgba(4, 16, 21, .88); background: rgba(118, 70, 22, .55);
color: #ffe4a1; 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; 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-upgrade-button.locked .shipyard-upgrade-state { color: #d8a8a0; }
.shipyard-active-plate { .shipyard-active-plate {
@ -260,32 +309,77 @@ style.textContent = `
.shipyard-footer { .shipyard-footer {
display: flex; display: flex;
align-items: center; flex-direction: column;
align-items: stretch;
justify-content: center; justify-content: center;
gap: 12px; gap: 8px;
min-height: 72px; min-height: 118px;
margin-top: 56px; 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 { .shipyard-salvage {
min-width: 150px; min-width: 112px;
min-height: 44px; min-height: 42px;
padding: 7px 14px; padding: 7px 12px;
border: 1px solid #b88131; border: 1px solid #b88131;
border-radius: 9px; border-radius: 9px;
background: linear-gradient(#174e43, #0d302a); background: linear-gradient(#174e43, #0d302a);
color: #e6f5d0; color: #e6f5d0;
font: 700 12px/1.15 Georgia, serif; font: 700 11px/1.15 Georgia, serif;
touch-action: manipulation; touch-action: manipulation;
} }
.shipyard-scrap {
background: linear-gradient(#553728, #342219);
color: #f0d2a4;
}
.shipyard-salvage:disabled { opacity: .35; } .shipyard-salvage:disabled { opacity: .35; }
.shipyard-message { .shipyard-message {
min-height: 1.2em; min-height: 1.2em;
max-width: 50%; width: min(720px, 96%);
margin: 0 auto;
color: #fff0bd; color: #fff0bd;
font-size: 12px; font-size: 12px;
text-align: center;
} }
body.shipyard-open #mobile-controls { visibility: hidden; } body.shipyard-open #mobile-controls { visibility: hidden; }
@ -340,8 +434,21 @@ style.textContent = `
gap: 7px; gap: 7px;
} }
.shipyard-salvage { min-width: 120px; min-height: 40px; font-size: 10px; } .shipyard-salvage-panel {
.shipyard-message { max-width: 56%; font-size: 10px; } 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) { @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]), [...overlay.querySelectorAll('[data-shipyard]')].map((el) => [el.dataset.shipyard, el]),
); );
const closeButton = overlay.querySelector('.shipyard-close'); 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')]; const upgradeButtons = [...overlay.querySelectorAll('.shipyard-upgrade-button')];
let latestState = null; let latestState = null;
const findStock = (id) =>
latestState?.progression?.marketStock?.find?.((module) => module.id === id) || null;
const render = (state) => { const render = (state) => {
if (!state?.progression) return; if (!state?.progression) return;
latestState = state; latestState = state;
const progression = state.progression; const progression = state.progression;
const stats = progression.shipStats || {}; const stats = progression.shipStats || {};
const loadout = progression.loadout || {};
fields.port.textContent = progression.nearestPort || '--'; fields.port.textContent = progression.nearestPort || '--';
fields.credits.textContent = progression.credits ?? 0; fields.credits.textContent = progression.credits ?? 0;
@ -395,27 +499,66 @@ const render = (state) => {
fields.cargo.textContent = `Cargo ${stats.cargoCapacity ?? 4}`; fields.cargo.textContent = `Cargo ${stats.cargoCapacity ?? 4}`;
for (const button of upgradeButtons) { for (const button of upgradeButtons) {
const id = button.dataset.moduleId; const slot = button.dataset.upgradeSlot;
const stock = findStock(id); const path = progression.upgradePaths?.[slot] || null;
const installed = Object.values(loadout).includes(id); const current = path?.current || null;
const stateLabel = button.querySelector('.shipyard-upgrade-state'); 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.dataset.moduleId = next?.id || '';
button.classList.toggle('locked', !stock && !installed); button.classList.toggle('maxed', !next);
button.disabled = installed || !stock || progression.credits < (stock?.price || Infinity); button.classList.toggle('locked', Boolean(next && !next.stocked));
stateLabel.textContent = installed if (currentText) {
? 'INSTALLED' currentText.textContent = `CURRENT: ${current?.tierLabel || 'STOCK'}`;
: !stock }
? 'NOT STOCKED' if (nextText) {
: progression.credits < stock.price nextText.textContent = next
? `NEED ¢${stock.price}` ? `NEXT: ${next.tierLabel} • ¢${next.price}`
: 'TAP TO BUY'; : `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, ' '); fields.message.textContent = String(state.status || '').replace(/\n/g, ' ');
}; };
@ -454,6 +597,11 @@ fitButton.addEventListener('click', () => {
dispatchAction('fit-salvage'); 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-open', (event) => show(event.detail));
window.addEventListener('airship:shipyard-state', (event) => render(event.detail)); window.addEventListener('airship:shipyard-state', (event) => render(event.detail));
window.addEventListener('airship:shipyard-close', hide); window.addEventListener('airship:shipyard-close', hide);

View File

@ -3,12 +3,15 @@ import assert from 'node:assert/strict';
import { import {
bestAffordableMarketUpgrade, bestAffordableMarketUpgrade,
buyAndInstallModule, buyAndInstallModule,
marketForPort,
collectSalvage, collectSalvage,
createStarterProgression, createStarterProgression,
deriveShipStats, deriveShipStats,
describeInventoryEntry,
installModule, installModule,
marketForPort,
salvageForPirate, salvageForPirate,
scrapInventoryModule,
upgradePathForSlot,
} from '../src/progression/ship-loadout.js'; } from '../src/progression/ship-loadout.js';
test('starter loadout preserves current ship combat and handling values', () => { 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.turnRate, 76);
assert.equal(stats.reloadMs, 850); assert.equal(stats.reloadMs, 850);
assert.equal(stats.cannonDamage, 16); 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', () => { test('installed modules derive meaningful ship stat changes', () => {
const progression = installModule(createStarterProgression(), 'corsair-overdrive'); const progression = installModule(createStarterProgression(), 'corsair-overdrive');
const stats = deriveShipStats(progression.loadout); const stats = deriveShipStats(progression.loadout);
assert.equal(stats.maxSpeed, 214); assert.equal(stats.maxSpeed, 218);
assert.equal(stats.acceleration, 110); assert.equal(stats.acceleration, 112);
assert.equal(stats.turnRate, 71); assert.equal(stats.turnRate, 74);
}); });
test('port purchase deducts credits and installs the module', () => { test('port purchase deducts credits and installs the module', () => {
const starter = createStarterProgression(); const starter = createStarterProgression();
const result = buyAndInstallModule(starter, 'tuned-propellers'); const result = buyAndInstallModule(starter, 'tuned-propellers');
assert.equal(result.purchased, false); assert.equal(result.purchased, false);
const funded = { ...starter, credits: 1000 }; const funded = { ...starter, credits: 1000 };
const fundedResult = buyAndInstallModule(funded, 'tuned-propellers'); const fundedResult = buyAndInstallModule(funded, 'tuned-propellers');
assert.equal(fundedResult.purchased, true); 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'); 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 salvageA = salvageForPirate({ seed: 'WORLD', classKey: 'captain', wave: 4, sequence: 2 });
const salvageB = 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.deepEqual(salvageA, salvageB);
assert.ok(salvageA.repair > 0);
const starter = createStarterProgression(); const starter = createStarterProgression();
const collected = collectSalvage(starter, salvageA); const collected = collectSalvage(starter, salvageA);
assert.ok(collected.credits >= starter.credits); assert.ok(collected.credits >= starter.credits);
assert.ok(collected.scrap >= starter.scrap); 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 starter = createStarterProgression();
const upgrade = bestAffordableMarketUpgrade(starter, { type: 'military' }); const upgrade = bestAffordableMarketUpgrade(starter, { type: 'military' });
assert.ok(upgrade); assert.ok(upgrade);
assert.ok(upgrade.price <= starter.credits); 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 its upgrade path remains actionable', () => {
test('rudder is stocked at every port type so the shipyard card is actionable', () => {
const portTypes = ['industrial', 'military', 'fuel', 'trade']; const portTypes = ['industrial', 'military', 'fuel', 'trade'];
for (const type of portTypes) { for (const type of portTypes) {
const stock = marketForPort({ type }); const stock = marketForPort({ type });
assert.ok(stock.some(({ id }) => id === 'reinforced-rudder')); 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 starter = { ...createStarterProgression(), credits: 650 };
const result = buyAndInstallModule(starter, 'reinforced-rudder'); const result = buyAndInstallModule(starter, 'reinforced-rudder');
const stats = deriveShipStats(result.loadout); const stats = deriveShipStats(result.loadout);

View File

@ -263,7 +263,7 @@ test('environment physics adds wind, soft island collision, turbulence, and proj
assert.match(source, /windSpeed: this\.environmentWind\?\.strength/); 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([ const [game, hud, index] = await Promise.all([
read('src/game-v4.js'), read('src/game-v4.js'),
read('src/mobile-hud.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="open-shipyard"/);
assert.match(index, /data-action="fit-salvage"/); assert.doesNotMatch(index, /data-action="fit-salvage"/);
assert.match(index, /CustomEvent\('airship:action'/); 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\('open-shipyard'\)/);
assert.match(game, /this\.pendingMobileActions\.delete\('fit-salvage'\)/); assert.match(game, /this\.pendingMobileActions\.delete\('fit-salvage'\)/);
assert.match(game, /marketUpgrade: marketUpgrade \? \{/); assert.match(game, /this\.pendingMobileActions\.delete\('scrap-salvage'\)/);
assert.match(game, /salvageModule: salvageModule \? \{/); assert.match(hud, /buyButton\.textContent = salvageCount > 0/);
assert.match(hud, /root\.querySelector\('\.mobile-control\.buy'\)/); assert.doesNotMatch(hud, /const fitButton = root\.querySelector/);
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/);
}); });
test('shipyard overlay uses supplied component art and explicit module purchases', async () => { 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, /shipyard-ui\/upgrades\.png\?url/);
assert.match(shipyard, /airship:shipyard-open/); assert.match(shipyard, /airship:shipyard-open/);
assert.match(shipyard, /dispatchAction\('purchase-module', \{ moduleId \}\)/); 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.doesNotMatch(shipyard, /data-shipyard="active"/);
assert.match(shipyard, /min-height: calc\(100dvh - 8px\)/); assert.match(shipyard, /min-height: calc\(100dvh - 8px\)/);
assert.match(game, /marketForPort\(port\)/); 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, /for \(const territory of this\.world\.pirateTerritories\)/);
assert.match(source, /this\.salvageWrecks = this\.physics\.add\.group\(\)/); assert.match(source, /this\.salvageWrecks = this\.physics\.add\.group\(\)/);
assert.match(source, /collectSalvageWreck\(/); 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, /bestAffordableMarketUpgrade\(/);
assert.match(source, /buyAndInstallModule\(/); assert.match(source, /buyAndInstallModule\(/);
assert.match(source, /installModule\(this\.progression, moduleId\)/); assert.match(source, /installModule\(this\.progression, moduleId\)/);