add chat commands
[browser-rts.git] / src / api.ts
index 75fa346f9b26c7c5a6327646c204a00c93479423..e1930c11bda57d50a3c4882c3cc58a00185df4d9 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';
@@ -11,18 +11,29 @@ import { construction } from './tasks/construction';
 import { unitTraining } from './tasks/unit-training';
 import { fight } from './tasks/fight';
 import { renderUnitTraining } from './render/unit-training';
-import { launchOffensive, listOperations, renderOverworldMap } from './render/fight';
+import { launchOffensive, listOperations, renderOverworldMap } from './render/map';
 import { createBullBoard } from '@bull-board/api';
 import { BullAdapter } from '@bull-board/api/bullAdapter';
 import _ from 'lodash';
-import { renderMailroom, renderMessage } from './render/mail';
+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';
+import {Socket} from 'socket.io';
 
-const server = new HttpServer(config.API_PORT);
 
+const server = new HttpServer(config.API_PORT);
 const accountRepo = new AccountRepository();
 const cityRepo = new CityRepository();
 const mailRepo = new MailRepository();
 
+const cache: Record<string, any> = {
+  online_users: []
+};
+
+const msgBuffer: string[] = [];
+
 createBullBoard({
        queues: [
                new BullAdapter(tick.queue),
@@ -47,7 +58,7 @@ server.post<{
        // lets create the city!
        await cityRepo.create(acct.id);
 
-       return `<p>You are all signed up! You can go ahead and log in</p>`;
+       return `<div class="alert success">You are all signed up! You can go ahead and log in</div>`;
 });
 
 server.post<{body: {username: string, password: string}}, void>('/login', async (req, raw, res) => {
@@ -64,33 +75,6 @@ server.post<{body: {username: string, password: string}}, void>('/login', async
 
 });
 
-server.get<{}, string>('/city', async req => {
-       const account = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
-       const city = await cityRepo.FindOne({ owner: account.id });
-
-
-       const buildQueues = await cityRepo.getBuildQueues(account.id);
-       const unitTrainingQueues = await cityRepo.getUnitTrainingQueues(account.id);
-
-       const buildings = await cityRepo.buildingRepository.list();
-       const units = await cityRepo.unitRepository.list();
-
-       let html = `
-       <h2>Kingom Overview</h2>
-       ${renderKingomOverview(city, account)}
-       <hr>
-       <h2>Land Development</h2>
-       ${renderLandDevelopment(city, buildings, buildQueues)}
-       <h2>Unit Training</h2>
-       ${renderUnitTraining(city, units, unitTrainingQueues)},
-       <h2>Map</h2>
-       ${renderOverworldMap(await cityRepo.FindAll(), city)}
-       <h2>Mail</h2>
-       ${renderMailroom(await mailRepo.listReceivedMessages(account.id))}
-       `;
-       return html;
-});
-
 server.post<{body: {
        soldiers: number,
        attackers: number,
@@ -106,8 +90,8 @@ server.post<{body: {
 
 server.get<{params: { cityId: string }}, string>('/city/:cityId', async req => {
        const account = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
-       const yourCity = await cityRepo.FindOne({ owner: account.id });
-       const city = await cityRepo.FindOne({ id: req.params.cityId });
+       const yourCity = await cityRepo.getUsersCity(account.id);
+       const city = await cityRepo.findById(req.params.cityId);
        const acct = await accountRepo.FindOne({id: city.owner});
 
 
@@ -120,28 +104,103 @@ server.get<{params: { cityId: string }}, string>('/city/:cityId', async req => {
 
 server.get<{}, string>('/poll/overview', async req => {
        const account = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
-       const city = await cityRepo.FindOne({ owner: account.id });
+       const city = await cityRepo.getUsersCity(account.id);
+  const unreadMail = await mailRepo.countUnread(account.id);
 
-       return renderKingomOverview(city, account);
+
+  const usage = {
+    foodUsagePerTick: await cityRepo.foodUsagePerTick(city),
+    foodProductionPerTick: await cityRepo.foodProductionPerTick(city),
+    energyUsagePerTick: await cityRepo.energyUsagePerTick(city),
+    energyProductionPerTick: await cityRepo.energyProductionPerTick(city)
+  }
+
+       return renderKingomOverview({
+    ...city,
+    ...usage
+  }, account) + topbar({...city, ...usage}, unreadMail);
 });
 
-server.get<{}, string>('/queue/construction', async req => {
+server.get<{}, string>('/poll/construction', async req => {
        const account = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
-       const city = await cityRepo.FindOne({ owner: account.id });
+       const city = await cityRepo.getUsersCity(account.id);
        const buildings = await cityRepo.buildingRepository.list();
+  const unreadMail = await mailRepo.countUnread(account.id);
 
        const buildQueues = await cityRepo.getBuildQueues(account.id);
-       return renderLandDevelopment(city, buildings, buildQueues);
+  const usage = {
+    foodUsagePerTick: await cityRepo.foodUsagePerTick(city),
+    foodProductionPerTick: await cityRepo.foodProductionPerTick(city),
+    energyUsagePerTick: await cityRepo.energyUsagePerTick(city),
+    energyProductionPerTick: await cityRepo.energyProductionPerTick(city)
+  }
+       return renderLandDevelopment(city, buildings, buildQueues) + topbar({...city, ...usage}, unreadMail);
 });
 
-server.get<{}, string>('/queue/units', async req => {
+server.get<{}, string>('/poll/unit-training', async req => {
        const account = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
-       const city = await cityRepo.FindOne({ owner: account.id });
+       const city = await cityRepo.getUsersCity(account.id);
+  const unreadMail = await mailRepo.countUnread(account.id);
 
        const unitTrainingQueues = await cityRepo.getUnitTrainingQueues(account.id);
        const units = await cityRepo.unitRepository.list();
+  const usage = {
+    foodUsagePerTick: await cityRepo.foodUsagePerTick(city),
+    foodProductionPerTick: await cityRepo.foodProductionPerTick(city),
+    energyUsagePerTick: await cityRepo.energyUsagePerTick(city),
+    energyProductionPerTick: await cityRepo.energyProductionPerTick(city)
+  }
+
+       return renderUnitTraining(city, units, unitTrainingQueues) + topbar({
+    ...city,
+    ...usage
+  }, unreadMail);
+});
 
-       return renderUnitTraining(city, units, unitTrainingQueues);
+server.post<{body: {sector: string}}, string>('/poll/map', async req => {
+       const account = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
+       const city = await cityRepo.getUsersCity(account.id);
+  const unreadMail = await mailRepo.countUnread(account.id);
+
+  let sector = city.sector_id;
+  if(req.body.sector) {
+    try {
+      sector = parseInt(req.body.sector);
+    }
+    catch(e) {
+      sector = city.sector_id;
+    }
+  }
+
+  const usage = {
+    foodUsagePerTick: await cityRepo.foodUsagePerTick(city),
+    foodProductionPerTick: await cityRepo.foodProductionPerTick(city),
+    energyUsagePerTick: await cityRepo.energyUsagePerTick(city),
+    energyProductionPerTick: await cityRepo.energyProductionPerTick(city)
+  }
+
+       return renderOverworldMap(await cityRepo.findAllInSector(sector), city, sector) + topbar({
+    ...city,
+    ...usage
+  }, unreadMail);
+});
+
+server.get<{}, string>('/poll/mailroom', async req => {
+       const account = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
+       const city = await cityRepo.getUsersCity(account.id);
+  const unreadMail = await mailRepo.countUnread(account.id);
+
+  const usage = {
+    foodUsagePerTick: await cityRepo.foodUsagePerTick(city),
+    foodProductionPerTick: await cityRepo.foodProductionPerTick(city),
+    energyUsagePerTick: await cityRepo.energyUsagePerTick(city),
+    energyProductionPerTick: await cityRepo.energyProductionPerTick(city)
+  }
+
+       return renderMailroom(await mailRepo.listReceivedMessages(account.id)) + topbar({
+    ...city,
+    ...usage
+  }, unreadMail);
 });
 
 
@@ -151,20 +210,29 @@ server.post<{
                building_type: string
        }
 }, string>('/cost/construction', async req => {
-       const amount = parseInt(req.body.amount, 10);
+       const account = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
+       const city = await cityRepo.getUsersCity(account.id);
+
+       const amount = parseInt(req.body.amount.trim(), 10);
+
+       if(isNaN(amount) || amount < 1) {
+               return '';
+       }
        const building = await cityRepo.buildingRepository.findBySlug(req.body.building_type);
 
        if(!building) {
                throw new NotFoundError(`Invalid building type ${req.body.building_type}`, ERROR_CODE.INVALID_BUILDING);
        }
 
-       return JSON.stringify({
-               gold: building.gold * amount,
-               ore: building.ore * amount,
-               logs: building.logs * amount,
+  const cost = {
+               credits: building.credits * amount,
+               alloys: building.alloys * amount,
+               energy: building.energy * amount,
                land: building.land * amount,
                time: building.time
-       });
+  };
+
+  return renderCost(cost, city);
 });
 
 server.post<{
@@ -173,21 +241,28 @@ server.post<{
                type: string;
        }
 }, string>('/cost/training', async req => {
+       const account = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
+       const city = await cityRepo.getUsersCity(account.id);
        const amount = parseInt(req.body.amount, 10);
-       const unit = await cityRepo.unitRepository.findBySlug(req.body.type);
 
+       if(isNaN(amount) || amount < 1) {
+               return '';
+       }
+
+       const unit = await cityRepo.unitRepository.findBySlug(req.body.type);
        if(!unit) {
                throw new NotFoundError(`Invalid unit type ${req.body.type}`, ERROR_CODE.INVALID_UNIT);
        }
 
-       return JSON.stringify({
+       return renderCost({
                population: unit.population * amount,
                soldiers: unit.soldiers * amount,
                attackers: unit.attackers * amount,
                defenders: unit.defenders * amount,
-               gold: unit.gold * amount,
-               bushels: unit.bushels * amount
-       });
+               credits: unit.credits * amount,
+               food: unit.food * amount,
+    time: unit.time * amount
+       }, city);
 });
 
 server.post<{
@@ -196,19 +271,22 @@ server.post<{
                building_type: string,
        }
 }, void>('/build', async req => {
-       const account = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
-       const city = await cityRepo.getUsersCity(account.id);
+  const account = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
+  const city = await cityRepo.getUsersCity(account.id);
 
-       const amount = parseInt(req.body.amount, 10);
-       const building = await cityRepo.buildingRepository.findBySlug(req.body.building_type);
+  const amount = parseInt(req.body.amount, 10);
+  if(amount < 1) {
+    throw new BadInputError('Please specify an amount > 0', ERROR_CODE.INVALID_AMOUNT);
+  }
+  const building = await cityRepo.buildingRepository.findBySlug(req.body.building_type);
 
-       if(!building) {
-               throw new NotFoundError(`Invalid building type ${req.body.building_type}`, ERROR_CODE.INVALID_BUILDING);
-       }
+  if(!building) {
+    throw new NotFoundError(`Invalid building type ${req.body.building_type}`, ERROR_CODE.INVALID_BUILDING);
+  }
 
-       const queueData = await cityRepo.buildBuilding(building, amount ,city);
+  const queueData = await cityRepo.buildBuilding(building, amount, city);
 
-       construction.trigger(queueData, { delay: queueData.due });
+       construction.trigger(queueData, { delay: queueData.due - Date.now() });
 }, 'reload-construction-queue');
 
 server.post<{
@@ -222,7 +300,11 @@ server.post<{
        const acct = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
        const city = await cityRepo.getUsersCity(acct.id);
 
-       const amount  = parseInt(req.body.amount, 10);
+       const amount  = parseInt(req.body.amount, 10) || 0;
+  console.log('request amount?!', amount);
+  if(amount < 1) {
+    throw new BadInputError('Please specify an amount > 0', ERROR_CODE.INVALID_AMOUNT);
+  }
        const unit = await cityRepo.unitRepository.findBySlug(req.body.type);
 
        if(!unit) {
@@ -230,7 +312,7 @@ server.post<{
        }
 
        const queueData = await cityRepo.train(unit, amount, city);
-       unitTraining.trigger(queueData, { delay: queueData.due });
+       unitTraining.trigger(queueData, { delay: queueData.due - Date.now() });
 
 }, 'reload-unit-training');
 
@@ -248,7 +330,7 @@ server.post<{
        >('/attack', async req => {
                const acct = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
                const city = await cityRepo.getUsersCity(acct.id);
-               const attackedCity = await cityRepo.FindOne({id: req.body.city});
+               const attackedCity = await cityRepo.findById(req.body.city);
 
                const army = {
                        soldiers: parseInt(req.body.soldiers),
@@ -265,24 +347,29 @@ server.post<{
                });
        }, 'reload-outgoing-attacks');
 
-server.get<void, string>('/messages', async req => {
-       const acct = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
-       const msgs = await mailRepo.listReceivedMessages(acct.id);
-
-       return JSON.stringify(msgs);
-});
-
 server.get<{params: {id: string}}, string>('/messages/:id', async req => {
        const acct = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
+  const city = await cityRepo.getUsersCity(acct.id);
        const msg = await mailRepo.getMessage(req.params.id, acct.id);
 
        if(!msg) {
                throw new NotFoundError('No such message', ERROR_CODE.DUPLICATE_CACHE_KEY);
        }
 
+  const usage = {
+    foodUsagePerTick: await cityRepo.foodUsagePerTick(city),
+    foodProductionPerTick: await cityRepo.foodProductionPerTick(city),
+    energyUsagePerTick: await cityRepo.energyUsagePerTick(city),
+    energyProductionPerTick: await cityRepo.energyProductionPerTick(city)
+  }
+
        await mailRepo.markAsRead(msg.id, msg.to_account);
+  const unreadMail = await mailRepo.countUnread(acct.id);
 
-       return renderMessage(msg);
+       return renderMailroom(await mailRepo.listReceivedMessages(acct.id), msg) + topbar({
+    ...city,
+    ...usage
+  }, unreadMail);
 });
 
 server.get<void, string>('/attacks/outgoing', async req => {
@@ -293,7 +380,197 @@ server.get<void, string>('/attacks/outgoing', async req => {
        return listOperations(attacks);
 });
 
+server.post<{body: {message: string}}, void>('/chat', async req => {
+  const acct = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
+  const now = Date.now();
+
+  if(!_.isEmpty(req.body.message)) {
+    // is this a command message?!
+    console.log(req.body.message);
+    if(req.body.message[0] === '/') {
+      console.log('Received command message from:', acct.username);
+      // this is a command message, don't record it in chat 
+      // history, and only send the response to the single person who sent
+      let socket = server.getSocketFromAuthenticatedUser(req.authInfo);
+      if(!socket) {
+        return;
+      }
+      if(req.body.message === '/online') {
+        socket.emit('/chat-message', renderPublicChatMessage(
+          'Server',
+          `Online Users: ${cache.online_users.join(', ')}`
+        ));
+      }
+      else if(req.body.message.indexOf('/give') === 0 && acct.username === 'xangelo') {
+        // expects message in format /give [username] [amount] [resource]
+        const [command, username, amount, resource] = req.body.message.split(' ');
+        const user = await accountRepo.FindOne({username: username});
+        const city = await cityRepo.getUsersCity(user.id);
+
+        console.log('giving!', req.body.message.split(' '));
+
+        await cityRepo.save({
+          id: city.id,
+          [resource]: city[resource] + parseInt(amount)
+        });
+
+        socket.emit('/chat-message', renderPublicChatMessage(
+          'Server',
+          `Gave ${user.username} ${amount} ${resource} ${city[resource]}=>${city[resource] + parseInt(amount)}`
+        ));
+      }
+      else if(req.body.message.indexOf('/assume') === 0 && acct.username === 'xangelo') {
+        // expects /assume [username]
+        const [command, username] = req.body.message.split(' ');
+        const user = await accountRepo.FindOne({username: username});
+        const session = await accountRepo.session.FindOne({account_id: user.id});
+
+        socket.emit('/chat-message', renderPublicChatMessage(
+          'Server',
+          `Login Link: /game.html?token=${session.id}&id=${session.account_id}`
+        ));
+      }
+      else {
+        console.log(req.body.message.indexOf('/give'), acct.username);
+        socket.emit('/chat-message', renderPublicChatMessage(
+          'Server',
+          `The command ${req.body.message} is not valid`
+        ));
+      }
+    }
+    else {
+      const msg = renderPublicChatMessage(acct.username, req.body.message);
+      server.ws.emit('/chat-message', msg);
+      msgBuffer.unshift(msg);
+      while(msgBuffer.length > 30) {
+        msgBuffer.pop();
+      }
+    }
+  }
+
+  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();
+  const min = date.getMinutes();
+  return `
+  <div class="text-right">
+    <span class="success-text">${server.ws.engine.clientsCount} Online</span><br>
+    <span>
+    Server Time: ${date.getHours()}:${min < 10 ? '0'+min : min}
+    </span>
+  </div>`;
+});
+
+server.ws.on('connection', async socket => {
+  const auth = server.authFromUrl(socket.request.headers['referer']);
+  const acct = await accountRepo.validate(auth.authInfo.accountId, auth.authInfo.token);
+
+  server.ws.emit('/chat-message', msgBuffer.join("\n"));
+
+  server.ws.emit('/chat-message', renderPublicChatMessage('Server', `${acct.username} logged in`));
+
+  cache.online_users.push(acct.username);
+
+  socket.on('disconnect', () => {
+    cache.online_users.splice(cache.online_users.indexOf(acct.username), 1);
+  });
+});
 
 server.start();
 
-tick.trigger({lastTickAt: 0, lastTick: 0});
\ No newline at end of file
+tick.trigger({
+  lastTickAt: 0,
+  lastTick: 0
+}, {
+  repeat: {
+    cron: '0 * * * *'
+  }
+});