Merge branch 'queue-sizes'
authorxangelo <git@xangelo.ca>
Mon, 6 Jun 2022 20:31:01 +0000 (16:31 -0400)
committerxangelo <git@xangelo.ca>
Mon, 6 Jun 2022 20:31:01 +0000 (16:31 -0400)
package-lock.json
package.json
public/scifi.css
public/stylesheet.css
src/api.ts
src/errors.ts
src/render/land-development.ts
src/render/unit-training.ts
src/repository/city.ts

index 1ebd7be36c34a7cccd9fbed3a0e26f76b3eec9cb..f3e411be41bf2aa2f507c2008afd29c8c45841ba 100644 (file)
@@ -20,7 +20,8 @@
                                "luxon": "^1.28.0",
                                "socket.io": "^4.5.1",
                                "sqlite3": "^5.0.6",
-                               "uuid": "^8.3.2"
+                               "uuid": "^8.3.2",
+                               "validator": "^13.7.0"
                        },
                        "devDependencies": {
                                "@types/bcrypt": "^5.0.0",
@@ -29,6 +30,7 @@
                                "@types/lodash": "^4.14.182",
                                "@types/luxon": "^2.3.2",
                                "@types/uuid": "^8.3.4",
+                               "@types/validator": "^13.7.2",
                                "nodemon": "^2.0.16",
                                "ts-node": "^10.7.0",
                                "tsconfig-paths": "^4.0.0",
                        "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==",
                        "dev": true
                },
+               "node_modules/@types/validator": {
+                       "version": "13.7.2",
+                       "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.2.tgz",
+                       "integrity": "sha512-KFcchQ3h0OPQgFirBRPZr5F/sVjxZsOrQHedj3zi8AH3Zv/hOLx2OLR4hxR5HcfoU+33n69ZuOfzthKVdMoTiw==",
+                       "dev": true
+               },
                "node_modules/abbrev": {
                        "version": "1.1.1",
                        "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
                        "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
                        "dev": true
                },
+               "node_modules/validator": {
+                       "version": "13.7.0",
+                       "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz",
+                       "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==",
+                       "engines": {
+                               "node": ">= 0.10"
+                       }
+               },
                "node_modules/vary": {
                        "version": "1.1.2",
                        "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
                        "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==",
                        "dev": true
                },
+               "@types/validator": {
+                       "version": "13.7.2",
+                       "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.2.tgz",
+                       "integrity": "sha512-KFcchQ3h0OPQgFirBRPZr5F/sVjxZsOrQHedj3zi8AH3Zv/hOLx2OLR4hxR5HcfoU+33n69ZuOfzthKVdMoTiw==",
+                       "dev": true
+               },
                "abbrev": {
                        "version": "1.1.1",
                        "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
                        "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
                        "dev": true
                },
+               "validator": {
+                       "version": "13.7.0",
+                       "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz",
+                       "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw=="
+               },
                "vary": {
                        "version": "1.1.2",
                        "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
index 98bdd7f39ec12d02f7de23fdbe41fd568550bcf6..4b3db390a35bad54361d4b908ac520fdba3fa966 100644 (file)
@@ -3,10 +3,10 @@
        "private": true,
        "scripts": {
                "dev": "npx nodemon src/api.ts",
-    "migrate": "npx ts-node --",
+               "migrate": "npx ts-node --",
                "setup:rebels": "npx ts-node scripts/generate-cities.ts",
                "setup": "npm run setup:rebels",
-    "migration": "npx ts-node migrations/"
+               "migration": "npx ts-node migrations/"
        },
        "dependencies": {
                "@bull-board/api": "^3.11.0",
@@ -23,7 +23,8 @@
                "luxon": "^1.28.0",
                "socket.io": "^4.5.1",
                "sqlite3": "^5.0.6",
-               "uuid": "^8.3.2"
+               "uuid": "^8.3.2",
+               "validator": "^13.7.0"
        },
        "devDependencies": {
                "@types/bcrypt": "^5.0.0",
@@ -32,6 +33,7 @@
                "@types/lodash": "^4.14.182",
                "@types/luxon": "^2.3.2",
                "@types/uuid": "^8.3.4",
+               "@types/validator": "^13.7.2",
                "nodemon": "^2.0.16",
                "ts-node": "^10.7.0",
                "tsconfig-paths": "^4.0.0",
index 450fddf644601f3d5457272ac2bc7a944416bb92..98c99bd372922779a6c911ff779b07810f4f7b61 100644 (file)
@@ -62,6 +62,7 @@ tr:nth-child(odd) td, tr:nth-child(odd) th {
 th, td {
     padding: 0.5rem;
 }
+l 
 p, form, ul, ol {
     line-height: 1.3rem;
     margin: 0 2rem 2rem 2rem;
@@ -73,6 +74,17 @@ label {
     margin-right: 5px;
 }
 
+a {
+    font-weight: bold;
+    color: #fff;
+    text-decoration: none;
+}
+a::before {
+    content: '\27EA';
+}
+a::after {
+    content: '\27EB';
+}
 button, .btn {
     border: solid 1px var(--border);
     background-color: #183238;
@@ -84,6 +96,11 @@ button, .btn {
     text-align: center;
     font-weight: bold;
     min-width: 150px;
+    display: inline-block;
+}
+a.btn::before {
+  content: '';
+  clear: both;
 }
 button::after, .btn::after {
     content: '\27EB';
@@ -116,6 +133,12 @@ button:active, .btn:active, button:hover, .btn:hover {
 button.success:active, .btn.success:active, button.success:hover, .btn.success:hover {
     background-color: #1e4c1a;
 }
+button.danger:active, .btn.danger:active, button.danger:hover, .btn.danger:hover {
+    background-color: #601f1f;
+}
+a.close::before, a.close::after{
+  content: '';
+}
 
 input[type="text"], input[type="password"], input[type="number"] {
     border: solid 1px var(--border);
@@ -127,17 +150,6 @@ input[type="text"], input[type="password"], input[type="number"] {
     padding: 5px;
 }
 
-a {
-    font-weight: bold;
-    color: #fff;
-    text-decoration: none;
-}
-a::before {
-    content: '\27EA';
-}
-a::after {
-    content: '\27EB';
-}
 footer {
     text-align: center;
     border-top: solid 1px var(--border);
@@ -235,6 +247,13 @@ footer {
 .text-center {
     text-align: center;
 }
+.progress-bar {
+    border: solid 1px #fff;
+    text-align: center;
+    color: #fff;
+    width: 100%;
+    min-width: 100px;
+}
 
 /** CUSTOMIZATIONS **/
 form > div {
index 609d28ea7868673fa0e7d6c457509f4013e1dd37..8d720a3e2eabb7b2f499a29d256c481578dc5bdc 100644 (file)
@@ -6,12 +6,6 @@ table form input {
     margin-bottom: 0;
 }
 
-.progress-bar {
-    border: solid 1px #fff;
-    text-align: center;
-    color: #fff;
-    width: 100%;
-}
 .unread td{
     background-color: #373737;
 }
index 50d4f06d014eeb92b75c7bc8df2a63b665d4795b..66381943a40e55598146862be56975ebd26dbbad 100644 (file)
@@ -1,7 +1,7 @@
 import { HttpServer } from './lib/server';
 import * as config from './config';
 import { AccountRepository } from './repository/accounts';
-import { CityRepository } from './repository/city';
+import { City, CityRepository } from './repository/city';
 import { MailRepository } from './repository/mail';
 import {BadInputError, ERROR_CODE, NotFoundError} from './errors';
 import { renderKingomOverview } from './render/kingdom-overview';
@@ -19,6 +19,7 @@ import { renderCost } from './render/costs';
 import {renderMailroom, renderMessage} from './render/mail';
 import {topbar} from './render/topbar';
 import {renderPublicChatMessage} from './render/chat-message';
+import validator from 'validator';
 
 
 const server = new HttpServer(config.API_PORT);
@@ -379,6 +380,92 @@ server.post<{body: {message: string}}, void>('/chat', async req => {
   return;
 });
 
+server.post<{params: {queueId: string}}, void>('/construction/:queueId/cancel', async req => {
+       const acct = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
+       const city = await cityRepo.getUsersCity(acct.id);
+
+  if(!validator.isUUID(req.params.queueId)) {
+    throw new BadInputError('Invalid queue ID', ERROR_CODE.INVALID_BUILD_QUEUE);
+  }
+
+  // validate that this is an actual queue
+  const queue = await cityRepo.buildQueue.FindOne({owner: city.owner, id: req.params.queueId});
+
+  if(!queue) {
+    throw new NotFoundError('That queue does not exist', ERROR_CODE.INVALID_BUILD_QUEUE);
+  }
+
+  const [, building] = await Promise.all([
+    cityRepo.buildQueue.Delete({
+      owner: city.owner,
+      id: req.params.queueId
+    }),
+    cityRepo.buildingRepository.findBySlug(queue.building_type)
+  ]);
+
+  // now that it's deleted we can give the player back some percentage 
+  // of resources based on how close they are to completion.
+  const diff = (queue.due - Date.now()) / (queue.due - queue.created);
+  // force a 20% loss minimum
+  const finalDiff = diff < 0.2 ? 0.2 : diff;
+
+  const costReturn: Partial<City> = {
+    id: city.id,
+    credits: city.credits + Math.floor(building.credits * queue.amount * diff),
+    alloys: city.alloys + Math.floor(building.alloys * queue.amount * diff),
+    energy: city.energy + Math.floor(building.energy * queue.amount * diff),
+    usedSpace: city.usedSpace - (building.land * queue.amount)
+  };
+
+  console.log('update', costReturn)
+
+  await cityRepo.save(costReturn);
+
+}, 'reload-construction-queue');
+
+server.post<{params: {queueId: string}}, void>('/training/:queueId/cancel', async req => {
+       const acct = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
+       const city = await cityRepo.getUsersCity(acct.id);
+
+  if(!validator.isUUID(req.params.queueId)) {
+    throw new BadInputError('Invalid queue ID', ERROR_CODE.INVALID_BUILD_QUEUE);
+  }
+  
+  const queue = await cityRepo.unitTrainigQueue.FindOne({owner: city.owner, id: req.params.queueId});
+  if(!queue) {
+    throw new NotFoundError('That queue does not exist', ERROR_CODE.INVALID_BUILD_QUEUE);
+  }
+
+  const [, unit] = await Promise.all([
+    cityRepo.unitTrainigQueue.Delete({
+      owner: city.owner,
+      id: req.params.queueId
+    }),
+    cityRepo.unitRepository.FindOne({slug: queue.unit_type})
+  ]);
+
+  // now that it's deleted we can give the player back some percentage 
+  // of resources based on how close they are to completion.
+  const diff = (queue.due - Date.now()) / (queue.due - queue.created);
+  // force a 20% loss minimum
+  const finalDiff = diff < 0.2 ? 0.2 : diff;
+
+  const costReturn: Partial<City> = {
+    id: city.id,
+    credits: city.credits + Math.floor(unit.credits * queue.amount * diff),
+    food: city.food + Math.floor(unit.food * queue.amount * diff),
+    population: city.population + Math.floor(unit.population * queue.amount),
+    soldiers: city.soldiers + Math.floor(unit.soldiers * queue.amount),
+    attackers: city.attackers + Math.floor(unit.attackers * queue.amount),
+    defenders: city.defenders + Math.floor(unit.attackers * queue.amount)
+  };
+
+  console.log('update', costReturn)
+
+  await cityRepo.save(costReturn);
+
+}, 'reload-unit-training');
+
 server.get<void, string>('/server-stats', async req => {
   const date = new Date();
   return `
index e245d9bb07f9c3437057764b8e0a196f6977c5a6..217008cd68ab73aeada74e3b3c6e670c81852b37 100644 (file)
@@ -47,6 +47,7 @@ export const ERROR_CODE = {
        NO_CITY: 2000,
        INSUFFICIENT_RESOURCE: 3000,
        INVALID_BUILDING: 4000,
+  INVALID_BUILD_QUEUE: 4100,
   INVALID_AMOUNT: 6000,
        INVALID_UNIT: 5000,
        DUPLICATE_CACHE_KEY: 900,
index 9cd4eb7418c2f601412311bcd19176a168f4b767..d0b5e7f1157460eba318713ce9430a6475e93fb1 100644 (file)
@@ -3,7 +3,16 @@ import { CityWithLocation } from "../repository/city";
 import { Building } from '../repository/buildings';
 import _ from "lodash";
 
-function progressBar(current, max): string {
+const emptyBuilding: BuildQueue = {
+  id: '',
+  owner: '',
+  amount: 0,
+  created: 0,
+  due: 0,
+  building_type: 'empty',
+};
+
+function progressBar(current: number, max: number): string {
     const percent = Math.ceil((current/max) * 100);
     return `
     <div class="progress-bar construction" style="background: background: var(--green-bg);
@@ -15,6 +24,10 @@ function progressBar(current, max): string {
 
 export function renderLandDevelopment(city: CityWithLocation, buildings: Building[], buildQueues: BuildQueue[]): string {
     const freeSpace = city.totalSpace - city.usedSpace;
+    const sortedBuildQueues = buildQueues.sort((a, b) => {
+        return a.due - b.due;
+    });
+
     let html = `
     <div hx-trigger="reload-construction-queue, every 600s" hx-get="/poll/construction">
     <h2 data-augmented-ui="tl-clip bl-clip none">Construction</h2>
@@ -47,19 +60,33 @@ export function renderLandDevelopment(city: CityWithLocation, buildings: Buildin
     `;
 
     const quickFindBuilding = _.keyBy(buildings, 'slug');
+    const finalBuildQueues = sortedBuildQueues;
+
+    if (finalBuildQueues.length < city.max_construction_queue) {
+      while(finalBuildQueues.length < city.max_construction_queue) {
+        finalBuildQueues.push(emptyBuilding);
+      }
+    }
+
 
     const queues = `
     <hr>
     <h2 data-augmented-ui="tl-clip bl-clip none">Build Queues</h2>
     <table>
     <tr>
-    <th>Building</th>
-    <th>Amount Expected</th>
-    <th>Progress</th>
+    <th align="left">Building</th>
+    <th align="left">Amount Expected</th>
+    <th colspan="2">Progress</th>
     </tr>
-    ${buildQueues.sort((a, b) => {
-        return a.due - b.due;
-    }).map(queue => {
+    ${sortedBuildQueues.map(queue => {
+      if(queue.building_type === 'empty') {
+        return `
+        <tr>
+        <td colspan="4">You have sufficient capacity to build something.</td>
+        </tr>
+        `
+      }
+      else {
         const now = Date.now() - queue.created;
         const duration = queue.due - queue.created;
 
@@ -68,8 +95,12 @@ export function renderLandDevelopment(city: CityWithLocation, buildings: Buildin
         <td>${quickFindBuilding[queue.building_type].display}</td>
         <td>${queue.amount}</td>
         <td>${progressBar(now, duration)}</td>
+        <td width="50">
+        <a href="#" hx-post="/construction/${queue.id}/cancel" hx-trigger="click" class="danger-text close" title="Cancel Construction">&times;</a>
+        </td>
         </tr>
         `;
+      }
     }).join("\n")}
     </table>
     </div>
index 5742d9453bf4704df05134fe07f70100e2f8a909..9e64c9c454337b1823c4b4794bd961a8399fd200 100644 (file)
@@ -4,6 +4,16 @@ import { UnitTrainingQueueWithName } from "../repository/training-queue";
 import { Unit } from "../repository/unit";
 import { DateTime } from "luxon";
 
+const emptyQueue: UnitTrainingQueueWithName = {
+  display: '',
+  id: '',
+  owner: '',
+  amount: 0,
+  created: 0,
+  due: 0,
+  unit_type: 'empty'
+}
+
 function progressBar(current: number, max: number): string {
     const percent = Math.ceil((current/max) * 100);
     return `
@@ -16,6 +26,9 @@ function progressBar(current: number, max: number): string {
 
 export function renderUnitTraining(city: CityWithLocation, units: Unit[], trainingQueues: UnitTrainingQueueWithName[]): string {
     const unit = _.keyBy(units, 'slug');
+    const sortedTrainingQueue = trainingQueues.sort((a, b) => {
+        return a.due - b.due;
+    });
     let html = `
     <div hx-trigger="reload-unit-training, every 600s" hx-get="/poll/unit-training">
     <h2 data-augmented-ui="tl-clip bl-clip none">Unit Training</h2>
@@ -90,34 +103,45 @@ export function renderUnitTraining(city: CityWithLocation, units: Unit[], traini
     </table>
     `;
 
+    const finalTrainingQueue = sortedTrainingQueue;
+    if(finalTrainingQueue.length < city.max_training_queue) {
+      while(finalTrainingQueue.length < city.max_construction_queue) {
+        finalTrainingQueue.push(emptyQueue);
+      }
+    }
+
     const queues = `
     <hr>
     <h2 data-augmented-ui="tl-clip bl-clip none">Training Queues</h2>
     <table>
     <tr>
-    <th>Unit Type</th>
-    <th>Amount Expected</th>
-    <th>Progress</th>
+    <th align="left">Unit Type</th>
+    <th align="left">Amount Expected</th>
+    <th colspan="2">Progress</th>
     </tr>
-    ${trainingQueues.sort((a, b) => {
-        return a.due - b.due;
-    }).map(queue => {
-      const created = DateTime.fromMillis(queue.created);
-      const due = DateTime.fromMillis(queue.due);
-      const now = Date.now() - queue.created;
-      const duration = queue.due - queue.created;
+    ${sortedTrainingQueue.map(queue => {
+      if(queue.unit_type === 'empty') {
+        return `<tr>
+        <td colspan="4">You have sufficient queue capacity to train more units.</td>
+        </tr>`;
+      }
+      else {
+        const created = DateTime.fromMillis(queue.created);
+        const due = DateTime.fromMillis(queue.due);
+        const now = Date.now() - queue.created;
+        const duration = queue.due - queue.created;
 
-      return `
-      <tr>
-      <td>${queue.display}</td>
-      <td>${queue.amount}</td>
-      <td>
-      <span title="${Math.ceil(due.diff(created).as('minutes')).toLocaleString()} minutes remaining">
-      ${progressBar(now, duration)}<br>
-      </span>
-      </td>
-      </tr>
-      `;
+        return `
+        <tr>
+        <td>${queue.display}</td>
+        <td>${queue.amount}</td>
+        <td>${progressBar(now, duration)}</td>
+        <td width="50">
+        <a href="#" hx-post="/training/${queue.id}/cancel" hx-trigger="click" class="danger-text close" title="Cancel Unit Training">&times;</a>
+        </td>
+        </tr>
+        `;
+      }
     }).join("\n")}
     </table>
     </div>
index 272ee3b960a410c018d07cc723b5620f8922da3c..c9ecb91b0aa358d96c9de6d4487087b328cd2670 100644 (file)
@@ -134,7 +134,10 @@ export class CityRepository extends Repository<City> {
         return sample.sector_id;
     }
 
-    async save(city: City) {
+    async save(city: Partial<City>) {
+      if(!city.id) {
+        throw new Error('Unknown city to save');
+      }
       const fieldsToSave = [
         'totalSpace', 'usedSpace', 'credits', 'alloys', 'energy', 'food',
         'poulation', 'soldiers', 'attackers', 'defenders', 'sp_attackers', 'sp_defenders',
@@ -206,7 +209,7 @@ where l.sector_id = ?`, [sector_id]);
         // validate that they have enough empty construction queues
         const concurrentConstruction = await this.buildQueue.list(city.owner);
         if(concurrentConstruction.length >= city.max_construction_queue) {
-          throw new InsufficientResourceError('Training queues', concurrentConstruction.length + 1, city.max_construction_queue);
+          throw new InsufficientResourceError('Construction queues', concurrentConstruction.length + 1, city.max_construction_queue);
         }
 
         city.usedSpace += (building.land * amount);