feat: make travel an idle activity
authorxangelo <me@xangelo.ca>
Wed, 22 Jan 2025 04:19:22 +0000 (23:19 -0500)
committerxangelo <me@xangelo.ca>
Wed, 22 Jan 2025 04:19:22 +0000 (23:19 -0500)
instead of clicking "next step" for each step during traveling, the game
will automatically move you to the next step every second (this is adjustable).

migrations/20250120182103_travel_timer.ts [new file with mode: 0644]
public/assets/css/game.css
public/assets/css/travel.css [new file with mode: 0644]
src/server/api.ts
src/server/map.ts
src/server/routes/travel.ts
src/server/views/travel.ts
src/shared/map.ts
src/shared/travel.ts

diff --git a/migrations/20250120182103_travel_timer.ts b/migrations/20250120182103_travel_timer.ts
new file mode 100644 (file)
index 0000000..89ea274
--- /dev/null
@@ -0,0 +1,19 @@
+import { Knex } from "knex";
+
+export async function up(knex: Knex): Promise<void> {
+    return knex.schema.alterTable('travel', table => {
+        table.timestamp('start_time').notNullable().defaultTo(knex.fn.now());
+        table.timestamp('end_time').notNullable().defaultTo(knex.fn.now());
+        table.dropColumn('total_distance');
+        table.dropColumn('current_position');
+    });
+}
+
+export async function down(knex: Knex): Promise<void> {
+    return knex.schema.alterTable('travel', table => {
+        table.dropColumn('start_time');
+        table.dropColumn('end_time');
+        table.integer('total_distance').notNullable().defaultTo(0);
+        table.integer('current_position').notNullable().defaultTo(0);
+    });
+}
index 75d9a2d2f6608cba1f80e0fcd7f1c1a32dd9421f..37c3af7650c4eb20b2bfed6e44bd96ce63b5b35c 100644 (file)
@@ -9,6 +9,7 @@
 @import 'explore.css';
 @import 'store.css';
 @import 'combat.css';
+@import 'travel.css';
 
 .equipment-slot {
   position: relative;
   mix-blend-mode: color-burn;
 }
 
+.time-remaining {
+  text-align: center;
+  font-size: 1.2rem;
+  margin: 0.5rem 0;
+  color: #d9975a;
+}
+
 #view {
   font-size: 14px;
   padding: 1rem;
diff --git a/public/assets/css/travel.css b/public/assets/css/travel.css
new file mode 100644 (file)
index 0000000..44e80cb
--- /dev/null
@@ -0,0 +1,3 @@
+#travel-plan {
+    height: 20px;
+}
\ No newline at end of file
index 76a722a0042f0a2c8692fa0f38add796beaf3fae..cac93605d463ac266d566ded8d52f20a3939e8c2 100644 (file)
@@ -17,7 +17,7 @@ import {broadcastMessage} from '@shared/message';
 import { Player } from '@shared/player';
 import {createFight, getMonsterList, getMonsterLocation, getRandomMonster, loadMonster, loadMonsterFromFight} from './monster';
 import {FightTrigger, Monster} from '@shared/monsters';
-import { getAllPaths, getAllServices, getCityDetails, getService, getTravelPlan, getDungeon } from './map';
+import { getAllPaths, getAllServices, getCityDetails, getService, getTravelPlan, getDungeon, clearTravelPlan, completeTravel } from './map';
 import { signup, login, authEndpoint } from './auth';
 import {db} from './lib/db';
 import { getPlayerSkills} from './skills';
@@ -34,7 +34,7 @@ import { renderMap } from './views/map';
 import { renderSkills } from './views/skills';
 import { renderMonsterSelector, renderOnlyMonsterSelector } from './views/monster-selector';
 import { renderFight, renderFightPreRound, renderRoundDetails } from './views/fight';
-import { renderTravel, travelButton } from './views/travel';
+import { renderTravel } from './views/travel';
 import { renderAdminActions } from './views/admin';
 
 // TEMP!
@@ -176,10 +176,7 @@ app.get('/player/explore', authEndpoint, async (req: Request, res: Response) =>
   const fight = await loadMonsterFromFight(req.player.id);
   const travelPlan = await getTravelPlan(req.player.id);
   let closestTown = req.player.city_id;
-
-  if(travelPlan) {
-      closestTown = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.destination_id : travelPlan.source_id;
-  }
+  const now = Date.now();
 
   if(fight && req.player.hp > 0) {
     const location = await getMonsterLocation(fight.ref_id);
@@ -198,26 +195,25 @@ app.get('/player/explore', authEndpoint, async (req: Request, res: Response) =>
     return;
   }
 
-  if(travelPlan && req.player.hp > 0) {
-    // traveling!
-    const chanceToSeeMonster = random(0, 100);
-    const things: any[] = [];
-    if(chanceToSeeMonster <= 30) {
-      const monster = await getRandomMonster([closestTown]);
-      things.push(monster);
+  if(travelPlan) {
+    const startTime = Date.parse(travelPlan.start_time);
+    const endTime = Date.parse(travelPlan.end_time);
+      // the player has completed their travel 
+    if(endTime < now) {
+      logger.info(`Completing travel for player [${req.player.id}]`);
+      await completeTravel(req.player.id);
+    }
+    else {
+      // check if your current time is closer to end_time or start_time
+      closestTown = (now - startTime) < (endTime - now) ? travelPlan.source_id : travelPlan.destination_id;
+
+      res.send(renderPlayerBar(req.player) + renderTravel({
+        closestTown: closestTown,
+        walkingText: '',
+        travelPlan
+      }));
+      return;
     }
-
-    // STEP_DELAY
-    const nextAction = cache[`step:${req.player.id}`] || 0;
-
-    res.send(renderPlayerBar(req.player) + renderTravel({
-      things,
-      nextAction,
-      closestTown: closestTown,
-      walkingText: '',
-      travelPlan
-    }));
-    return;
   }
 
   // display the default explore view
@@ -313,8 +309,6 @@ app.post('/fight/turn', authEndpoint, async (req: Request, res: Response) => {
   if(monster.fight_trigger === 'travel' && fightData.roundData.winner === 'player') {
     // you're travellinga dn you won.. display the keep walking!
     const travelPlan = await getTravelPlan(req.player.id);
-    const closest: number = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.destination_id : travelPlan.source_id;
-    travelSection = travelButton(0);
   }
 
   const playerBar = renderPlayerBar(fightData.player);
index 45975bd1d3fc3ec739a0d0b9d054adf337f2f71e..4960077bf2a1dd47b2088fe987efbfd9d8a5bc67 100644 (file)
@@ -66,13 +66,13 @@ export async function travel(player: Player, dest_id: number): Promise<TravelWit
   }
 
   const deviation = Math.floor(path.distance * (random(3,10)/100));
-  const steps = random(path.distance - deviation, path.distance + deviation);
+  const timeInSeconds = random(path.distance - deviation, path.distance + deviation);
 
   const rows = await db('travel').insert({
     player_id: player.id,
     source_id: player.city_id,
     destination_id: dest_id,
-    total_distance: steps
+    end_time: db.raw(`now() + interval '${timeInSeconds} seconds'`)
   });
   
   return getTravelPlan(player.id);
@@ -94,6 +94,10 @@ export async function completeTravel(player_id: string): Promise<Travel> {
     throw new Error('Unexpected response when moving');
   }
 
+  await db('players').where({id: player_id}).update({
+    city_id: rows[0].destination_id
+  });
+
   return rows[0] as Travel;
 }
 
index ba2f70cfcd053fde1a77d9e6ee95554ff48b2961..d400659890533f0e3083521eb79c5d0d7a004e65 100644 (file)
@@ -6,81 +6,14 @@ import { getRandomMonster, loadMonsterFromFight, getMonsterLocation } from '../m
 import { clearTravelPlan, completeTravel, getAllPaths, getAllServices, getCityDetails, getService, getTravelPlan, stepForward, travel, getDungeon } from '../map';
 import * as CONSTANT from '../../shared/constants';
 import * as Alert from '../views/alert';
-import { renderTravel, travelButton } from '../views/travel';
+import { renderTravel, renderTravelComplete, renderTravelProgress } from '../views/travel';
 import { renderMap } from '../views/map';
 import { renderPlayerBar } from '../views/player-bar'
 import { renderFightPreRound } from '../views/fight';
+import { logger } from '@server/lib/logger';
 
 export const travelRouter = Router();
 
-// take a step along your travel plan
-travelRouter.post('/travel/step', authEndpoint, async (req: Request, res: Response) => {
-  const stepTimerKey = `step:${req.player.id}`;
-
-  const travelPlan = await getTravelPlan(req.player.id);
-  if(!travelPlan) {
-    res.send(Alert.ErrorAlert('You don\'t have a travel plan'));
-    return;
-  }
-
-  if(req.rl.cache[stepTimerKey]) {
-    if(req.rl.cache[stepTimerKey] > Date.now()) {
-      res.send(Alert.ErrorAlert('Hmm.. travelling too quickly'));
-      return;
-    }
-  }
-
-  travelPlan.current_position++;
-
-  if(travelPlan.current_position >= travelPlan.total_distance) {
-    const travel = await completeTravel(req.player.id);
-
-    req.player.city_id = travel.destination_id;
-    await movePlayer(travel.destination_id, req.player.id);
-
-    const [city, locations, paths] = await Promise.all([
-      getCityDetails(travel.destination_id),
-      getAllServices(travel.destination_id, req.player.level),
-      getAllPaths(travel.destination_id)
-    ]);
-
-    delete req.rl.cache[stepTimerKey];
-    res.send(await renderMap({city, locations, paths}, req.player.city_id));
-  }
-  else {
-    const walkingText: string[] = [
-      'You take a step forward',
-      'You keep moving'
-    ];
-    // update existing plan..
-    // decide if they will run into anything
-    const travelPlan = await stepForward(req.player.id);
-
-    const closest: number = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.destination_id : travelPlan.source_id;
-
-    const chanceToSeeMonster = random(0, 100);
-    const things: any[] = [];
-    if(chanceToSeeMonster <= 30) {
-      const monster = await getRandomMonster([closest]);
-      things.push(monster);
-    }
-
-    // STEP_DELAY
-    const nextAction = Date.now() + CONSTANT.STEP_DELAY;
-
-    req.rl.cache[stepTimerKey] = nextAction;
-
-    res.send(renderTravel({
-      things,
-      nextAction,
-      closestTown: closest,
-      walkingText: sample(walkingText),
-      travelPlan
-    }));
-
-  }
-});
-
 travelRouter.post('/travel/return-to-source', authEndpoint, async (req: Request, res: Response) => {
   // puts the player back in their starting town
   // doesn't matter if they don't have one
@@ -125,8 +58,6 @@ travelRouter.post('/travel/:destination_id', authEndpoint, async (req: Request,
   const travelPlan = await travel(req.player, destination.id);
 
   res.send(renderTravel({
-    things: [],
-    nextAction: 0,
     walkingText: '',
     closestTown: req.player.city_id,
     travelPlan
@@ -134,3 +65,37 @@ travelRouter.post('/travel/:destination_id', authEndpoint, async (req: Request,
 });
 
 
+// return the curret travel plan information
+travelRouter.get('/travel', authEndpoint, async (req: Request, res: Response) => {
+  const travelPlan = await getTravelPlan(req.player.id);
+  const now = Date.now();
+
+  if(!travelPlan) {
+    res.sendStatus(400);
+    return;
+  }
+  
+  logger.debug(`Travel plan: ${JSON.stringify(travelPlan)}`);
+  logger.debug({
+    now,
+    start_time: travelPlan.start_time,
+    end_time: Date.parse(travelPlan.end_time as any) <= Date.now() ? 'complete' : travelPlan.end_time,
+  });
+
+  
+  if (Date.parse(travelPlan.end_time as any) <= Date.now()) {
+    await completeTravel(req.player.id);
+    res.send(renderTravelComplete({
+      walkingText: '',
+      closestTown: travelPlan.destination_id,
+      travelPlan
+    }));
+  }
+  else {
+    res.send(renderTravel({
+      walkingText: '',
+      closestTown: req.player.city_id,
+      travelPlan
+    }));
+  }
+});
\ No newline at end of file
index 9fcbb478e7a689049f74b57b89d79ede65f149a7..31788244a2acf9c4b506ba056420b0494cf289ba 100644 (file)
@@ -1,49 +1,70 @@
 import { TravelDTO } from "@shared/map";
 import { ProgressBar } from './components/progress-bar';
-import { ButtonWithBlock } from './components/button';
+import { BackToTown, ButtonWithBlock } from './components/button';
+import { Title } from "./components/city";
 
-export function travelButton(blockTime: number): string {
-  return ButtonWithBlock({
-    id: 'keep-walking',
-    'hx-post': '/travel/step'
-  }, 'Keep Walking', blockTime);
+function formatTimeRemaining(remainingMs: number): string {
+  const minutes = Math.floor(remainingMs / (1000 * 60));
+  const seconds = Math.floor((remainingMs % (1000 * 60)) / 1000);
+  
+  if (minutes > 0) {
+    return `${minutes}m ${seconds}s`;
+  }
+  return `${seconds}s`;
 }
 
-export function renderTravel(data: TravelDTO): string {
+export function renderTravelProgress(data: TravelDTO): string {
+  const now = Date.now();
+  const totalDuration = Date.parse(data.travelPlan.end_time) - Date.parse(data.travelPlan.start_time);
+  const elapsed = now - Date.parse(data.travelPlan.start_time);
+  const remaining = Date.parse(data.travelPlan.end_time) - now;
+  const progress = Math.min(100, Math.max(0, (elapsed / totalDuration) * 100));
+
+  let html = `
+    <div class="travel-distance">
+      <p>Travelling from ${data.travelPlan.source_city_name} to ${data.travelPlan.destination_city_name}</p>
+      <p class="time-remaining">Time remaining: ${formatTimeRemaining(remaining)}</p>
+      ${ProgressBar(progress, 100, 'travel-plan', {
+        startingColor: '#d9975a',
+        endingColor: '#bb724c',
+        noContent: true
+      })}
+    </div>
+  `;
+  return html;
+}
 
+export function renderTravel(data: TravelDTO): string {
   let promptText = data.walkingText;
-  const blockTime = data.nextAction || 0;
 
   let html = `<section id="explore" class="tab active" hx-swap-oob="true" style="background-image: url('/assets/img/map/${data.closestTown}.jpeg')">
-
-<div id="travelling" class="city-details">
-  <div class="travel-distance">
-    <p>Travelling from ${data.travelPlan.source_city_name} to ${data.travelPlan.destination_city_name}</p>
-    ${ProgressBar(data.travelPlan.current_position, data.travelPlan.total_distance, 'travel-plan', {
-      startingColor: '#d9975a',
-      endingColor: '#bb724c'
-    })}
-  </div>
-`;
+  <div id="travelling" class="city-details" hx-get="/travel" hx-trigger="every 1s">
+    ${renderTravelProgress(data)}
+  `;
+  
   html += '<div id="travelling-actions">';
-  html += travelButton(blockTime);
-  if(data.things.length) {
-    // ok you found something, for now we only support 
-    // monsters, but eventually that will change
-    promptText = `You see a ${data.things[0].name}`;
-    html += `<form hx-post="/fight" hx-target="#explore">
-<input type="hidden" name="monsterId" value="${data.things[0].id}">
-<button type="submit" class="red">Fight</button>
-</form>`;
-  }
-
-  // end #travelling-actions
   html += '</div>';
-  html += `<p>${promptText}</p>`;
+
+  if(promptText) {
+    html += `<div class="prompt">${promptText}</div>`;
+  }
 
   html += `<p align="right"><a href="#" hx-post="/travel/return-to-source">Return to ${data.travelPlan.source_city_name}</a></p>`;
 
   html += '</div></section>';
-
   return html;
 }
+
+export function renderTravelComplete(data: TravelDTO): string {
+  return `
+  <section id="explore" class="tab active" hx-swap-oob="true" style="background-image: url('/assets/img/map/${data.closestTown}.jpeg')">
+  ${Title(data.travelPlan.destination_city_name)}
+  <div id="travelling" class="city-details">
+    <div class="travel-complete">
+      <p>You have arrived at ${data.travelPlan.destination_city_name}!</p>
+    </div>
+    <p>${BackToTown()}</p>
+  </div>
+  </section>
+  `;
+}
\ No newline at end of file
index acf1f45abbc8eed72331476c77b37f14468e1135..ac942c51808ba92423352783ca7da5f504adc3c5 100644 (file)
@@ -30,8 +30,6 @@ export type Path = {
 }
 
 export type TravelDTO = {
-  things: any[],
-  nextAction: number,
   walkingText: string,
   closestTown: number;
   travelPlan: TravelWithNames
index babafa062e0a029e94b191415350ca7bcaa75ffe..e5a37ebe3d288b1b972dcda02d6f0dcddd490a4b 100644 (file)
@@ -2,8 +2,8 @@ export type Travel = {
   player_id: string;
   source_id: number;
   destination_id: number;
-  total_distance: number;
-  current_position: number;
+  start_time: string;
+  end_time: string;
 }
 
 export type TravelWithNames = Travel & {