Clarify upgrade tiers and salvage recovery
This commit is contained in:
parent
0316acf702
commit
bb302b0ac7
@ -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 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 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>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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 (!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) {
|
||||
this.status.setText('NO SALVAGED MODULES IN HOLD');
|
||||
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 SALVAGE\n${module?.name || moduleId}`);
|
||||
this.status.setText(`FITTED ${module?.name || moduleId}`);
|
||||
}
|
||||
if (this.shipyardOpen) {
|
||||
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: {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = `
|
||||
<div class="shipyard-upgrade-wrap">
|
||||
<img class="shipyard-upgrades" src="${upgradesUrl}" alt="Ship upgrades" draggable="false" />
|
||||
<div class="shipyard-upgrade-hotspots">
|
||||
${UPGRADE_SLOTS.map(({ id, label }) => `
|
||||
<button class="shipyard-upgrade-button" type="button" data-module-id="${id}" aria-label="Buy ${label}">
|
||||
<span class="shipyard-upgrade-state"></span>
|
||||
${UPGRADE_SLOTS.map(({ slot, label }) => `
|
||||
<button class="shipyard-upgrade-button" type="button" data-upgrade-slot="${slot}" aria-label="Upgrade ${label}">
|
||||
<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>
|
||||
`).join('')}
|
||||
</div>
|
||||
@ -71,7 +77,18 @@ overlay.innerHTML = `
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</footer>
|
||||
</section>
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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\)/);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user