chore(release): 0.4.0
[risinglegends.git] / src / server / api.ts
1 import * as otel from './tracing';
2 import { version } from "../../package.json";
3 import { config as dotenv } from 'dotenv';
4 import { join } from 'path';
5 import express, {Request, Response} from 'express';
6 import bodyParser from 'body-parser';
7 import xss from 'xss';
8 import { rateLimit } from 'express-rate-limit';
9
10 import http from 'http';
11 import { Server, Socket } from 'socket.io';
12 import * as CONSTANT from '../shared/constants';
13 import { logger } from './lib/logger';
14 import { loadPlayer, createPlayer, updatePlayer, movePlayer } from './player';
15 import { random, sample } from 'lodash';
16 import {broadcastMessage, Message} from '../shared/message';
17 import {maxHp, maxVigor, Player} from '../shared/player';
18 import {createFight, getMonsterList, getMonsterLocation, getRandomMonster, loadMonster, loadMonsterFromFight, loadMonsterWithFaction} from './monster';
19 import {addInventoryItem, getEquippedItems, getInventory, getInventoryItem} from './inventory';
20 import { getItemFromPlayer, getItemFromShop, getPlayersItems, getShopItems, givePlayerItem, updateItemCount } from './items';
21 import {FightTrigger, Monster} from '../shared/monsters';
22 import {getShopEquipment, listShopItems } from './shopEquipment';
23 import {EquipmentSlot} from '../shared/inventory';
24 import { clearTravelPlan, completeTravel, getAllPaths, getAllServices, getCityDetails, getService, getTravelPlan, stepForward, travel, getDungeon } from './map';
25 import { signup, login, authEndpoint } from './auth';
26 import {db} from './lib/db';
27 import { getPlayerSkills} from './skills';
28 import { handleChatCommands } from './chat-commands';
29
30 import { fightRound, blockPlayerInFight } from './fight';
31
32 import { router as healerRouter } from './locations/healer';
33 import { router as professionRouter } from './locations/recruiter';
34 import { router as repairRouter } from './locations/repair';
35 import { router as dungeonRouter } from './locations/dungeon';
36
37 import * as Alert from './views/alert';
38 import { ExplorePane } from './views/components/explore-pane';
39 import { renderPlayerBar } from './views/player-bar'
40 import { renderEquipmentDetails, renderStore } from './views/stores';
41 import { renderMap } from './views/map';
42 import { renderProfilePage } from './views/profile';
43 import { renderSkills } from './views/skills';
44 import { renderInventoryPage } from './views/inventory';
45 import { renderMonsterSelector, renderOnlyMonsterSelector } from './views/monster-selector';
46 import { renderFight, renderFightPreRound, renderRoundDetails } from './views/fight';
47 import { renderTravel, travelButton } from './views/travel';
48 import { renderChatMessage } from './views/chat';
49
50 // TEMP!
51 import { Item, PlayerItem, ShopItem } from 'shared/items';
52 import { equip, unequip } from './equipment';
53 import { HealthPotionSmall } from '../shared/items/health_potion';
54 import { completeDungeonFight, getActiveDungeon, getRoomVists, loadRoom, blockPlayerInDungeon } from './dungeon';
55 import { renderDungeon, renderDungeonRoom } from './views/dungeons/room';
56 import { flushBuffer, addEvent } from './events';
57
58 dotenv();
59
60 otel.s();
61
62 flushBuffer();
63 const app = express();
64 const server = http.createServer(app);
65
66 app.use(express.static(join(__dirname, '..', '..', 'public')));
67 app.use(bodyParser.urlencoded({ extended: true }));
68 app.use(express.json());
69
70 const io = new Server(server);
71
72 const cache = new Map<string, any>();
73 const chatHistory: Message[] = [];
74
75 app.use((req, res, next) => {
76   console.log(req.method, req.url);
77   next();
78 });
79
80 const fightRateLimiter = rateLimit({
81   windowMs: parseInt(process.env.RATE_LIMIT_WINDOW || '30000'),
82   max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '20'),
83   standardHeaders: true,
84   legacyHeaders: false,
85   handler: (req, res, next, options) => {
86     logger.log(`Blocked request: [${req.headers['x-authtoken']}: ${req.method} ${req.path}]`);
87     res.status(options.statusCode).send(options.message);
88   }
89 });
90
91 async function bootstrapSocket(socket: Socket, player: Player) {
92   // ref to get the socket id for a particular player
93   cache.set(`socket:${player.id}`, socket.id);
94   // ref to get the player object
95   cache.set(`token:${player.id}`, player);
96   cache.set(`socket-lookup:${socket.id}`, {
97     id: player.id,
98     username: player.username
99   });
100
101   socket.emit('authToken', player.id);
102
103   socket.emit('chat', renderChatMessage(broadcastMessage('server', `${player.username} just logged in`)));
104 }
105
106 function uniqueConnectedUsers(): Set<string> {
107   const users = new Set<string>();
108
109   io.sockets.sockets.forEach((socket) => {
110     users.add(cache.get(`socket-lookup:${socket.id}`).username);
111   });
112
113   return users;
114 }
115
116 io.on('connection', async socket => {
117   logger.log(`socket ${socket.id} connected, authToken: ${socket.handshake.headers['x-authtoken']}`);
118
119   let authToken = socket.handshake.headers['x-authtoken'].toString() === 'null' ? null : socket.handshake.headers['x-authtoken'].toString();
120
121   let player: Player;
122   if(authToken) {
123     logger.log(`Attempting to load player with id ${authToken}`);
124     player = await loadPlayer(authToken);
125   }
126   if(!player) {
127     logger.log(`Creating player`);
128     player = await createPlayer();
129     authToken = player.id;
130     socket.handshake.headers['x-authtoken'] = authToken;
131   }
132
133   logger.log(`Socket [${socket.id}] auth token: ${player.id}`);
134
135   bootstrapSocket(socket, player);
136
137   socket.on('disconnect', () => {
138     console.log(`Player ${player.username} left`);
139     cache.delete(`socket-lookup:${socket.id}`);
140
141     io.emit('status', `${uniqueConnectedUsers().size} Online (v${version})`);
142   });
143
144
145   io.emit('status', `${uniqueConnectedUsers().size} Online (v${version})`);
146   // this is a special event to let the client know it can start 
147   // requesting data
148   socket.emit('ready');
149
150   addEvent('LOGIN', player.id);
151 });
152
153
154 app.use(healerRouter);
155 app.use(professionRouter);
156 app.use(repairRouter);
157 app.use(dungeonRouter);
158
159
160 app.get('/chat/history', authEndpoint, async (req: Request, res: Response) => {
161   let html = chatHistory.map(renderChatMessage);
162
163   res.send(html.join("\n"));
164 });
165
166 app.post('/chat', authEndpoint, async (req: Request, res: Response) => {
167   const msg = req.body.message.trim();
168
169   if(!msg || !msg.length) {
170     res.sendStatus(204);
171     return;
172   }
173
174   if(msg.startsWith('/server') && req.player.permissions.includes('admin')) {
175     const sender = io.sockets.sockets.get(cache.get(`socket:${req.player.id}`));
176     try {
177       await handleChatCommands(msg, req.player, io, sender);
178     }
179     catch(e) {
180       sender.emit('chat', renderChatMessage(broadcastMessage('server', e.message)));
181     }
182   }
183   else if(msg === '/online') {
184     const users = Array.from(uniqueConnectedUsers().values());
185     // send to specific user
186     const message = broadcastMessage('server', `Online Players: [${users.join(", ")}]`);
187     io.to(cache.get(`socket:${req.player.id}`)).emit('chat', renderChatMessage(message));
188     res.sendStatus(204);
189   }
190   else {
191     const message = broadcastMessage(req.player.username, xss(msg, {
192       whiteList: {}
193     }));
194     chatHistory.push(message);
195     chatHistory.slice(-10);
196     io.emit('chat', renderChatMessage(message));
197   }
198
199   res.sendStatus(204);
200 });
201
202 app.get('/player', authEndpoint, async (req: Request, res: Response) => {
203   const equipment = await getEquippedItems(req.player.id);
204   res.send(renderPlayerBar(req.player) + renderProfilePage(req.player, equipment));
205 });
206
207 app.post('/player/stat/:stat', authEndpoint, async (req: Request, res: Response) => {
208   const equipment = await getEquippedItems(req.player.id);
209   const stat = req.params.stat;
210   if(!['strength', 'constitution', 'dexterity', 'intelligence'].includes(stat)) {
211     res.send(Alert.ErrorAlert(`Sorry, that's not a valid stat to increase`));
212     return;
213   }
214
215   if(req.player.stat_points <= 0) {
216     res.send(Alert.ErrorAlert(`Sorry, you don't have enough stat points`));
217     return;
218   }
219
220   req.player.stat_points -= 1;
221   req.player[stat]++;
222
223   req.player.hp = maxHp(req.player.constitution, req.player.level);
224   req.player.vigor = maxVigor(req.player.constitution, req.player.level);
225   updatePlayer(req.player);
226
227   res.send(renderPlayerBar(req.player) + renderProfilePage(req.player, equipment));
228 });
229
230 app.get('/player/skills', authEndpoint, async (req: Request, res: Response) => {
231   const skills = await getPlayerSkills(req.player.id);
232
233   res.send(renderSkills(skills));
234 });
235
236 app.get('/player/inventory', authEndpoint, async (req: Request, res: Response) => {
237   const [inventory, items] = await Promise.all([
238     getInventory(req.player.id),
239     getPlayersItems(req.player.id)
240   ]);
241
242   res.send(renderInventoryPage(inventory, items));
243 });
244
245 app.post('/player/equip/:item_id/:slot', authEndpoint, blockPlayerInFight, blockPlayerInDungeon, async (req: Request, res: Response) => {
246   const inventoryItem = await getInventoryItem(req.player.id, req.params.item_id);
247   const equippedItems = await getEquippedItems(req.player.id);
248   const requestedSlot = req.params.slot;
249   let desiredSlot: EquipmentSlot = inventoryItem.equipment_slot;
250
251   try {
252     // handes the situation where you're trying to equip an item 
253     // that can be equipped to any hand
254     if(inventoryItem.equipment_slot === 'ANY_HAND') {
255       if(requestedSlot === 'LEFT_HAND' || requestedSlot === 'RIGHT_HAND') {
256         // get the players current equipment in that slot!
257         if(equippedItems.some(v => {
258           return v.equipment_slot === requestedSlot || v.equipment_slot === 'TWO_HANDED';
259         })) {
260           throw new Error();
261         }
262         else {
263           desiredSlot = requestedSlot;
264         }
265       }
266     }
267
268     if(requestedSlot === 'TWO_HANDED') {
269       if(equippedItems.some(v => {
270         return v.equipment_slot === 'LEFT_HAND' || v.equipment_slot === 'RIGHT_HAND';
271       })) {
272         throw new Error();
273       }
274     }
275
276
277     await equip(req.player.id, inventoryItem, desiredSlot);
278     const socketId = cache.get(`socket:${req.player.id}`).toString();
279     io.to(socketId).emit('updatePlayer', req.player);
280     io.to(socketId).emit('alert', {
281       type: 'success',
282       text: `You equipped your ${inventoryItem.name}`
283     });
284   }
285   catch(e) {
286     logger.log(e);
287   }
288
289   const [inventory, items] = await Promise.all([
290     getInventory(req.player.id),
291     getPlayersItems(req.player.id)
292   ]);
293
294   res.send(renderInventoryPage(inventory, items, inventoryItem.type) + renderPlayerBar(req.player));
295 });
296
297 app.post('/player/unequip/:item_id', authEndpoint, blockPlayerInFight, blockPlayerInDungeon, async (req: Request, res: Response) => {
298   const [item, ] = await Promise.all([
299     getInventoryItem(req.player.id, req.params.item_id),
300     unequip(req.player.id, req.params.item_id)
301   ]);
302
303   const [inventory, items] = await Promise.all([
304     getInventory(req.player.id),
305     getPlayersItems(req.player.id)
306   ]);
307
308   res.send(renderInventoryPage(inventory, items, item.type) + renderPlayerBar(req.player));
309 });
310
311 app.get('/player/explore', authEndpoint, async (req: Request, res: Response) => {
312   const fight = await loadMonsterFromFight(req.player.id);
313   const travelPlan = await getTravelPlan(req.player.id);
314   let closestTown = req.player.city_id;
315
316   if(travelPlan) {
317       closestTown = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.destination_id : travelPlan.source_id;
318   }
319
320   if(fight && req.player.hp > 0) {
321     const location = await getMonsterLocation(fight.ref_id);
322
323     res.send(renderPlayerBar(req.player) + renderFightPreRound(fight, true, location, closestTown));
324     return;
325   }
326
327   const dungeon = await getActiveDungeon(req.player.id);
328   if(dungeon && req.player.hp > 0) {
329     const service = await getDungeon(dungeon.dungeon_id);
330     const room = await loadRoom(dungeon.current_room_id);
331     const visits = await getRoomVists(req.player.id, service.event_name);
332
333     res.send(ExplorePane(service.city_id, renderDungeon(service.city_name, service.name, room, visits)));
334     return;
335   }
336
337   if(travelPlan && req.player.hp > 0) {
338     // traveling!
339     const chanceToSeeMonster = random(0, 100);
340     const things: any[] = [];
341     if(chanceToSeeMonster <= 30) {
342       const monster = await getRandomMonster([closestTown]);
343       things.push(monster);
344     }
345
346     // STEP_DELAY
347     const nextAction = cache[`step:${req.player.id}`] || 0;
348
349     res.send(renderPlayerBar(req.player) + renderTravel({
350       things,
351       nextAction,
352       closestTown: closestTown,
353       walkingText: '',
354       travelPlan
355     }));
356     return;
357   }
358
359   // display the default explore view
360   const [city, locations, paths] = await Promise.all([
361     getCityDetails(req.player.city_id),
362     getAllServices(req.player.city_id, req.player.level),
363     getAllPaths(req.player.city_id)
364   ]);
365
366   res.send(renderPlayerBar(req.player) + await renderMap({city, locations, paths}, closestTown));
367 });
368
369 // used to purchase equipment from a particular shop
370 app.put('/location/:location_id/equipment/:item_id', authEndpoint, async (req: Request, res: Response) => {
371   const item = await getShopEquipment(parseInt(req.params.item_id), parseInt(req.params.location_id));
372
373   if(!item) {
374     logger.log(`Invalid item [${req.params.item_id}]`);
375     return res.sendStatus(400);
376   }
377
378   if(req.player.gold < item.cost) {
379     res.send(Alert.ErrorAlert(`Sorry, you need at least ${item.cost.toLocaleString()}G to purchase this.`));
380     return;
381   }
382
383   req.player.gold -= item.cost;
384
385   await updatePlayer(req.player);
386   await addInventoryItem(req.player.id, item);
387
388   res.send(renderPlayerBar(req.player) + Alert.SuccessAlert(`You purchased ${item.name}`));
389 });
390
391 // used to purchase items from a particular shop
392 app.put('/location/:location_id/items/:item_id', authEndpoint, async (req: Request, res: Response) => {
393   const item: (ShopItem & Item) = await getItemFromShop(parseInt(req.params.item_id), parseInt(req.params.location_id));
394
395   if(!item) {
396     logger.log(`Invalid item [${req.params.item_id}]`);
397     return res.sendStatus(400);
398   }
399
400   if(req.player.gold < item.price_per_unit) {
401     res.send(Alert.ErrorAlert(`Sorry, you need at least ${item.price_per_unit.toLocaleString()}G to purchase this.`));
402     return;
403   }
404
405   req.player.gold -= item.price_per_unit;
406
407   await updatePlayer(req.player);
408   await givePlayerItem(req.player.id, item.id, 1);
409
410   res.send(renderPlayerBar(req.player) + Alert.SuccessAlert(`You purchased a ${item.name}`));
411 });
412
413 // used to display equipment modals in a store, validates that 
414 // the equipment is actually in this store before displaying 
415 // the modal
416 app.get('/location/:location_id/equipment/:item_id/overview', authEndpoint, async (req: Request, res: Response) => {
417   const equipment = await getShopEquipment(parseInt(req.params.item_id), parseInt(req.params.location_id));
418
419   if(!equipment) {
420     logger.log(`Invalid equipment [${req.params.item_id}]`);
421     return res.sendStatus(400);
422   }
423
424   let html = `
425 <dialog>
426   <div class="item-modal-overview">
427     <div class="icon">
428       <img src="${equipment.icon ? `/assets/img/icons/equipment/${equipment.icon}` : 'https://via.placeholder.com/64x64'}" title="${equipment.name}" alt="${equipment.name}"> 
429     </div>
430     <div>
431       ${renderEquipmentDetails(equipment, req.player)}
432     </div>
433   </div>
434   <div class="actions">
435     <button hx-put="/location/${equipment.location_id}/equipment/${equipment.id}" formmethod="dialog" value="cancel" class="green">Buy</button>
436     <button class="close-modal" formmethod="dialog" value="cancel">Cancel</button>
437   </div>
438 </dialog>
439 `;
440
441   res.send(html);
442 });
443
444 // used to display item modals in a store, validates that 
445 // the item is actually in this store before displaying 
446 // the modal
447 app.get('/location/:location_id/items/:item_id/overview', authEndpoint, async (req: Request, res: Response) => {
448   const item: (ShopItem & Item) = await getItemFromShop(parseInt(req.params.item_id), parseInt(req.params.location_id));
449
450   if(!item) {
451     logger.log(`Invalid item [${req.params.item_id}]`);
452     return res.sendStatus(400);
453   }
454
455   let html = `
456 <dialog>
457   <div class="item-modal-overview">
458     <div class="icon">
459       <img src="/assets/img/icons/items/${item.icon_name}" title="${item.name}" alt="${item.name}"> 
460     </div>
461     <div>
462       <h4>${item.name}</h4>
463       <p>${item.description}</p>
464     </div>
465   </div>
466   <div class="actions">
467     <button hx-put="/location/${item.location_id}/items/${item.id}" formmethod="dialog" value="cancel" class="red">Buy</button>
468     <button class="close-modal" formmethod="dialog" value="cancel">Cancel</button>
469   </div>
470 </dialog>
471 `;
472
473   res.send(html);
474 });
475
476 app.put('/item/:item_id', authEndpoint, async (req: Request, res: Response) => {
477   const item: PlayerItem = await getItemFromPlayer(req.player.id, parseInt(req.params.item_id));
478
479   if(!item) {
480     console.log(`Can't find item [${req.params.item_id}]`);
481     return;
482   }
483
484   if(item.amount < 1) {
485     res.send(Alert.ErrorAlert(`You dont have enough ${item.name}`));
486     return;
487   }
488
489   item.amount -= 1;
490
491   switch(item.effect_name) {
492     case 'heal_small':
493       const hpGain = HealthPotionSmall.effect(req.player);
494
495       req.player.hp += hpGain;
496
497       if(req.player.hp > maxHp(req.player.constitution, req.player.level)) {
498         req.player.hp = maxHp(req.player.constitution, req.player.level);
499       }
500     break;
501   }
502
503   await updateItemCount(req.player.id, item.item_id, -1);
504   await updatePlayer(req.player);
505
506   const inventory = await getInventory(req.player.id);
507   const items = await getPlayersItems(req.player.id);
508
509   res.send(
510     [
511       renderPlayerBar(req.player),
512       renderInventoryPage(inventory, items, 'ITEMS'),
513       Alert.SuccessAlert(`You used the ${item.name}`)
514     ].join("")
515   );
516
517 });
518
519 app.get('/modal/items/:item_id', authEndpoint, async (req: Request, res: Response) => {
520   const item: PlayerItem = await getItemFromPlayer(req.player.id, parseInt(req.params.item_id));
521
522   if(!item) {
523     logger.log(`Invalid item [${req.params.item_id}]`);
524     return res.sendStatus(400);
525   }
526
527   let html = `
528 <dialog>
529   <div class="item-modal-overview">
530     <div class="icon">
531       <img src="/assets/img/icons/items/${item.icon_name}" title="${item.name}" alt="${item.name}"> 
532     </div>
533     <div>
534       <h4>${item.name}</h4>
535       <p>${item.description}</p>
536     </div>
537   </div>
538   <div class="actions">
539     <button hx-put="/item/${item.item_id}" formmethod="dialog" value="cancel" hx-target="#inventory" class="red">Use</button>
540     <button class="close-modal" formmethod="dialog" value="cancel">Cancel</button>
541   </div>
542 </dialog>
543 `;
544
545   res.send(html);
546 });
547
548 app.get('/city/stores/city:stores/:location_id', authEndpoint, async (req: Request, res: Response) => {
549   const location = await getService(parseInt(req.params.location_id));
550
551   if(!location || location.city_id !== req.player.city_id) {
552     logger.log(`Invalid location: [${req.params.location_id}]`);
553     res.sendStatus(400);
554   }
555   const [shopEquipment, shopItems] = await Promise.all([
556     listShopItems({location_id: location.id}),
557     getShopItems(location.id),
558   ]);
559
560   const html = await renderStore(shopEquipment, shopItems, req.player, location);
561
562   res.send(html);
563 });
564
565 app.get('/city/explore/city:explore/:location_id', authEndpoint, async (req: Request, res: Response) => {
566   const location = await getService(parseInt(req.params.location_id));
567   if(!location || location.city_id !== req.player.city_id) {
568
569     logger.log(`Invalid location: [${req.params.location_id}]`);
570     res.sendStatus(400);
571   }
572
573   const monsters: Monster[] = await getMonsterList(location.id);
574   res.send(renderOnlyMonsterSelector(monsters, 0, location));
575 });
576
577 app.post('/travel', authEndpoint, async (req: Request, res: Response) => {
578   const destination_id = parseInt(req.body.destination_id);
579
580   if(!destination_id || isNaN(destination_id)) {
581     logger.log(`Invalid destination_id [${req.body.destination_id}]`);
582     return res.sendStatus(400);
583   }
584
585   const travelPlan = travel(req.player, req.body.destination_id);
586
587   res.json(travelPlan);
588 });
589
590 app.post('/fight/turn', authEndpoint, async (req: Request, res: Response) => {
591   const fightBlockKey = `fightturn:${req.player.id}`;
592
593   if(cache[fightBlockKey] && cache[fightBlockKey] > Date.now()) {
594     res.status(429).send(Alert.ErrorAlert('Hmm, you are fight too quickly'));
595     return;
596
597   }
598
599   cache[fightBlockKey] = Date.now() + CONSTANT.FIGHT_ATTACK_DELAY;
600   const monster = await loadMonsterFromFight(req.player.id);
601
602   if(!monster) {
603     res.send(Alert.ErrorAlert('Not in a fight'));
604     return;
605   }
606
607   const fightData  = await fightRound(req.player, monster, {
608     action: req.body.action
609   });
610
611
612   if(fightData.roundData.winner !== 'in-progress') {
613     delete cache[fightBlockKey];
614   }
615
616   if(fightData.roundData.winner === 'player') {
617     //@TODO: Add equipped weapons
618     addEvent('MONSTER_KILLED', req.player.id, {
619       monster_id: monster.ref_id,
620       monster_name: monster.name,
621       monster_level: monster.level,
622       fight_trigger: monster.fight_trigger,
623     });
624   }
625   else if(fightData.roundData.winner === 'monster') {
626     addEvent('PLAYER_KILLED', req.player.id, {
627       monster_id: monster.ref_id,
628       monster_name: monster.name,
629       monster_level: monster.level,
630       fight_trigger: monster.fight_trigger
631     });
632   }
633
634
635   if(monster.fight_trigger === 'dungeon-forced' && fightData.roundData.winner === 'player') {
636     // ok the player was in a dungeon, lets make sure 
637     // that they complete whatever dungeon room they are in
638     const dungeonState = await completeDungeonFight(req.player.id, monster);
639     const room = await loadRoom(dungeonState.current_room_id);
640     const visits = await getRoomVists(req.player.id, room.dungeon_id);
641
642     res.send(renderDungeonRoom(room, visits));
643     return;
644   }
645
646   let html = renderFight(
647     monster,
648     renderRoundDetails(fightData.roundData),
649     fightData.roundData.winner === 'in-progress',
650     cache[fightBlockKey]
651   );
652
653   if(fightData.monsters.length && monster.fight_trigger === 'explore') {
654     html += renderMonsterSelector(fightData.monsters, monster.ref_id);
655   }
656
657   let travelSection = '';
658   if(monster.fight_trigger === 'travel' && fightData.roundData.winner === 'player') {
659     // you're travellinga dn you won.. display the keep walking!
660     const travelPlan = await getTravelPlan(req.player.id);
661     const closest: number = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.destination_id : travelPlan.source_id;
662     travelSection = travelButton(0);
663   }
664
665   const playerBar = renderPlayerBar(fightData.player);
666
667   res.send(html + travelSection + playerBar);
668 });
669
670 app.post('/fight', fightRateLimiter, authEndpoint, async (req: Request, res: Response) => {
671   if(req.player.hp <= 0) {
672     logger.log(`Player didn\'t have enough hp`);
673     return res.sendStatus(400);
674   }
675
676   const monsterId: number = req.body.monsterId;
677   const fightTrigger: FightTrigger = req.body.fightTrigger ?? 'travel';
678
679   if(!monsterId) {
680     logger.log(`Missing monster Id ${monsterId}`);
681     return res.sendStatus(400);
682   }
683
684   if(!fightTrigger || !['travel', 'explore'].includes(fightTrigger)) {
685     logger.log(`Invalid fight trigger [${fightTrigger}]`);
686     return res.sendStatus(400);
687   }
688
689   const monster = await loadMonster(monsterId);
690
691   if(!monster) {
692     logger.log(`Couldnt find monster for ${monsterId}`);
693     return res.sendStatus(400);
694   }
695
696   const fight = await createFight(req.player.id, monster, fightTrigger);
697   const location = await getService(monster.location_id);
698
699   res.send(renderFightPreRound(fight, true, location, location.city_id));
700 });
701
702 app.post('/travel/step', authEndpoint, async (req: Request, res: Response) => {
703   const stepTimerKey = `step:${req.player.id}`;
704
705   const travelPlan = await getTravelPlan(req.player.id);
706   if(!travelPlan) {
707     res.send(Alert.ErrorAlert('You don\'t have a travel plan'));
708     return;
709   }
710
711   if(cache[stepTimerKey]) {
712     if(cache[stepTimerKey] > Date.now()) {
713       res.send(Alert.ErrorAlert('Hmm.. travelling too quickly'));
714       return;
715     }
716   }
717
718   travelPlan.current_position++;
719
720   if(travelPlan.current_position >= travelPlan.total_distance) {
721     const travel = await completeTravel(req.player.id);
722
723     req.player.city_id = travel.destination_id;
724     await movePlayer(travel.destination_id, req.player.id);
725
726     const [city, locations, paths] = await Promise.all([
727       getCityDetails(travel.destination_id),
728       getAllServices(travel.destination_id, req.player.level),
729       getAllPaths(travel.destination_id)
730     ]);
731
732     delete cache[stepTimerKey];
733     res.send(await renderMap({city, locations, paths}, req.player.city_id));
734   }
735   else {
736     const walkingText: string[] = [
737       'You take a step forward',
738       'You keep moving'
739     ];
740     // update existing plan..
741     // decide if they will run into anything
742     const travelPlan = await stepForward(req.player.id);
743
744     const closest: number = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.destination_id : travelPlan.source_id;
745
746     const chanceToSeeMonster = random(0, 100);
747     const things: any[] = [];
748     if(chanceToSeeMonster <= 30) {
749       const monster = await getRandomMonster([closest]);
750       things.push(monster);
751     }
752
753     // STEP_DELAY
754     const nextAction = Date.now() + CONSTANT.STEP_DELAY;
755
756     cache[stepTimerKey] = nextAction;
757
758     res.send(renderTravel({
759       things,
760       nextAction,
761       closestTown: closest,
762       walkingText: sample(walkingText),
763       travelPlan
764     }));
765
766   }
767 });
768
769 app.post('/travel/return-to-source', authEndpoint, async (req: Request, res: Response) => {
770   // puts the player back in their starting town
771   // doesn't matter if they don't have one
772   // redirect them!
773   await clearTravelPlan(req.player.id);
774
775   const fight = await loadMonsterFromFight(req.player.id);
776   if(fight) {
777     // go to the fight screen
778     const location = await getMonsterLocation(fight.ref_id);
779
780     res.send(renderPlayerBar(req.player) + renderFightPreRound(fight, true, location, req.player.city_id));
781   }
782   else {
783     const [city, locations, paths] = await Promise.all([
784       getCityDetails(req.player.city_id),
785       getAllServices(req.player.city_id, req.player.level),
786       getAllPaths(req.player.city_id)
787     ]);
788
789     res.send(renderPlayerBar(req.player) + await renderMap({city, locations, paths}, req.player.city_id));
790
791   }
792
793 });
794
795 app.post('/travel/:destination_id', authEndpoint, async (req: Request, res: Response) => {
796 if(req.player.hp <= 0) {
797     logger.log(`Player didn\'t have enough hp`);
798     res.send(Alert.ErrorAlert('Sorry, you need some HP to start travelling.'));
799     return;
800   }
801
802   const destination = await getCityDetails(parseInt(req.params.destination_id));
803
804   if(!destination) {
805     res.send(Alert.ErrorAlert(`Thats not a valid desination`));
806     return;
807   }
808
809   const travelPlan = await travel(req.player, destination.id);
810
811   res.send(renderTravel({
812     things: [],
813     nextAction: 0,
814     walkingText: '',
815     closestTown: req.player.city_id,
816     travelPlan
817   }));
818 });
819
820 app.get('/settings', authEndpoint, async (req: Request, res: Response) => {
821   let warning = '';
822   let html = '';
823   if(req.player.account_type === 'session') {
824     warning += `<div class="alert error">If you log out without signing up first, this account is lost forever.</div>`;
825   }
826
827   html += '<a href="#" hx-post="/logout">Logout</a>';
828   res.send(warning + html);
829 });
830
831 app.post('/logout', authEndpoint, async (req: Request, res: Response) => {
832   // ref to get the socket id for a particular player
833   cache.delete(`socket:${req.player.id}`);
834   // ref to get the player object
835   cache.delete(`token:${req.player.id}`);
836
837   logger.log(`${req.player.username} logged out`);
838
839   res.send('logout');
840 });
841
842
843 app.post('/auth', async (req: Request, res: Response) => {
844   if(req.body.authType === 'login') {
845     loginHandler(req, res);
846   }
847   else if(req.body.authType === 'signup') { 
848     signupHandler(req, res);
849   }
850   else {
851     logger.log(`Invalid auth type [${req.body.authType}]`);
852     res.sendStatus(400);
853   }
854 });
855
856
857 async function signupHandler(req: Request, res: Response) {
858   const {username, password} = req.body;
859   const authToken = req.headers['x-authtoken'];
860
861   if(!username || !password || !authToken) {
862     res.send(Alert.ErrorAlert('Invalid username/password'));
863     return;
864   }
865
866   try {
867     const player = await loadPlayer(authToken.toString());
868     logger.log(`Attempted claim for ${player.username}`);
869
870     await signup(authToken.toString(), username, password);
871
872     await db('players').where({id: player.id}).update({
873       account_type: 'auth',
874       username: username
875     });
876
877     logger.log(`${username} claimed ${player.username}`);
878
879     io.emit('chat', broadcastMessage('server', `${player.username} is now ${username}`));
880
881     res.setHeader('hx-refresh', 'true');
882     res.sendStatus(200);
883   }
884   catch(e) {
885     logger.log(e);
886     if(e?.constraint === 'players_username_unique') {
887       res.send(Alert.ErrorAlert('That username is already taken'));
888     }
889     else {
890       res.send(Alert.ErrorAlert('Please try again'));
891     }
892   }
893 }
894
895 async function loginHandler (req: Request, res: Response) {
896   const {username, password} = req.body;
897   if(!username || !username.length) {
898     res.send(Alert.ErrorAlert('Invalid username'));
899     return;
900   }
901   if(!password || !password.length) {
902     res.send(Alert.ErrorAlert('Invalid password'));
903     return;
904   }
905
906   try {
907     const player = await login(username, password);
908     io.sockets.sockets.forEach(socket => {
909       if(socket.handshake.headers['x-authtoken'] === req.headers['x-authtoken']) {
910         bootstrapSocket(socket, player);
911       }
912     });
913     res.sendStatus(204);
914   }
915   catch(e) {
916     console.log(e);
917     res.send(Alert.ErrorAlert('That user doesn\'t exist'));
918   }
919 }
920
921 server.listen(process.env.API_PORT, () => {
922   logger.log(`Listening on port ${process.env.API_PORT}`);
923 });