feat: repairing damaged equipment
authorxangelo <me@xangelo.ca>
Tue, 29 Aug 2023 19:34:47 +0000 (15:34 -0400)
committerxangelo <me@xangelo.ca>
Tue, 29 Aug 2023 19:34:47 +0000 (15:34 -0400)
If your equipment is damaged in battle, you can visit the Iron Smith to
repair it for a fraction of what you would pay to buy new equipment!

src/server/api.ts
src/server/inventory.ts
src/server/locations/repair.ts [new file with mode: 0644]
src/server/views/components/city.ts [new file with mode: 0644]
src/server/views/inventory.ts
src/server/views/repair.ts [new file with mode: 0644]
src/server/views/stores.ts
src/shared/inventory.ts

index 61e7f5cb9c38a8bd4ef9ad15fb2f69c8a737e0a2..77d54b28c942efb5ae375e78686ae144d9a7e84e 100644 (file)
@@ -28,8 +28,9 @@ import { getPlayerSkills} from './skills';
 
 import { fightRound, blockPlayerInFight } from './fight';
 
-import  { router as healerRouter } from './locations/healer';
+import { router as healerRouter } from './locations/healer';
 import { router as professionRouter } from './locations/recruiter';
+import { router as repairRouter } from './locations/repair';
 
 import * as Alert from './views/alert';
 import { renderPlayerBar } from './views/player-bar'
@@ -130,6 +131,7 @@ io.on('connection', async socket => {
 
 app.use(healerRouter);
 app.use(professionRouter);
+app.use(repairRouter);
 
 
 app.get('/chat/history', authEndpoint, async (req: AuthRequest, res: Response) => {
index 71e5da0f3d34711c6b1edcca69522e5d4a12f741..3074644501ca3b708bacefd05938b1e515b1ca96 100644 (file)
@@ -94,6 +94,15 @@ export async function updateAp(player_id: string, apDamage: number, itemIds: str
   return itemsToUnequip;
 }
 
+export async function repair(player_id: string, item_id: string) {
+  return db('inventory').where({
+    player_id,
+    item_id
+  }).update({
+      'currentAp': db.raw('"maxAp"')
+    });
+}
+
 export async function deleteInventoryItem(player_id: string, item_id: string) {
   await db('equipped').where({
     player_id,
diff --git a/src/server/locations/repair.ts b/src/server/locations/repair.ts
new file mode 100644 (file)
index 0000000..6bb7423
--- /dev/null
@@ -0,0 +1,70 @@
+import { Response, Router } from "express";
+import { authEndpoint, AuthRequest } from '../auth';
+import { logger } from "../lib/logger";
+import { getService } from "../map";
+import { getInventory, getInventoryItem, repair } from '../inventory';
+import { renderRepairService } from '../views/repair';
+import { repairCost } from "../../shared/inventory";
+import * as Alert from "../views/alert";
+import { updatePlayer } from "../player";
+import { renderPlayerBar } from "../views/player-bar";
+
+export const router = Router();
+
+router.get('/city/services/repair/:location_id', authEndpoint, async(req: AuthRequest, res: Response) => {
+  const service = await getService(parseInt(req.params.location_id));
+
+  if(!service || service.city_id !== req.player.city_id) {
+    logger.log(`Invalid location: [${req.params.location_id}]`);
+    res.sendStatus(400);
+  }
+
+  const equippedItems = await getInventory(req.player.id);
+
+  const damaged = equippedItems.filter(i => {
+    return i.currentAp < i.maxAp
+  });
+
+  res.send(renderRepairService(damaged, req.player, service));
+});
+
+router.post('/city/services/:location_id/repair/:item_id', authEndpoint, async (req: AuthRequest, res: Response) => {
+  const service = await getService(parseInt(req.params.location_id));
+
+  if(!service || service.city_id !== req.player.city_id) {
+    logger.log(`Invalid location: [${req.params.location_id}]`);
+    res.sendStatus(400);
+  }
+
+  const item = await getInventoryItem(req.player.id, req.params.item_id);
+
+  if(!item) {
+    logger.log(`Invalid item [${req.params.item_id}]`);
+    res.sendStatus(400);
+  }
+
+  const cost = repairCost(item);
+
+  if(req.player.gold < cost) {
+    res.status(400).send(Alert.ErrorAlert(`You need at least ${cost}G to repair your ${item.name}`));
+    return;
+  }
+
+  req.player.gold -= cost;
+  item.currentAp = item.maxAp;
+
+  await updatePlayer(req.player);
+  await repair(req.player.id, item.item_id);
+
+  const equippedItems = await getInventory(req.player.id);
+
+  const damaged = equippedItems.filter(i => {
+    return i.currentAp < i.maxAp
+  });
+
+  res.send(
+    renderRepairService(damaged, req.player, service) 
+      + renderPlayerBar(req.player)
+    + Alert.SuccessAlert(`You repaired your ${item.name} for ${cost}G`)
+  );
+});
diff --git a/src/server/views/components/city.ts b/src/server/views/components/city.ts
new file mode 100644 (file)
index 0000000..18eb3e2
--- /dev/null
@@ -0,0 +1,12 @@
+export function Title(str: string): string {
+  return `<div class="city-title-wrapper"><div class="city-title">${str}</div></div>`;
+}
+
+export function Details(name: string, content: string): string {
+  return `
+  <div class="city-details">
+  <h3 class="location-name"><span>${name}</span></h3>
+    ${content}
+  </div>
+  `;
+}
index bef40c33e58e76e44566d80aa2238554a1e45e8e..e21d1fd2e74bbcedc94459ffb968dc20e22102dc 100644 (file)
@@ -113,7 +113,7 @@ function renderInventoryItem(item: EquippedItemDetails , action: (item: Equipped
         ${item.boosts.intelligence ? renderStatBoost('INT', item.boosts.intelligence) : ''}
         ${item.boosts.damage ? renderStatBoost('DMG', item.boosts.damage) : ''}
         ${item.boosts.damage_mitigation ? renderStatBoost('MIT', item.boosts.damage_mitigation.toString())+'%' : ''}
-        ${['WEAPON','SPELL'].includes(item.type) ? '': generateProgressBar(item.currentAp, item.maxAp, '#7be67b')}
+        ${['SPELL'].includes(item.type) ? '': generateProgressBar(item.currentAp, item.maxAp, '#7be67b')}
       </div>
       ${item.hasOwnProperty('id') ? `<div>${item.cost.toLocaleString()}G</div>` : ''}
       </div>
diff --git a/src/server/views/repair.ts b/src/server/views/repair.ts
new file mode 100644 (file)
index 0000000..447dc84
--- /dev/null
@@ -0,0 +1,115 @@
+import { capitalize } from "lodash";
+import { Player } from "../../shared/player";
+import { repairCost } from "../../shared/inventory";
+import { LocationWithCity } from "../../shared/map";
+import { EquippedItemDetails } from "../../shared/equipped";
+import { ProgressBar } from "./components/progress-bar";
+import * as City from './components/city';
+
+function renderStatBoost(name: string, val: number | string): string {
+  let valSign: string = '';
+  if(typeof val === 'number') {
+    valSign = val > 0 ? '+' : '-';
+  }
+  return `<span class="requirement-title">${name}</span>: <span class="requirement-value ${typeof val === 'number' ? (val > 0 ? "success": "error") : ""}">${valSign}${val}</span>`;
+}
+
+function renderRequirement(name: string, val: number | string, currentVal?: number): string {
+  let colorIndicator = '';
+  if(currentVal) {
+    colorIndicator = currentVal >= val ? 'success' : 'error';
+  }
+  return `<span class="requirement-title">${name}</span>: <span class="requirement-value ${colorIndicator}">${val.toLocaleString()}</span>`;
+}
+
+export function renderEquipmentDetails(item: EquippedItemDetails, player: Player): string {
+  return `
+    <div class="details">
+      <div class="name">${item.name}${item.equipment_slot === 'TWO_HANDED' ? ' (2H)': ''}</div>
+      <div class="requirements">
+      ${item.requirements.level ? renderRequirement('LVL', item.requirements.level, player.level): ''}
+      ${item.requirements.strength ? renderRequirement('STR', item.requirements.strength, player.strength): ''}
+      ${item.requirements.constitution ? renderRequirement('CON', item.requirements.constitution, player.constitution): ''}
+      ${item.requirements.dexterity ? renderRequirement('DEX', item.requirements.dexterity, player.dexterity): ''}
+      ${item.requirements.intelligence ? renderRequirement('INT', item.requirements.intelligence, player.intelligence): ''}
+      ${renderRequirement('PRF', item.profession)}
+      </div>
+      <div class="stat-mods">
+      ${item.boosts.defence ? renderStatBoost('DEF', item.boosts.defence) : ''}
+      ${item.boosts.strength ? renderStatBoost('STR', item.boosts.strength) : ''}
+      ${item.boosts.constitution ? renderStatBoost('CON', item.boosts.constitution) : ''}
+      ${item.boosts.dexterity ? renderStatBoost('DEX', item.boosts.dexterity) : ''}
+      ${item.boosts.intelligence ? renderStatBoost('INT', item.boosts.intelligence) : ''}
+      ${item.boosts.damage ? renderStatBoost(item.affectedSkills.includes('restoration_magic') ? 'HP' : 'DMG', item.boosts.damage) : ''}
+      ${item.boosts.damage_mitigation ? renderStatBoost('MIT', item.boosts.damage_mitigation.toString())+'%' : ''}
+      ${['WEAPON','SPELL'].includes(item.type) ? '' :  ProgressBar(item.currentAp, item.maxAp, `${item.item_id}-ap`, {
+displayPercent: false,
+title: 'Durability',
+startingColor: '#7be67b',
+endingColor: '#7be67b'
+})}
+      </div>
+      <div class="store-cost">${repairCost(item).toLocaleString()}G to Repair</div>
+    </div>
+`
+
+}
+
+function renderEquipmentToRepair(item: EquippedItemDetails, action: (item: EquippedItemDetails) => string, player: Player): string {
+    return `<div class="store-list">
+    <div class="store-icon" style="background-image: url('${item.icon ? `/assets/img/icons/equipment/${item.icon}` : 'https://via.placeholder.com/64x64'}')">
+      <div class="store-actions">${action(item)}</div>
+    </div>
+    ${renderEquipmentDetails(item, player)}
+    </div>`;
+}
+
+
+
+export function renderRepairService(equipment: EquippedItemDetails[], player: Player, location: LocationWithCity): string {
+  const listing: Record<string, string> = {};
+  const listingTypes = new Set<string>();
+
+  if(equipment.length === 0) {
+    return `
+      ${City.Title(location.city_name)}
+      ${City.Details(location.name, `You don't have any equipment that needs repairing.`)}
+    `;
+  }
+
+  equipment.forEach(item => {
+    const filter = item.type === 'ARMOUR' ? item.equipment_slot : item.type;
+
+    listingTypes.add(filter);
+    if(!listing[filter]) {
+      listing[filter] = '';
+    }
+
+    listing[filter] += renderEquipmentToRepair(item, i => {
+      return `<button type="button" hx-post="/city/services/${location.id}/repair/${i.item_id}" hx-target="#explore">Repair</button>`
+    }, player);
+
+  });
+
+  let activeTab: string = listingTypes.keys().next().value;
+
+  const nav: string[] = [];
+  const finalListing: string[] = [];
+
+  listingTypes.forEach(type => {
+    nav.push(`<a href="#" data-filter="${type}" class="${activeTab === type ? 'active': ''}">${capitalize(type)}</a>`);
+    finalListing.push(`<div class="filter-result ${activeTab === type ? 'active': 'hidden'}" data-filter="${type}" id="filter_${type}">${listing[type]}</div>`);
+  });
+
+  let html = `
+    ${City.Title(location.city_name)}
+    ${City.Details(location.name, `
+<div class="shop-inventory-listing filter-container">
+    <nav class="filter" id="shop-inventory-listing">${nav.join("")}</nav><div class="inventory-listing listing">
+      ${finalListing.join("\n")}
+    </div>
+  </div>
+`)}`;
+
+  return html;
+}
index 75e1a30230ff77fd38790dcaa62cb702e544ae14..be20cf07aece0951e8b30995835ff756b6f91703 100644 (file)
@@ -1,15 +1,33 @@
 import { ShopEquipment } from "../../shared/inventory";
 import { ShopItem, Item } from "../../shared/items";
-import { capitalize } from "lodash";
+import { capitalize, merge } from "lodash";
 import { Player } from "../../shared/player";
 import { LocationWithCity } from "shared/map";
+import { ProgressBar } from "./components/progress-bar";
 
-function renderStatBoost(name: string, val: number | string): string {
+type RenderStatOptions = {
+  unsigned: boolean
+}
+
+function renderStat(title: string, display: string, val: any, options?: RenderStatOptions): string {
+  const opts = merge({
+    unsigned: false
+  } as RenderStatOptions, options);
+
+  let valSign: string = '';
+  if(typeof val === 'number') {
+    valSign = val > 0 ? '+' : '-';
+  }
+
+  return `<span title="${title}"><span class="requirement-title">${display}</span>: <span class="requirement-value ${opts.unsigned ? '' : (val > 0 ? "success": "error")}">${opts.unsigned ? val : `${valSign}${val}`}</span></span>`;
+}
+
+function renderStatBoost(name: string, val: number | string, title?: string): string {
   let valSign: string = '';
   if(typeof val === 'number') {
     valSign = val > 0 ? '+' : '-';
   }
-  return `<span class="requirement-title">${name}</span>: <span class="requirement-value ${typeof val === 'number' ? (val > 0 ? "success": "error") : ""}">${valSign}${val}</span>`;
+  return `<span class="requirement-title" title="${title}">${name}</span>: <span class="requirement-value ${typeof val === 'number' ? (val > 0 ? "success": "error") : ""}">${valSign}${val}</span>`;
 }
 
 function renderRequirement(name: string, val: number | string, currentVal?: number): string {
@@ -55,7 +73,7 @@ export function renderEquipmentDetails(item: ShopEquipment, player: Player): str
       ${item.boosts.intelligence ? renderStatBoost('INT', item.boosts.intelligence) : ''}
       ${item.boosts.damage ? renderStatBoost(item.affectedSkills.includes('restoration_magic') ? 'HP' : 'DMG', item.boosts.damage) : ''}
       ${item.boosts.damage_mitigation ? renderStatBoost('MIT', item.boosts.damage_mitigation.toString())+'%' : ''}
-      ${['WEAPON','SPELL'].includes(item.type) ? '' : renderStatBoost('AP', item.maxAp.toString())}
+      ${['SPELL'].includes(item.type) ? '' : renderStat('Durability', 'DUR', item.maxAp, { unsigned: true })}
       </div>
       ${item.hasOwnProperty('id') ? `<div class="store-cost">${item.cost.toLocaleString()}G</div>` : ''}
     </div>
index c0b08596eee6208f9fce91f78833579f1c19eb9a..0ed26ddef78e988274df8da97b3832ac253911d3 100644 (file)
@@ -46,3 +46,11 @@ export type ShopEquipment = Omit<InventoryItem, 'id' | 'player_id'> & {
   id: number;
   location_id: number;
 };
+
+export function repairCost(item: InventoryItem): number {
+  const totalCost = item.cost * 0.7;
+
+  const damageRatio = 1 - (item.currentAp / item.maxAp);
+
+  return Math.floor(totalCost * damageRatio);
+}