fix(tours): keep a city walk on foot across the water, mark a crossing by boat apart from the walking time and save it only once taken

This commit is contained in:
Maurice
2026-10-21 13:16:43 +02:00
parent 41f3267503
commit 6e13f7e655
72 changed files with 2312 additions and 109 deletions
@@ -27,7 +27,17 @@ const osrmSchema = z.object({
distance: z.number().nonnegative(),
duration: z.number().nonnegative(),
legs: z
.array(z.object({ distance: z.number().nonnegative(), duration: z.number().nonnegative() }))
.array(
z.object({
distance: z.number().nonnegative(),
duration: z.number().nonnegative(),
// CW-12: asked only for a walk on foot, to tell a crossing by boat (`mode` ferry) apart.
steps: z
.array(z.object({ mode: z.string().max(32).optional() }))
.max(20000)
.optional(),
}),
)
.min(1)
.max(99),
geometry: z.object({ coordinates: z.array(coordinate).min(2).max(200000) }),
@@ -49,6 +59,11 @@ const valhallaSchema = z.object({
has_highway: z.boolean().optional(),
has_ferry: z.boolean().optional(),
}),
// CW-12: where a walk on foot boards a ferry (type 28, or `ferry` on the crossing).
maneuvers: z
.array(z.object({ type: z.number().optional(), ferry: z.boolean().optional() }))
.max(20000)
.optional(),
}),
)
.min(1)
@@ -61,6 +76,48 @@ export interface RoadtripRoute {
parts: { distance: number; duration: number }[];
snapped?: SnappedWaypoint[];
avoidMissed: RouteAvoidClass[];
/** CW-12: on a way asked on foot only, the legs (counted from 0) that still cross by boat. */
ferryLegs?: number[];
}
/** How a route is asked for besides its profile. */
export interface RouteOptions {
/**
* CW-12: a way that is walked: no point bound to a ferry's edge, ferries
* avoided wherever a way on land exists, and the legs that still cross by
* boat said. A city walk asks this; a road trip day never does.
*/
footOnly?: boolean;
}
/** Valhalla's maneuver type for boarding a ferry. */
const FERRY_ENTER = 28;
type ValhallaLegs = z.infer<typeof valhallaSchema>['trip']['legs'];
/** Whether a walked leg still crosses by boat (CW-12). */
function crossesByBoat(leg: ValhallaLegs[number]): boolean {
return (
leg.summary.has_ferry === true ||
(leg.maneuvers ?? []).some((maneuver) => maneuver.ferry === true || maneuver.type === FERRY_ENTER)
);
}
/** Valhalla's legs as a route: the line, its length and time, a part per leg, and where each point met a way. */
function routeOfLegs(points: Point[], legs: ValhallaLegs) {
const shapes = legs.map((leg) => decode(leg.shape));
const locations = [...shapes.map((shape) => shape[0]), shapes[shapes.length - 1].at(-1)!];
return {
line: shapes.flat(),
distance: legs.reduce((sum, leg) => sum + leg.summary.length * 1000, 0),
duration: legs.reduce((sum, leg) => sum + leg.summary.time, 0),
parts: legs.map((leg) => ({ distance: leg.summary.length * 1000, duration: leg.summary.time })),
snapped: locations.map(([lat, lng], index): SnappedWaypoint => ({
asked: [points[index].lat, points[index].lng],
at: [lat, lng],
meters: haversineKm(points[index], { lat, lng }) * 1000,
})),
};
}
function decode(shape: string): [number, number][] {
@@ -152,8 +209,10 @@ export class RoadtripRouterService {
points: Point[],
profile: string,
avoid: RouteAvoidClass[],
options: RouteOptions = {},
): Promise<RoadtripRoute> {
const settings = await this.settings.getUserSettings(userId);
const footOnly = options.footOnly === true && profile === 'walking';
const key = JSON.stringify([
userId,
tripId,
@@ -163,6 +222,7 @@ export class RoadtripRouterService {
avoid,
settings.routing_base_url,
settings.valhalla_base_url,
...(footOnly ? ['foot-only'] : []),
]);
const cached = this.cache.get(key);
if (!profile.startsWith('plugin:') && cached && Date.now() - cached.at < 300000) return cached.route;
@@ -171,6 +231,7 @@ export class RoadtripRouterService {
let vias: RoutedLeg['vias'] = [];
let avoidMissed = profile === 'driving' ? [...avoid] : [];
let snapped: SnappedWaypoint[] | undefined;
let ferryLegs: number[] | undefined;
const plugin = /^plugin:([^/]+)\/(.+)$/.exec(profile);
if (plugin) {
const [, id, name] = plugin;
@@ -197,6 +258,16 @@ export class RoadtripRouterService {
typeof settings.valhalla_base_url === 'string' ? settings.valhalla_base_url.trim().replace(/\/+$/, '') : '';
const valhalla = configuredValhalla || (base ? '' : 'https://valhalla1.openstreetmap.de');
let weighted = false;
if (footOnly && valhalla) {
try {
const legs = await this.valhallaLegs(valhalla, points, 'pedestrian', { use_ferry: 0 }, true);
({ line, distance, duration, parts, snapped } = routeOfLegs(points, legs));
ferryLegs = legs.flatMap((leg, index) => (crossesByBoat(leg) ? [index] : []));
weighted = true;
} catch {
snapped = undefined;
}
}
if (mode === 'driving' && avoid.length && valhalla) {
try {
const options = {
@@ -204,28 +275,8 @@ export class RoadtripRouterService {
use_highways: avoid.includes('motorway') ? 0 : 1,
use_ferry: avoid.includes('ferry') ? 0 : 1,
};
const legs: z.infer<typeof valhallaSchema>['trip']['legs'] = [];
for (let start = 0; start < points.length - 1; start += 9) {
const payload = await this.request(`${valhalla}/route`, {
locations: points.slice(start, start + 10).map((p) => ({ lat: p.lat, lon: p.lng, radius: 50 })),
costing: 'auto',
costing_options: { auto: options },
directions_options: { units: 'kilometers' },
});
legs.push(...valhallaSchema.parse(payload).trip.legs);
}
if (legs.length !== points.length - 1) throw new Error('Routing response has mismatched legs');
const shapes = legs.map((leg) => decode(leg.shape));
line = shapes.flat();
const locations = [...shapes.map((shape) => shape[0]), shapes[shapes.length - 1].at(-1)!];
snapped = locations.map(([lat, lng], index) => ({
asked: [points[index].lat, points[index].lng],
at: [lat, lng],
meters: haversineKm(points[index], { lat, lng }) * 1000,
}));
distance = legs.reduce((sum, leg) => sum + leg.summary.length * 1000, 0);
duration = legs.reduce((sum, leg) => sum + leg.summary.time, 0);
parts = legs.map((leg) => ({ distance: leg.summary.length * 1000, duration: leg.summary.time }));
const legs = await this.valhallaLegs(valhalla, points, 'auto', options);
({ line, distance, duration, parts, snapped } = routeOfLegs(points, legs));
avoidMissed = avoid.filter((cls) =>
legs.some((leg) =>
cls === 'toll'
@@ -254,15 +305,20 @@ export class RoadtripRouterService {
// browser sends the same thing, see `uTurnParam` in client RouteCalculator.ts.
// Only with something in the middle to turn round at.
const uTurn = points.length > 2 ? '&continue_straight=false' : '';
// A walk on foot only asks for the steps, the one place OSRM says a leg is on a ferry.
const payload = await this.request(
`${origin}/route/v1/${path}/${points.map((p) => `${p.lng},${p.lat}`).join(';')}?overview=full&geometries=geojson&steps=false${uTurn}`,
`${origin}/route/v1/${path}/${points.map((p) => `${p.lng},${p.lat}`).join(';')}?overview=full&geometries=geojson&steps=${footOnly}${uTurn}`,
);
const parsed = osrmSchema.parse(payload);
const route = parsed.routes[0];
line = route.geometry.coordinates.map(([lng, lat]) => [lat, lng]);
distance = route.distance;
duration = route.duration;
parts = route.legs;
parts = route.legs.map((leg) => ({ distance: leg.distance, duration: leg.duration }));
if (footOnly)
ferryLegs = route.legs.flatMap((leg, index) =>
leg.steps?.some((step) => step.mode === 'ferry') ? [index] : [],
);
if (parsed.waypoints?.length === points.length)
snapped = parsed.waypoints.map((waypoint, index) => ({
asked: [points[index].lat, points[index].lng],
@@ -308,9 +364,41 @@ export class RoadtripRouterService {
},
},
avoidMissed,
...(ferryLegs ? { ferryLegs } : {}),
};
this.cache.set(key, { at: Date.now(), route });
if (this.cache.size > 100) this.cache.delete(this.cache.keys().next().value!);
return route;
}
/**
* Valhalla's legs through the points, ten locations a request, one after
* another through the paced queue. CW-12: a walk on foot binds no point to
* a ferry's edge (`offBoatLines`).
*/
private async valhallaLegs(
valhalla: string,
points: Point[],
costing: 'auto' | 'pedestrian',
options: Record<string, number>,
offBoatLines = false,
): Promise<ValhallaLegs> {
const legs: ValhallaLegs = [];
for (let start = 0; start < points.length - 1; start += 9) {
const payload = await this.request(`${valhalla}/route`, {
locations: points.slice(start, start + 10).map((p) => ({
lat: p.lat,
lon: p.lng,
radius: 50,
...(offBoatLines ? { search_filter: { exclude_ferry: true } } : {}),
})),
costing,
costing_options: { [costing]: options },
directions_options: { units: 'kilometers' },
});
legs.push(...valhallaSchema.parse(payload).trip.legs);
}
if (legs.length !== points.length - 1) throw new Error('Routing response has mismatched legs');
return legs;
}
}
@@ -114,8 +114,8 @@ export type FootWay =
| { kind: 'routed'; line: Array<[number, number]>; legs: Leg[]; seconds: number }
| {
kind: 'unrouted';
why: 'nothing_to_route' | 'router_unavailable' | 'station_off_the_way';
/** The station no way reaches, counted from 1. */
why: 'nothing_to_route' | 'router_unavailable' | 'station_off_the_way' | 'ferry_only';
/** The station no way reaches, or (ferry_only) the one the crossing by boat starts at, counted from 1. */
position: number | null;
};
@@ -124,6 +124,8 @@ export interface FootRouteAnswer {
leg: { line: Array<[number, number]> };
parts: ReadonlyArray<{ distance: number; duration: number }>;
snapped?: ReadonlyArray<{ meters: number }>;
/** CW-12: the legs, counted from 0, the router could only join by boat. */
ferryLegs?: readonly number[];
}
interface Visit {
@@ -186,11 +188,16 @@ export function legOf(from: { lat: number; lng: number }, to: { lat: number; lng
/**
* The router's way as the walk takes it: each leg's distance and walking time,
* and the line. A station the router had to move further onto a way than the
* planner allows is one no way on foot reaches, and the way is not taken.
* planner allows is one no way on foot reaches, and the way is not taken. Nor
* is a way that crosses by boat (CW-12): the boat's time would pass for
* walking, so the walk waits for the travellers to take the crossing in the
* planner.
*/
export function footWayOf(answer: FootRouteAnswer): FootWay {
const off = (answer.snapped ?? []).findIndex((point) => point.meters > TOUR_MAX_SNAP_M);
if (off >= 0) return { kind: 'unrouted', why: 'station_off_the_way', position: off + 1 };
const boat = answer.ferryLegs?.[0];
if (boat !== undefined) return { kind: 'unrouted', why: 'ferry_only', position: boat + 1 };
return {
kind: 'routed',
line: answer.leg.line,
@@ -16,7 +16,7 @@ const ANY_DAY = 0;
* they set one). The proposal of a new walk, its creation and a prepared
* change to a saved walk all ask here, so their distances and times come from
* one source. Where the router gives no way, the answer says why instead of
* guessing one.
* guessing one; a way that crosses by boat is none on foot (CW-12).
*/
@Injectable()
export class CityWalkFootWayService {
@@ -27,7 +27,7 @@ export class CityWalkFootWayService {
if (points.length < 2) return { kind: 'unrouted', why: 'nothing_to_route', position: null };
const asked = points.map(({ lat, lng }) => ({ lat, lng }));
try {
return footWayOf(await this.router.route(userId, tripId, ANY_DAY, asked, 'walking', []));
return footWayOf(await this.router.route(userId, tripId, ANY_DAY, asked, 'walking', [], { footOnly: true }));
} catch (err: unknown) {
logWarn(`[CityWalk] no way on foot for trip ${tripId}: ${err instanceof Error ? err.message : String(err)}`);
return { kind: 'unrouted', why: 'router_unavailable', position: null };
+2 -2
View File
@@ -46,7 +46,7 @@ export class CityWalkMcp {
@Tool({
name: 'propose_city_walk',
description:
"Propose a city walk for a trip from the traveller's briefing, without saving anything: where it starts (and ends), the time frame (start_time to end_time, on day_id for its date), their interests (themes: culture for museums and sights, parks for parks and viewpoints, food for cafes, places to eat and markets, mixed for a bit of each; mixed when left out), the stations they asked for (wished), the places of the trip it may use (trip_places) and their fixed appointments with their times. Places for the themes come from the same search as search_tour_pois, around the start, the wishes and the appointments, up to theme_places_max (8 when left out); nothing is made up. Answers the stations in order, each with its source and why it is in (found names the theme, the category and where it was found), the walking distance and time from the one before (walk_from_previous: metres, minutes), the arrival and leave times, the stay where the traveller gave it (stay_source given; null when not known: then every later arrival is only the earliest, arrival_is_earliest; the start and the end are passed, stay_source frame), and its opening hours checked against the arrival on that day where they can be read (hours; an earliest arrival only ever shows a conflict that is certain). The order is chosen on estimates; the walk in that order is then planned on foot by TREK's router (the traveller's own routing server where they set one), whose legs give the distances and times (estimate kind routed). Where the router gives no way, they are estimates over the straight distance with an allowance for streets (estimate kind straight_line with street_factor and pace_kmh, and why: router_unavailable, or station_off_the_way with the position of the station no way on foot reaches); tell the traveller so. totals keeps walking and stays apart, total_complete only when every stay is known, and minutes_left_for_unknown_stays is what the frame leaves for the stays not known. gaps lists free time before an appointment and at the end, wishes_not_met the wishes and appointments that cannot be kept as asked (closed that day, closed or closing at the arrival, late, after the frame), left_out what was not taken and why, searched what was searched. Wished stations and appointments are always in; an appointment is a must and protected. Show the proposal to the traveller; once they confirm, pass stations_for_create to create_city_walk.",
"Propose a city walk for a trip from the traveller's briefing, without saving anything: where it starts (and ends), the time frame (start_time to end_time, on day_id for its date), their interests (themes: culture for museums and sights, parks for parks and viewpoints, food for cafes, places to eat and markets, mixed for a bit of each; mixed when left out), the stations they asked for (wished), the places of the trip it may use (trip_places) and their fixed appointments with their times. Places for the themes come from the same search as search_tour_pois, around the start, the wishes and the appointments, up to theme_places_max (8 when left out); nothing is made up. Answers the stations in order, each with its source and why it is in (found names the theme, the category and where it was found), the walking distance and time from the one before (walk_from_previous: metres, minutes), the arrival and leave times, the stay where the traveller gave it (stay_source given; null when not known: then every later arrival is only the earliest, arrival_is_earliest; the start and the end are passed, stay_source frame), and its opening hours checked against the arrival on that day where they can be read (hours; an earliest arrival only ever shows a conflict that is certain). The order is chosen on estimates; the walk in that order is then planned on foot by TREK's router (the traveller's own routing server where they set one), whose legs give the distances and times (estimate kind routed). Where the router gives no way, they are estimates over the straight distance with an allowance for streets (estimate kind straight_line with street_factor and pace_kmh, and why: router_unavailable, station_off_the_way with the position of the station no way on foot reaches, or ferry_only with the position of the station after which the only way runs by boat, a ferry that is no walking); tell the traveller so. totals keeps walking and stays apart, total_complete only when every stay is known, and minutes_left_for_unknown_stays is what the frame leaves for the stays not known. gaps lists free time before an appointment and at the end, wishes_not_met the wishes and appointments that cannot be kept as asked (closed that day, closed or closing at the arrival, late, after the frame), left_out what was not taken and why, searched what was searched. Wished stations and appointments are always in; an appointment is a must and protected. Show the proposal to the traveller; once they confirm, pass stations_for_create to create_city_walk.",
inputSchema: { tripId: idSchema, ...cityWalkBriefingRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
@@ -60,7 +60,7 @@ export class CityWalkMcp {
@Tool({
name: 'create_city_walk',
description:
'Create a city walk in a trip from stations the traveller confirmed (for instance stations_for_create of propose_city_walk), the way the planner saves a new walk: a new tour of type walk with its stations in their order, each with its name, stay_minutes (0 for a point passed through, null when not known), importance (optional, must, or null for normal), protected (a station any later proposal has to keep where it is), opening_hours as OpenStreetMap writes them and the place of the trip it stands for (linked_place_id; a place that is not of this trip leaves the station unlinked, a tour is refused). The way between the stations is planned on foot by the router the proposal uses, and the walk is saved with it, its distance and its walking time (foot_way planned true). Where the router gives no way (foot_way planned false, with why and position as in the estimate of the proposal), the walk is saved with its stations only (route_pending true, distance and walking time null), and TREK plans the way on foot when someone opens it in the planner and saves it; no straight line is stored as a route. Put it on a day with assign_place_to_day and give it a start time with update_assignment_time. Needs place editing permission on the trip. Create it only after the traveller said yes. A change to a walk already saved is prepared with prepare_tour_change and saved with save_tour_change instead.',
'Create a city walk in a trip from stations the traveller confirmed (for instance stations_for_create of propose_city_walk), the way the planner saves a new walk: a new tour of type walk with its stations in their order, each with its name, stay_minutes (0 for a point passed through, null when not known), importance (optional, must, or null for normal), protected (a station any later proposal has to keep where it is), opening_hours as OpenStreetMap writes them and the place of the trip it stands for (linked_place_id; a place that is not of this trip leaves the station unlinked, a tour is refused). The way between the stations is planned on foot by the router the proposal uses, and the walk is saved with it, its distance and its walking time (foot_way planned true). Where the router gives no way (foot_way planned false, with why and position as in the estimate of the proposal), the walk is saved with its stations only (route_pending true, distance and walking time null), and TREK plans the way on foot when someone opens it in the planner and saves it (for ferry_only it shows the crossing there, and the travellers take it as a ferry leg or move a station before it saves); no straight line is stored as a route. Put it on a day with assign_place_to_day and give it a start time with update_assignment_time. Needs place editing permission on the trip. Create it only after the traveller said yes. A change to a walk already saved is prepared with prepare_tour_change and saved with save_tour_change instead.',
inputSchema: {
tripId: idSchema.describe('The trip to create the walk in'),
name: create.name,
+5 -1
View File
@@ -14,6 +14,7 @@ import { parseRouteGeometry } from './tours.helpers';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable, NotFoundException } from '@nestjs/common';
import {
ferryCrossingsOf,
isClosedLoop,
walkRemainderOf,
walkStationsOf,
@@ -84,14 +85,17 @@ export class TourVisitsService {
* off since counts; nothing changes.
*/
async remaining(tripId: number, placeId: number, dayId: number, assignmentId?: number): Promise<WalkRemaining> {
const [{ walk, progress }, place] = await Promise.all([
const [{ walk, progress }, place, points] = await Promise.all([
this.dayRun(tripId, placeId, dayId, assignmentId),
this.placesRepo.findInTrip(placeId, tripId),
this.waypointsRepo.listForPlace(placeId),
]);
const remainder = walkRemainderOf({
stations: progress.stations.map((station) => ({ ...station, stayMinutes: station.stay_minutes })),
line: parseRouteGeometry(place?.route_geometry),
walkingMinutes: progress.walking_minutes,
// CW-12: a ferry leg is crossed by boat; the saved walking time holds none of it.
crossings: ferryCrossingsOf(points),
});
return {
place_id: placeId,
+2 -2
View File
@@ -132,7 +132,7 @@ export class ToursMcp {
@Tool({
name: 'get_tour_plan',
description:
'Read one saved tour of a trip as the travellers saved it: its facts (tour: place_id, name, tour_type, distance, heights and walking time as stored, null when not known; the routing options as list_tours describes them; revision, raised by every saved change; the days it is on), its ordered control points (start, via and end, each with its sequence and its key, the same across saves; an import without its own shows the two ends of its track), where it came from (provenance) and its legs. Drafts in a browser are never visible: only saved tours exist here, and reading changes nothing, not the tour, its revision nor any visit. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. A control point with stage_end true ends a stage of a multi-day tour, and the next stage begins there; the stages run in the order of the route, the first from the start, the last to the end, and a tour without such a point is a tour of one day. legs lists every step from one control point to the next (from_sequence, to_sequence, from_key, to_key) with its kind: routed (planned by the router), track (part of a recorded line, an import without control points of its own, which no router planned), ferrata (follows a via ferrata, name gives it), free (drawn by hand and checked by nothing; a leg of a planned ski tour is routed where the piste router planned it and free, a straight line between its points, where it did not), portage (a paddle tour carried over land) or horse_route (a ride only: a section of a signposted horse route from OpenStreetMap in the direction the rider chose; the fixed_leg of its control point says signposted, with or against the direction the route is signposted in, null where it is not signposted one way, and access, the metres of each horse=* value its ways carried when it was taken, unknown where untagged; being on a horse route is no permission to ride it); length_km is the length of the line a fixed leg keeps (for the leg of a ski tour the straight line) and null for a routed or track leg, whose length is not stored apart. line gives the saved line in brief: point_count, has_heights and a preview thinned to 200 points; null while the tour waits for its way to be planned. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app), opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known) and visit_status. Visits belong to one day of the walk: name that day with dayId (one of its days) to read the status of each station that day (planned, visited or skipped) and run (the day, its assignment and what is still open); without dayId visit_status and run are null. A station visited or skipped that day is done: do not ask about it again or move it to another day. dayId is for a city walk only. The recorded tracks assigned to the tour (activities, ACT-01) each name their execution (assignment_id and day_id, null for none), the recording (track: its tour in the trip, origin, file name, when it came in) and what the recording measures (metrics: distance_km, elevation_gain_m and elevation_loss_m, null without heights; started_at, ended_at and elapsed_minutes from the first to the last time on its points, pauses included, null when the file carried no times, never estimated); these are the values of the recording, the planned values of the tour stay apart and unchanged. derived names the fields worked out on reading rather than stored with the tour: never present them as saved facts. tour.time_estimate is one of them where present: an estimate from distance and heights (or the pace of a run), never a time the travellers planned or recorded.',
'Read one saved tour of a trip as the travellers saved it: its facts (tour: place_id, name, tour_type, distance, heights and walking time as stored, null when not known; the routing options as list_tours describes them; revision, raised by every saved change; the days it is on), its ordered control points (start, via and end, each with its sequence and its key, the same across saves; an import without its own shows the two ends of its track), where it came from (provenance) and its legs. Drafts in a browser are never visible: only saved tours exist here, and reading changes nothing, not the tour, its revision nor any visit. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. A control point with stage_end true ends a stage of a multi-day tour, and the next stage begins there; the stages run in the order of the route, the first from the start, the last to the end, and a tour without such a point is a tour of one day. legs lists every step from one control point to the next (from_sequence, to_sequence, from_key, to_key) with its kind: routed (planned by the router), track (part of a recorded line, an import without control points of its own, which no router planned), ferrata (follows a via ferrata, name gives it), free (drawn by hand and checked by nothing; a leg of a planned ski tour is routed where the piste router planned it and free, a straight line between its points, where it did not), ferry (a city walk only: a crossing by boat the travellers took on purpose in the planner where no way on foot joins the two stations; the fixed_leg of its control point says where on the line of the leg the boat runs (crossing, the indices of the points it leaves from and lands at), minutes, the time of the boat as the router gave it, null when it gave none, and walk_minutes, the walk to the boat and on from it; the walking time of the walk holds walk_minutes and never the time of the boat), portage (a paddle tour carried over land) or horse_route (a ride only: a section of a signposted horse route from OpenStreetMap in the direction the rider chose; the fixed_leg of its control point says signposted, with or against the direction the route is signposted in, null where it is not signposted one way, and access, the metres of each horse=* value its ways carried when it was taken, unknown where untagged; being on a horse route is no permission to ride it); length_km is the length of the line a fixed leg keeps (for the leg of a ski tour the straight line) and null for a routed or track leg, whose length is not stored apart. line gives the saved line in brief: point_count, has_heights and a preview thinned to 200 points; null while the tour waits for its way to be planned. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app), opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known) and visit_status. Visits belong to one day of the walk: name that day with dayId (one of its days) to read the status of each station that day (planned, visited or skipped) and run (the day, its assignment and what is still open); without dayId visit_status and run are null. A station visited or skipped that day is done: do not ask about it again or move it to another day. dayId is for a city walk only. The recorded tracks assigned to the tour (activities, ACT-01) each name their execution (assignment_id and day_id, null for none), the recording (track: its tour in the trip, origin, file name, when it came in) and what the recording measures (metrics: distance_km, elevation_gain_m and elevation_loss_m, null without heights; started_at, ended_at and elapsed_minutes from the first to the last time on its points, pauses included, null when the file carried no times, never estimated); these are the values of the recording, the planned values of the tour stay apart and unchanged. derived names the fields worked out on reading rather than stored with the tour: never present them as saved facts. tour.time_estimate is one of them where present: an estimate from distance and heights (or the pace of a run), never a time the travellers planned or recorded.',
inputSchema: {
tripId: idSchema,
placeId: idSchema.describe('The place id of the tour, as list_tours gives it'),
@@ -209,7 +209,7 @@ export class ToursMcp {
@Tool({
name: 'get_city_walk_remaining_time',
description:
'Read what is left of a city walk (tour_type walk) on one day it is on, from the stations the travellers checked off that day: the walking time still ahead, the stays still open and the stations still open, each apart. reference says what the estimate counts from: start (nothing visited yet), last_visited (the last station checked off as visited; its stay counts as done) or ambiguous (a station is still planned before the last one visited, so where the walk stands cannot be told: ask the travellers where they are rather than guessing). A status says what was done, never where anyone is now: live_position is always false, TREK knows no live position and no clock. walking.total_minutes is the walking time of the whole walk as saved; walking.remaining_minutes is the part of it still ahead of the reference, the saved walking time shared out by how far along the saved line each station is passed (never by how many stations are open), so stretches already walked are not in it; it holds no stay. walking.upper_bound is true when a station ahead was skipped (listed in skipped_ahead): the saved line still leads through it and the shorter way was never planned, so the time is at most that. walking.after_last_station_minutes is the part from the last station to the end of the line, for a loop (loop true) the way back to its start, still ahead even once every station is done. walking.remaining_minutes is null, with unknown_because, when it cannot be told: no_stations, route_pending (saved with its points only, no line yet), no_walking_time or progress_ambiguous; it is never given as 0 or as the whole walk instead. stays.remaining_known_minutes sums the stays of the open stations as far as entered, stays.unknown lists the open stations whose stay is not known (never counted as 0), stays.optional_known_minutes is the part of the known minutes at optional stations. open_stations lists the stations still planned in order, with importance, stay_minutes and walk_minutes_from_reference (walking time from the reference to it, null where the remaining time is not known); a skipped station is done and not in it. finished is true when no station is open. Visits belong to that day only: the same walk on another day, or a copy, starts from the beginning. A time the traveller tells you (for instance "continue from 2 pm") is theirs to add, not stored here. A walk put on the same day twice runs apart: assignment_id names the run read, the first of the day unless assignmentId names another. derived names the fields worked out on reading from the visits and the saved line (all but walking.total_minutes, the walking time as saved): never present them as stored facts. Reading changes nothing: no status, tour or day plan.',
'Read what is left of a city walk (tour_type walk) on one day it is on, from the stations the travellers checked off that day: the walking time still ahead, the stays still open and the stations still open, each apart. reference says what the estimate counts from: start (nothing visited yet), last_visited (the last station checked off as visited; its stay counts as done) or ambiguous (a station is still planned before the last one visited, so where the walk stands cannot be told: ask the travellers where they are rather than guessing). A status says what was done, never where anyone is now: live_position is always false, TREK knows no live position and no clock. walking.total_minutes is the walking time of the whole walk as saved; walking.remaining_minutes is the part of it still ahead of the reference, the saved walking time shared out by how far along the saved line each station is passed (never by how many stations are open), so stretches already walked are not in it; it holds no stay, and no crossing by boat: a ferry leg the travellers took (get_tour_plan, leg kind ferry) is not walked and takes no share of it. walking.upper_bound is true when a station ahead was skipped (listed in skipped_ahead): the saved line still leads through it and the shorter way was never planned, so the time is at most that. walking.after_last_station_minutes is the part from the last station to the end of the line, for a loop (loop true) the way back to its start, still ahead even once every station is done. walking.remaining_minutes is null, with unknown_because, when it cannot be told: no_stations, route_pending (saved with its points only, no line yet), no_walking_time or progress_ambiguous; it is never given as 0 or as the whole walk instead. stays.remaining_known_minutes sums the stays of the open stations as far as entered, stays.unknown lists the open stations whose stay is not known (never counted as 0), stays.optional_known_minutes is the part of the known minutes at optional stations. open_stations lists the stations still planned in order, with importance, stay_minutes and walk_minutes_from_reference (walking time from the reference to it, null where the remaining time is not known); a skipped station is done and not in it. finished is true when no station is open. Visits belong to that day only: the same walk on another day, or a copy, starts from the beginning. A time the traveller tells you (for instance "continue from 2 pm") is theirs to add, not stored here. A walk put on the same day twice runs apart: assignment_id names the run read, the first of the day unless assignmentId names another. derived names the fields worked out on reading from the visits and the saved line (all but walking.total_minutes, the walking time as saved): never present them as stored facts. Reading changes nothing: no status, tour or day plan.',
inputSchema: {
tripId: idSchema,
placeId: idSchema.describe('The place id of the city walk, as list_tours gives it'),