diff --git a/src/dock-ui.js b/src/dock-ui.js index bf410af..1755407 100644 --- a/src/dock-ui.js +++ b/src/dock-ui.js @@ -24,6 +24,7 @@ overlay.innerHTML = ` CARGO 0/0 SALVAGE 0 MISSION NONE + WANTED 0 @@ -46,6 +47,13 @@ overlay.innerHTML = ` SHIPYARD Upgrade the Sparrow and manage recovered salvage. + + + HULL SERVICE + Hull already at full strength. + + REPAIR + @@ -166,6 +174,32 @@ style.textContent = ` } .dock-service strong { display:block; margin-bottom:8px; color:#fff1bc; font-size:22px; } .dock-service span { color:#aac0c6; font:600 12px/1.45 system-ui,sans-serif; } + .dock-repair-strip { + grid-column: 1 / -1; + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; + padding:12px 14px; + border:1px solid rgba(194, 142, 61, .46); + border-radius:10px; + background:rgba(4, 28, 35, .9); + } + .dock-repair-strip > div { display:grid; gap:3px; } + .dock-repair-strip strong { color:#fff0bd; font-size:14px; } + .dock-repair-strip span { color:#9fb5bb; font:600 10px/1.3 system-ui,sans-serif; } + .dock-repair-strip button { + min-width:110px; + min-height:42px; + padding:7px 12px; + border:1px solid #9a6a2b; + border-radius:8px; + background:#103b3d; + color:#f5dda0; + font:700 10px/1 system-ui,sans-serif; + touch-action:manipulation; + } + .dock-repair-strip button:disabled { opacity:.36; } .dock-panel { display: grid; gap: 10px; } .dock-section-head { display:flex; align-items:center; justify-content:space-between; gap:10px; } .dock-section-head > div { display:grid; gap:2px; } @@ -309,7 +343,13 @@ const renderMarket = (state) => { strong.textContent = item.name; const tag = document.createElement('small'); tag.className = 'dock-tag'; - tag.textContent = item.exported ? 'LOCAL EXPORT' : item.imported ? 'HIGH DEMAND' : 'MARKET'; + tag.textContent = item.shortage > 0.5 + ? `SHORTAGE +${Math.round(item.shortage * 8)}%` + : item.exported + ? 'LOCAL EXPORT' + : item.imported + ? 'HIGH DEMAND' + : 'MARKET'; name.append(strong, tag); const buy = document.createElement('span'); buy.className = 'dock-price'; @@ -317,13 +357,20 @@ const renderMarket = (state) => { const sell = document.createElement('span'); sell.className = 'dock-price'; sell.textContent = `SELL ¢${item.sellPrice} • HOLD ${item.held || 0}`; + const hint = document.createElement('p'); + const opportunity = item.opportunity; + hint.textContent = opportunity + ? opportunity.margin > 0 + ? `BEST KNOWN: ${opportunity.portName} • SELL ¢${opportunity.sellPrice} • +¢${opportunity.margin}/UNIT` + : `BEST KNOWN: ${opportunity.portName} • no positive margin` + : 'No known destination price.'; const actions = document.createElement('div'); actions.className = 'dock-card-actions'; actions.append( actionButton('BUY 1', 'buy-trade-good', { goodId: item.goodId }, !item.canBuy), actionButton('SELL 1', 'sell-trade-good', { goodId: item.goodId }, !item.canSell), ); - card.append(name, buy, sell, actions); + card.append(name, buy, sell, hint, actions); fields['market-list'].append(card); } }; @@ -338,6 +385,19 @@ const render = (state) => { fields.cargo.textContent = `${dock.cargoUsed || 0}/${dock.cargoCapacity || 0}`; fields.salvage.textContent = state.progression?.inventoryCount || 0; fields.mission.textContent = dock.activeMission ? dock.activeMission.type.toUpperCase() : 'NONE'; + fields.wanted.textContent = dock.wanted || 0; + + const repairButton = overlay.querySelector('[data-action="repair-hull"]'); + const repair = dock.repair; + if (repair) { + fields['repair-status'].textContent = repair.missing > 0 + ? `Repair ${repair.missing} hull for ¢${repair.cost}.` + : 'Hull already at full strength.'; + repairButton.disabled = repair.missing <= 0 + || (state.progression?.credits || 0) < repair.cost; + repairButton.textContent = repair.missing > 0 ? `REPAIR ¢${repair.cost}` : 'FULL HULL'; + } + fields.message.textContent = String(state.status || '').replace(/\n/g, ' '); renderMission(state); renderMarket(state); @@ -360,6 +420,8 @@ for (const button of overlay.querySelectorAll('[data-dock-tab]')) { for (const button of overlay.querySelectorAll('[data-action="open-shipyard"]')) { button.addEventListener('click', () => dispatchAction('open-shipyard')); } +overlay.querySelector('[data-action="repair-hull"]') + .addEventListener('click', () => dispatchAction('repair-hull')); overlay.querySelector('.dock-leave').addEventListener('click', () => dispatchAction('close-dock')); overlay.querySelector('.dock-backdrop').addEventListener('click', () => dispatchAction('close-dock')); diff --git a/src/game-v4.js b/src/game-v4.js index 0a556d7..281b175 100644 --- a/src/game-v4.js +++ b/src/game-v4.js @@ -36,6 +36,7 @@ import { createStarterProgression, deriveShipStats, describeInventoryEntry, + installInventoryModule, installModule, marketForPort, salvageForPirate, @@ -45,11 +46,15 @@ import { import { MODULE_CATALOG } from './progression/modules.js'; import { acceptMission, + bestTradeDestination, buyTradeGood, completeMission, marketAtPort, missionOffersAtPort, missionStatus, + payForHullRepair, + recordRouteTradeEvent, + repairQuote, sellTradeGood, tradeCargoUnits, } from './progression/dock-economy.js'; @@ -114,7 +119,7 @@ class GameScene extends Phaser.Scene { height: WORLD_SIZE, }); this.progression = createStarterProgression(); - this.playerStats = deriveShipStats(this.progression.loadout); + this.playerStats = deriveShipStats(this.progression.loadout, this.progression.installedConditions); this.pendingMobileActions = new Set(); this.pendingActionDetails = new Map(); this.pendingModulePurchaseId = null; @@ -545,7 +550,7 @@ class GameScene extends Phaser.Scene { } applyProgressionStats(previousMaxHull = this.playerStats?.maxHull || PLAYER_MAX_HULL) { - this.playerStats = deriveShipStats(this.progression.loadout); + this.playerStats = deriveShipStats(this.progression.loadout, this.progression.installedConditions); this.playerBroadsideReloadMs = this.playerStats.reloadMs; if (this.playerStats.maxHull > previousMaxHull) { @@ -581,6 +586,7 @@ class GameScene extends Phaser.Scene { const sellTradeAction = takeAction('sell-trade-good'); const acceptMissionAction = takeAction('accept-mission'); const completeMissionAction = takeAction('complete-mission'); + const repairHullAction = takeAction('repair-hull'); const keyboardDock = Phaser.Input.Keyboard.JustDown(this.keys.buyUpgrade); const keyboardEscape = Phaser.Input.Keyboard.JustDown(this.keys.closeShipyard); @@ -661,7 +667,8 @@ class GameScene extends Phaser.Scene { if (!this.shipyardOpen || !docked) { this.status.setText('FIT RECOVERED MODULES AT THE SHIPYARD'); } else { - const salvageEntry = this.progression.inventory?.[0]; + const inventoryIndex = Math.max(0, Number(fitAction?.inventoryIndex) || 0); + const salvageEntry = this.progression.inventory?.[inventoryIndex]; const moduleId = typeof salvageEntry === 'string' ? salvageEntry : salvageEntry?.moduleId; @@ -669,10 +676,11 @@ class GameScene extends Phaser.Scene { this.status.setText('SALVAGE HOLD IS EMPTY'); } else { const previousMaxHull = this.playerStats.maxHull; - this.progression = installModule(this.progression, moduleId); + this.progression = installInventoryModule(this.progression, inventoryIndex); this.applyProgressionStats(previousMaxHull); const module = MODULE_CATALOG[moduleId]; - this.status.setText(`FITTED ${module?.name || moduleId}`); + const condition = Math.round((this.progression.installedCondition || 1) * 100); + this.status.setText(`FITTED ${module?.name || moduleId} // ${condition}% CONDITION`); } window.dispatchEvent(new CustomEvent('airship:shipyard-state', { detail: this.buildHudState(), @@ -685,7 +693,8 @@ class GameScene extends Phaser.Scene { this.status.setText('SCRAP RECOVERED MODULES AT THE SHIPYARD'); } else { const beforeScrap = this.progression.scrap || 0; - this.progression = scrapInventoryModule(this.progression, 0); + const inventoryIndex = Math.max(0, Number(scrapAction?.inventoryIndex) || 0); + this.progression = scrapInventoryModule(this.progression, inventoryIndex); const gained = Math.max(0, (this.progression.scrap || 0) - beforeScrap); this.status.setText( this.progression.scrapped @@ -699,7 +708,7 @@ class GameScene extends Phaser.Scene { } if (this.dockOpen && docked && !this.shipyardOpen) { - const market = marketAtPort(this.world, port); + const market = marketAtPort(this.world, port, this.progression.economy); if (buyTradeAction) { const item = market.find(({ goodId }) => goodId === buyTradeAction.goodId); @@ -724,7 +733,12 @@ class GameScene extends Phaser.Scene { } if (acceptMissionAction) { - const offers = missionOffersAtPort(this.world, port, this.defeated); + const offers = missionOffersAtPort( + this.world, + port, + this.defeated, + this.progression.completedMissions || 0, + ); const mission = offers.find(({ id }) => id === acceptMissionAction.missionId); this.progression = acceptMission( this.progression, @@ -754,7 +768,25 @@ class GameScene extends Phaser.Scene { ); } - if (buyTradeAction || sellTradeAction || acceptMissionAction || completeMissionAction) { + if (repairHullAction) { + const result = payForHullRepair( + this.progression, + this.playerHull, + this.playerStats.maxHull, + port, + ); + this.progression = result.progression; + this.playerHull = result.hull; + this.status.setText( + result.repaired + ? `HULL REPAIRED // -¢${result.cost}` + : result.reason === 'full-hull' + ? 'HULL ALREADY AT FULL STRENGTH' + : `REPAIR COST ¢${result.cost} // NOT ENOUGH COIN`, + ); + } + + if (buyTradeAction || sellTradeAction || acceptMissionAction || completeMissionAction || repairHullAction) { window.dispatchEvent(new CustomEvent('airship:dock-state', { detail: this.buildHudState(), })); @@ -1066,6 +1098,14 @@ class GameScene extends Phaser.Scene { const range = Math.hypot(target.x - merchant.x, target.y - merchant.y); if (range <= MERCHANT_PORT_ARRIVAL_RANGE) { + const destinationPortId = direction >= 0 ? route.toPortId : route.fromPortId; + this.progression = recordRouteTradeEvent( + this.progression, + this.world, + route, + destinationPortId, + { kind: 'delivery' }, + ); merchant.setData('routeDirection', -direction); merchant.setData('deliveries', (merchant.getData('deliveries') || 0) + 1); this.tradeDeliveries += 1; @@ -1154,10 +1194,29 @@ class GameScene extends Phaser.Scene { const cargoValue = merchant.getData('cargoValue') || 50; const x = merchant.x; const y = merchant.y; + const route = this.world.tradeRoutes + .find(({ id }) => id === merchant.getData('routeId')); + const direction = merchant.getData('routeDirection') || 1; + const destinationPortId = direction >= 0 ? route?.toPortId : route?.fromPortId; + merchant.destroy(); this.spawnExplosion(x, y, 1.05); this.tradeLosses += 1; if (projectileOwner === 'player') this.playerTradeLosses += 1; + if (route && destinationPortId) { + this.progression = recordRouteTradeEvent( + this.progression, + this.world, + route, + destinationPortId, + { kind: 'loss', playerCaused: projectileOwner === 'player' }, + ); + } + if (projectileOwner === 'player') { + this.status.setText( + `MERCHANT DESTROYED // WANTED ${this.progression.economy?.wanted || 0}`, + ); + } const cargoSalvage = { moduleId: null, @@ -1380,7 +1439,7 @@ class GameScene extends Phaser.Scene { updatePlayer(dt, deltaMs) { if (!this.player?.body) return; - const stats = this.playerStats || deriveShipStats(this.progression?.loadout); + const stats = this.playerStats || deriveShipStats(this.progression?.loadout, this.progression?.installedConditions); const rawHelmX = Math.abs(this.helmVector.x) > HELM_DEAD_ZONE ? this.helmVector.x : 0; const helmX = Math.sign(rawHelmX) * Math.pow(Math.abs(rawHelmX), HELM_STEER_CURVE); @@ -1811,23 +1870,70 @@ class GameScene extends Phaser.Scene { const marketUpgrade = docked ? bestAffordableMarketUpgrade(this.progression, portState.port) : null; - const salvageEntry = this.progression?.inventory?.[0] || null; - const salvageItem = describeInventoryEntry(salvageEntry); + const salvageItems = (this.progression?.inventory || []) + .map((entry, index) => { + const details = describeInventoryEntry(entry); + return details ? { ...details, inventoryIndex: index } : null; + }) + .filter(Boolean); + const salvageItem = salvageItems[0] || null; const upgradePaths = Object.fromEntries( ['engine', 'hull', 'cargo', 'rudder'].map((slot) => [ slot, upgradePathForSlot(this.progression.loadout, slot, marketStock), ]), ); - const dockMarket = docked ? marketAtPort(this.world, portState.port) : []; + const dockMarket = docked + ? marketAtPort(this.world, portState.port, this.progression.economy) + : []; const cargoUsed = tradeCargoUnits(this.progression); const missionOffers = docked - ? missionOffersAtPort(this.world, portState.port, this.defeated) + ? missionOffersAtPort( + this.world, + portState.port, + this.defeated, + this.progression.completedMissions || 0, + ) : []; const activeMission = missionStatus(this.progression, { portId: docked ? portState.port?.id : null, defeated: this.defeated, }); + const repair = docked + ? repairQuote( + this.playerHull, + this.playerStats.maxHull, + portState.port, + this.progression.economy?.wanted || 0, + ) + : null; + + let objective = null; + if (activeMission?.type === 'delivery') { + const targetPort = this.world.ports + .find(({ id }) => id === activeMission.targetPortId); + if (targetPort) { + const objectiveBearing = bearingTo(player, targetPort); + objective = { + type: 'delivery', + label: targetPort.name, + distance: Math.hypot(targetPort.x - player.x, targetPort.y - player.y), + bearing: objectiveBearing, + relativeBearing: signedAngleDelta(objectiveBearing, normalizeDegrees(player.angle)), + progressText: activeMission.progressText, + ready: activeMission.ready, + }; + } + } else if (activeMission?.type === 'bounty') { + objective = { + type: 'bounty', + label: 'PIRATE BOUNTY', + progressText: activeMission.progressText, + kills: activeMission.kills || 0, + targetKills: activeMission.targetKills, + ready: activeMission.ready, + }; + } return { gameState: this.gameState, @@ -1851,6 +1957,7 @@ class GameScene extends Phaser.Scene { port: reloadReady('port'), starboard: reloadReady('starboard'), }, + objective, environment: { windSpeed: this.environmentWind?.strength || 0, windHeading: this.environmentWind?.heading || 0, @@ -1873,9 +1980,16 @@ class GameScene extends Phaser.Scene { cargoCapacity: this.playerStats?.cargoCapacity || 0, market: dockMarket.map((item) => { const held = this.progression.tradeCargo?.[item.goodId] || 0; + const opportunity = bestTradeDestination( + this.world, + portState.port, + item.goodId, + this.progression.economy, + ); return { ...item, held, + opportunity, canBuy: (this.progression.credits || 0) >= item.buyPrice && cargoUsed < (this.playerStats?.cargoCapacity || 0), canSell: held > 0, @@ -1883,6 +1997,9 @@ class GameScene extends Phaser.Scene { }), missionOffers, activeMission, + repair, + wanted: this.progression.economy?.wanted || 0, + reputation: this.progression.economy?.reputation || 0, }, progression: { credits: this.progression?.credits || 0, @@ -1895,6 +2012,7 @@ class GameScene extends Phaser.Scene { shipyardOpen: this.shipyardOpen, shipStats: { ...this.playerStats }, loadout: { ...this.progression.loadout }, + installedConditions: { ...(this.progression.installedConditions || {}) }, marketStock: marketStock.map((module) => ({ id: module.id, name: module.name, @@ -1909,6 +2027,7 @@ class GameScene extends Phaser.Scene { name: marketUpgrade.name, price: marketUpgrade.price, } : null, + salvageItems, salvageItem, salvageModule: salvageItem ? { id: salvageItem.moduleId, @@ -1998,9 +2117,14 @@ class GameScene extends Phaser.Scene { this.speedText.setText(`${Math.abs(state.speed || 0).toFixed(0)} ${state.speed < 0 ? 'REV' : 'FWD'}`); this.headingText.setText(`${(state.heading || 0).toFixed(0).padStart(3, '0')}°`); this.waveText.setText(`WAVE ${state.wave} • ${state.hostiles} HOSTILE${state.hostiles === 1 ? '' : 'S'}`); + const objectiveText = state.objective?.type === 'delivery' + ? `${state.objective.label} ${Math.round(state.objective.distance || 0)}m` + : state.objective?.type === 'bounty' + ? state.objective.progressText + : null; this.combatText.setText(compact - ? `K ${state.kills}\nT${state.trade?.activeMerchants || 0}` - : `DEFEATED ${state.kills}\nRANGE ${rangeText}\nTRADE ${state.trade?.activeMerchants || 0} LOSS ${state.trade?.losses || 0}\n¢${state.progression?.credits || 0} S${state.progression?.scrap || 0}`); + ? `K ${state.kills}\nT${state.trade?.activeMerchants || 0}${objectiveText ? `\n${objectiveText}` : ''}` + : `DEFEATED ${state.kills}\nRANGE ${rangeText}\nTRADE ${state.trade?.activeMerchants || 0} LOSS ${state.trade?.losses || 0}\n¢${state.progression?.credits || 0} S${state.progression?.scrap || 0}${objectiveText ? `\nMISSION ${objectiveText}` : ''}`); this.playerHullLabel.setText(`SPARROW ${playerHull}/${playerMaxHull}`); this.enemyHullLabel.setText(target diff --git a/src/mobile-hud.js b/src/mobile-hud.js index 1793f41..aca24f0 100644 --- a/src/mobile-hud.js +++ b/src/mobile-hud.js @@ -326,6 +326,42 @@ if (!isDesktopPointer.matches) { display: block; } + .steam-objective { + display: none; + position: absolute; + left: 50%; + top: 20.5%; + transform: translateX(-50%); + align-items: center; + gap: 7px; + max-width: 78%; + padding: 5px 9px; + border: 1px solid rgba(201, 146, 60, .58); + border-radius: 999px; + background: rgba(5, 22, 28, .82); + color: #f7dfa1; + font: 700 clamp(9px, 2.45vw, 12px)/1.1 system-ui, sans-serif; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + box-shadow: 0 2px 10px rgba(0,0,0,.28); + } + + .steam-objective.visible { display: flex; } + + .steam-objective-arrow { + display: inline-block; + color: #ffe08c; + font-size: 18px; + line-height: 1; + transform-origin: center; + } + + .steam-objective-text { + overflow: hidden; + text-overflow: ellipsis; + } + #mobile-controls.controller-active #drag-stick { left: 1%; bottom: max(8px, env(safe-area-inset-bottom)); @@ -697,6 +733,14 @@ if (!isDesktopPointer.matches) { status.dataset.steam = 'status'; hud.appendChild(status); + const objective = document.createElement('div'); + objective.className = 'steam-objective'; + objective.innerHTML = ` + ↑ + + `; + hud.appendChild(objective); + const fields = Object.fromEntries( [...hud.querySelectorAll('[data-steam]')].map((el) => [el.dataset.steam, el]), ); @@ -800,6 +844,21 @@ if (!isDesktopPointer.matches) { fields['port-reload'].style.transform = `scaleX(${clamp01(state.reload?.port ?? 1)})`; fields['starboard-reload'].style.transform = `scaleX(${clamp01(state.reload?.starboard ?? 1)})`; + const mission = state.objective || null; + objective.classList.toggle('visible', Boolean(mission)); + if (mission?.type === 'delivery') { + fields['objective-arrow'].hidden = false; + fields['objective-arrow'].style.transform = `rotate(${mission.relativeBearing || 0}deg)`; + fields['objective-text'].textContent = + `${mission.label} • ${Math.round(mission.distance || 0)}m`; + } else if (mission?.type === 'bounty') { + fields['objective-arrow'].hidden = true; + fields['objective-text'].textContent = mission.progressText || 'PIRATE BOUNTY'; + } else { + fields['objective-arrow'].hidden = true; + fields['objective-text'].textContent = ''; + } + const progression = state.progression || {}; if (buyButton) { buyButton.hidden = !progression.docked; diff --git a/src/progression/dock-economy.js b/src/progression/dock-economy.js index 4e22548..33d4283 100644 --- a/src/progression/dock-economy.js +++ b/src/progression/dock-economy.js @@ -23,7 +23,11 @@ const resourceToGood = (resource) => resource === 'food' ? 'ore' : resource === 'fuel' ? 'fuel' - : resource; + : resource === 'timber' + ? 'machinery' + : resource; + +const shortageKey = (portId, goodId) => `${portId}:${goodId}`; export const tradeCargoUnits = (progression = {}) => { const cargo = progression.tradeCargo || {}; @@ -34,11 +38,14 @@ export const tradeCargoUnits = (progression = {}) => { return goods + missionUnits; }; -export const marketAtPort = (world, port) => { +export const marketAtPort = (world, port, economy = {}) => { if (!port) return []; const typeMods = TYPE_MODIFIERS[port.type] || TYPE_MODIFIERS.trade; const exports = new Set((port.exports || []).map(resourceToGood)); const imports = new Set((port.imports || []).map(resourceToGood)); + const wanted = clamp(Number(economy.wanted) || 0, 0, 8); + const reputation = Number(economy.reputation) || 0; + const piracyMarkup = 1 + wanted * 0.035 + Math.max(0, -reputation) * 0.004; return Object.values(TRADE_GOODS).map((good) => { const random = createSeededRandom(`${world?.seed || 'world'}:${port.id}:${good.id}:market`); @@ -48,6 +55,10 @@ export const marketAtPort = (world, port) => { if (imports.has(good.id)) modifier *= 1.2; modifier *= 0.96 + (1 - (port.wealth || 0.5)) * 0.08; + const shortage = clamp(Number(economy.shortages?.[shortageKey(port.id, good.id)]) || 0, 0, 6); + modifier *= 1 + shortage * 0.08; + modifier *= piracyMarkup; + const localPrice = Math.max(8, Math.round(good.basePrice * modifier * jitter)); return { goodId: good.id, @@ -56,10 +67,35 @@ export const marketAtPort = (world, port) => { sellPrice: Math.max(5, Math.round(localPrice * 0.86)), exported: exports.has(good.id), imported: imports.has(good.id), + shortage, }; }); }; +export const bestTradeDestination = (world, originPort, goodId, economy = {}) => { + if (!originPort || !goodId) return null; + const originItem = marketAtPort(world, originPort, economy) + .find((item) => item.goodId === goodId); + if (!originItem) return null; + + const candidates = (world?.ports || []) + .filter((port) => port.id !== originPort.id) + .map((port) => { + const item = marketAtPort(world, port, economy) + .find((entry) => entry.goodId === goodId); + return item ? { + portId: port.id, + portName: port.name, + sellPrice: item.sellPrice, + margin: item.sellPrice - originItem.buyPrice, + } : null; + }) + .filter(Boolean) + .sort((a, b) => b.margin - a.margin || b.sellPrice - a.sellPrice); + + return candidates[0] || null; +}; + export const buyTradeGood = (progression, marketItem, stats, quantity = 1) => { const qty = Math.max(1, Math.floor(quantity)); const price = Math.max(0, marketItem?.buyPrice || 0) * qty; @@ -111,15 +147,17 @@ const otherPortId = (route, portId) => route.fromPortId === portId ? route.toPortId : route.fromPortId; -export const missionOffersAtPort = (world, port, defeated = 0) => { +export const missionOffersAtPort = (world, port, defeated = 0, cycle = 0) => { if (!port) return []; + const random = createSeededRandom(`${world?.seed || 'world'}:${port.id}:missions:${cycle}`); const routes = connectedRoutes(world, port.id) .sort((a, b) => b.value - a.value || routeDistance(b) - routeDistance(a)); - const deliveryRoute = routes[0] || null; + const deliveryRoute = routes.length + ? routes[Math.floor(random() * routes.length)] + : null; const targetPort = deliveryRoute ? world.ports.find(({ id }) => id === otherPortId(deliveryRoute, port.id)) : null; - const random = createSeededRandom(`${world?.seed || 'world'}:${port.id}:missions`); const offers = []; if (deliveryRoute && targetPort) { @@ -127,7 +165,7 @@ export const missionOffersAtPort = (world, port, defeated = 0) => { const distance = routeDistance(deliveryRoute); const reward = Math.round(120 + distance * 0.11 + deliveryRoute.value * 160 + cargoUnits * 30); offers.push({ - id: `delivery:${port.id}:${targetPort.id}`, + id: `delivery:${cycle}:${port.id}:${targetPort.id}`, type: 'delivery', title: `Courier Run → ${targetPort.name}`, description: `Carry ${cargoUnits} sealed cargo unit${cargoUnits === 1 ? '' : 's'} to ${targetPort.name}.`, @@ -137,12 +175,13 @@ export const missionOffersAtPort = (world, port, defeated = 0) => { targetPortName: targetPort.name, cargoUnits, reward, + boardCycle: cycle, }); } const killTarget = 2 + Math.floor(random() * 3); offers.push({ - id: `bounty:${port.id}:${killTarget}`, + id: `bounty:${cycle}:${port.id}:${killTarget}`, type: 'bounty', title: `Pirate Bounty ×${killTarget}`, description: `Destroy ${killTarget} pirate airship${killTarget === 1 ? '' : 's'} and claim the bounty at any dock.`, @@ -151,6 +190,7 @@ export const missionOffersAtPort = (world, port, defeated = 0) => { targetKills: killTarget, reward: 150 + killTarget * 90, baselineKills: defeated, + boardCycle: cycle, }); return offers; @@ -204,7 +244,96 @@ export const completeMission = (progression, context = {}) => { ...progression, credits: (progression.credits || 0) + Math.max(0, status.reward || 0), activeMission: null, + completedMissions: (progression.completedMissions || 0) + 1, missionCompleted: true, lastCompletedMission: status, }; }; + +const routeTradeGood = (world, route, destinationPortId) => { + const destination = world?.ports?.find(({ id }) => id === destinationPortId); + const sourceId = route?.fromPortId === destinationPortId ? route?.toPortId : route?.fromPortId; + const source = world?.ports?.find(({ id }) => id === sourceId); + const desired = (destination?.imports || []).map(resourceToGood) + .find((goodId) => TRADE_GOODS[goodId]); + if (desired) return desired; + const exported = (source?.exports || []).map(resourceToGood) + .find((goodId) => TRADE_GOODS[goodId]); + return exported || 'provisions'; +}; + +export const recordRouteTradeEvent = ( + progression, + world, + route, + destinationPortId, + { kind = 'delivery', playerCaused = false } = {}, +) => { + if (!route || !destinationPortId) return progression; + const goodId = routeTradeGood(world, route, destinationPortId); + const economy = { + shortages: { ...(progression.economy?.shortages || {}) }, + reputation: Number(progression.economy?.reputation) || 0, + wanted: Number(progression.economy?.wanted) || 0, + }; + const key = shortageKey(destinationPortId, goodId); + const current = Number(economy.shortages[key]) || 0; + economy.shortages[key] = kind === 'loss' + ? clamp(current + 1.25, 0, 6) + : clamp(current - 0.7, 0, 6); + + if (playerCaused) { + economy.reputation = Math.max(-20, economy.reputation - 2); + economy.wanted = clamp(economy.wanted + 1, 0, 8); + } + + return { + ...progression, + economy, + lastTradeEvent: { + kind, + routeId: route.id, + destinationPortId, + goodId, + playerCaused, + }, + }; +}; + +export const repairQuote = (currentHull, maxHull, port, wanted = 0) => { + const missing = Math.max(0, Math.ceil((maxHull || 0) - (currentHull || 0))); + const baseRate = port?.type === 'capital' ? 2 + : port?.type === 'industrial' ? 2.2 + : port?.type === 'trade' ? 2.45 + : 2.7; + const rate = baseRate * (1 + clamp(Number(wanted) || 0, 0, 8) * 0.06); + return { + missing, + cost: Math.ceil(missing * rate), + rate, + }; +}; + +export const payForHullRepair = (progression, currentHull, maxHull, port) => { + const quote = repairQuote( + currentHull, + maxHull, + port, + progression.economy?.wanted || 0, + ); + if (quote.missing <= 0) { + return { progression, hull: currentHull, repaired: false, reason: 'full-hull', cost: 0 }; + } + if ((progression.credits || 0) < quote.cost) { + return { progression, hull: currentHull, repaired: false, reason: 'insufficient-credits', cost: quote.cost }; + } + return { + progression: { + ...progression, + credits: progression.credits - quote.cost, + }, + hull: maxHull, + repaired: true, + cost: quote.cost, + }; +}; diff --git a/src/progression/ship-loadout.js b/src/progression/ship-loadout.js index 756ae3a..3fed3af 100644 --- a/src/progression/ship-loadout.js +++ b/src/progression/ship-loadout.js @@ -17,20 +17,29 @@ export const createStarterProgression = () => ({ credits: 650, scrap: 0, loadout: { ...STARTER_LOADOUT }, + installedConditions: {}, inventory: [], tradeCargo: {}, activeMission: null, + completedMissions: 0, + economy: { + shortages: {}, + reputation: 0, + wanted: 0, + }, }); -export const deriveShipStats = (loadout = STARTER_LOADOUT) => { +export const deriveShipStats = (loadout = STARTER_LOADOUT, installedConditions = {}) => { const stats = { ...SHIP_BASE_STATS }; - for (const moduleId of Object.values(loadout)) { + for (const [slot, moduleId] of Object.entries(loadout)) { if (!moduleId) continue; const module = MODULE_CATALOG[moduleId]; if (!module) continue; + const condition = clamp(Number(installedConditions?.[slot] ?? 1), 0.05, 1); + const effectiveness = 0.55 + condition * 0.45; for (const [key, value] of Object.entries(module.modifiers || {})) { - stats[key] = (stats[key] ?? 0) + value; + stats[key] = (stats[key] ?? 0) + value * effectiveness; } } @@ -103,7 +112,7 @@ export const describeInventoryEntry = (entry) => { }; }; -export const installModule = (progression, moduleId) => { +export const installModule = (progression, moduleId, condition = 1) => { const module = MODULE_CATALOG[moduleId]; if (!module) return { ...progression, installed: false, reason: 'unknown-module' }; if (progression.loadout?.[module.slot] === moduleId) { @@ -115,22 +124,71 @@ export const installModule = (progression, 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; + const loadoutState = { ...(progression.loadout || STARTER_LOADOUT) }; + const installedConditions = { ...(progression.installedConditions || {}) }; + const replaced = loadoutState[module.slot] || null; + const replacedCondition = installedConditions[module.slot] ?? 1; + loadoutState[module.slot] = moduleId; + installedConditions[module.slot] = installedEntry + ? inventoryCondition(installedEntry) + : clamp(Number(condition) || 1, 0.05, 1); if (replaced && MODULE_CATALOG[replaced]?.price > 0) { - inventory.push({ moduleId: replaced, condition: 1, source: 'removed' }); + inventory.push({ + moduleId: replaced, + condition: replacedCondition, + source: 'removed', + }); } return { ...progression, - loadout, + loadout: loadoutState, + installedConditions, inventory, installed: true, replaced, installedModule: moduleId, - installedCondition: installedEntry ? inventoryCondition(installedEntry) : 1, + installedCondition: installedConditions[module.slot], + }; +}; + +export const installInventoryModule = (progression, index = 0) => { + const inventory = [...(progression.inventory || [])]; + const entry = inventory[index]; + const moduleId = inventoryModuleId(entry); + const module = MODULE_CATALOG[moduleId]; + if (!entry || !module) return { ...progression, installed: false, reason: 'empty-hold' }; + if (progression.loadout?.[module.slot] === moduleId) { + return { ...progression, installed: false, reason: 'already-installed' }; + } + + inventory.splice(index, 1); + const loadoutState = { ...(progression.loadout || STARTER_LOADOUT) }; + const installedConditions = { ...(progression.installedConditions || {}) }; + const replaced = loadoutState[module.slot] || null; + const replacedCondition = installedConditions[module.slot] ?? 1; + + loadoutState[module.slot] = moduleId; + installedConditions[module.slot] = inventoryCondition(entry); + + if (replaced && MODULE_CATALOG[replaced]?.price > 0) { + inventory.push({ + moduleId: replaced, + condition: replacedCondition, + source: 'removed', + }); + } + + return { + ...progression, + loadout: loadoutState, + installedConditions, + inventory, + installed: true, + replaced, + installedModule: moduleId, + installedCondition: installedConditions[module.slot], }; }; @@ -151,10 +209,15 @@ export const buyAndInstallModule = (progression, moduleId) => { } const loadout = { ...(progression.loadout || STARTER_LOADOUT), [module.slot]: moduleId }; + const installedConditions = { + ...(progression.installedConditions || {}), + [module.slot]: 1, + }; return { ...progression, credits: progression.credits - module.price, loadout, + installedConditions, purchased: true, installed: true, installedModule: moduleId, @@ -205,7 +268,11 @@ export const salvageForPirate = ({ }; }; -export const collectSalvage = (progression, salvage, stats = deriveShipStats(progression.loadout)) => { +export const collectSalvage = ( + progression, + salvage, + stats = deriveShipStats(progression.loadout, progression.installedConditions), +) => { const inventory = [...(progression.inventory || [])]; let moduleCollected = false; @@ -244,7 +311,7 @@ export const scrapInventoryModule = (progression, index = 0) => { }; export const bestAffordableMarketUpgrade = (progression, port) => { - const stats = deriveShipStats(progression.loadout); + const stats = deriveShipStats(progression.loadout, progression.installedConditions); const candidates = marketForPort(port) .filter((module) => { if (!Number.isInteger(module.tier)) { @@ -256,7 +323,10 @@ export const bestAffordableMarketUpgrade = (progression, port) => { }) .filter((module) => module.price <= (progression.credits || 0)) .map((module) => { - const next = deriveShipStats({ ...progression.loadout, [module.slot]: module.id }); + const next = deriveShipStats( + { ...progression.loadout, [module.slot]: module.id }, + { ...(progression.installedConditions || {}), [module.slot]: 1 }, + ); const score = (next.maxHull - stats.maxHull) * 0.7 + (next.maxSpeed - stats.maxSpeed) + (next.acceleration - stats.acceleration) * 0.45 diff --git a/src/shipyard-ui.js b/src/shipyard-ui.js index ef3f187..1818664 100644 --- a/src/shipyard-ui.js +++ b/src/shipyard-ui.js @@ -79,12 +79,14 @@ overlay.innerHTML = `