mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
feat(assistant): the assistant plans hikes over several days in stages
- propose_hike, create_hike and update_tour take stage_end on the stops a day ends at, checked by the tours' own stage rules (never at the start or the end, at most 60 stages) on REST and MCP alike. - A proposal over several days names its stage ends and sets no single day's schedule; its card counts the stages and marks the stops they end at. - New points for a hike in stages keep the keys of the stops they stand on, so a stage keeps its day; they are refused only where they would drop every stage end. - The tours group and the prompt name the stages, how to put each on its day, and what to offer once a tour is planned: places along it, its packing, the GPX file.
This commit is contained in:
@@ -226,9 +226,10 @@ type ToolGroups = Readonly<Record<string, { about: string; controllers: readonly
|
||||
const CORE_TOOL_GROUPS: ToolGroups = {
|
||||
tours: {
|
||||
about:
|
||||
'Tours: hikes, bike tours, city walks, paddling and ski routes; plan, create, change and export them ' +
|
||||
'(hikes with their heights and SAC grades), their packing, the tour library and imports from GPX files ' +
|
||||
'and Google Maps walks.',
|
||||
'Tours: hikes (also over several days in stages), city walks, bike tours, runs, rides, paddling and ski ' +
|
||||
'routes; plan, create, change and export them (hikes with their heights and SAC grades), search along a ' +
|
||||
'tour, check off a city walk under way, the recorded tracks and tour stats, their packing, tours in ' +
|
||||
'collections, the tour library and imports from GPX files and Google Maps walks.',
|
||||
controllers: [
|
||||
'ToursMcp',
|
||||
'CityWalkMcp',
|
||||
|
||||
@@ -85,7 +85,7 @@ export const TOUR_CARDS: DomainCards = {
|
||||
},
|
||||
},
|
||||
prompt: [
|
||||
'Hikes: call propose_hike with a name, and day_id and start_time when known; to compare ways, call it up to three times in one step, each with a variant_label and along false, the way you recommend first (the card shows the first one chosen). Call create_hike only once the user chose a way.',
|
||||
'The app draws a proposed, planned or saved tour (route, profile, stops, times, weather, warnings) and lists of tours as cards: do not repeat their figures or list them again, say in one sentence what matters.',
|
||||
'Hikes: call propose_hike with a name, and day_id and start_time when known; to compare ways, call it up to three times in one step, each with a variant_label and along false, the way you recommend first (the card shows the first one chosen). Call create_hike only once the user chose a way. A hike over several days is one hike: stage_end on each stop a day ends at (no day_id), then each stage on its day with assign_place_to_day and tourStageKey.',
|
||||
'The app draws a proposed, planned or saved tour (route, profile, stops, times, weather, warnings) and lists of tours as cards: do not repeat their figures or list them again, say in one sentence what matters. Once a tour is planned, offer what goes with it: places along it (search_tour_pois), what to pack (get_tour_packing, link_tour_packing), the GPX file (export_tour_gpx).',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -139,11 +139,18 @@ function waypointStops(waypoints: readonly Row[], start: string | null): TourRou
|
||||
atM: metresOf(point.at_m) ?? 0,
|
||||
minutes,
|
||||
clock: clockOf(point.arrival) ?? clockAfter(start, minutes),
|
||||
stageEnd: point.stage_end === true && index > 0 && index < waypoints.length - 1,
|
||||
}),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** How many stages a proposed hike's stops make; null for a day hike, as a saved tour's card says it. */
|
||||
function stagesOf(stops: readonly TourRouteStop[]): number | null {
|
||||
const count = 1 + stops.filter((stop) => stop.stageEnd).length;
|
||||
return count >= 2 ? Math.min(count, 60) : null;
|
||||
}
|
||||
|
||||
/** The places along a proposed way and its highest point, timed between the points around them. */
|
||||
function alongStops(value: Row, own: readonly TourRouteStop[], start: string | null): TourRouteStop[] {
|
||||
const along = rowsIn(value.along)
|
||||
@@ -248,6 +255,7 @@ export function hikeCard(
|
||||
line,
|
||||
stretches: sacStretches(sac?.segments),
|
||||
stops: sortedStops([...own, ...alongStops(plan, own, start)]),
|
||||
stages: stagesOf(own),
|
||||
schedule: scheduleOf(plan.schedule, card),
|
||||
warnings: warningsOf(plan.warnings),
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type HikeHighPoint,
|
||||
type HikePlan,
|
||||
type HikePlanPoint,
|
||||
type HikeStop,
|
||||
type HikeProposal,
|
||||
type HikeProposalSac,
|
||||
type HikeProposeRequest,
|
||||
@@ -39,9 +40,10 @@ import {
|
||||
*/
|
||||
|
||||
/** The points a hike is planned through: as asked, and for a round trip back to its start. */
|
||||
export function hikePointsOf(waypoints: readonly HikePlanPoint[], roundTrip: boolean | undefined): HikePlanPoint[] {
|
||||
export function hikePointsOf(waypoints: readonly HikeStop[], roundTrip: boolean | undefined): HikeStop[] {
|
||||
const [start] = waypoints;
|
||||
return roundTrip === true && start ? [...waypoints, { ...start }] : [...waypoints];
|
||||
// The way back ends where the hike began: the start ends no stage there.
|
||||
return roundTrip === true && start ? [...waypoints, { ...start, stage_end: undefined }] : [...waypoints];
|
||||
}
|
||||
|
||||
/** Whether the way was planned, and why not, as the answers name it. */
|
||||
@@ -49,19 +51,29 @@ export function routeOutcomeOf(plan: HikePlan): HikeRouteOutcome {
|
||||
return plan.kind === 'routed' ? { planned: true } : { planned: false, why: plan.why, position: plan.position };
|
||||
}
|
||||
|
||||
/** The points as a tour keeps them: in order, the first the start and the last the end, with their names. */
|
||||
export function hikeWaypointsOf(points: readonly HikePlanPoint[]): TourWaypoint[] {
|
||||
/**
|
||||
* The points as a tour keeps them: in order, the first the start and the
|
||||
* last the end, with their names, and on a hike over several days the stops
|
||||
* its stages end at.
|
||||
*/
|
||||
export function hikeWaypointsOf(points: readonly HikeStop[]): TourWaypoint[] {
|
||||
const last = points.length - 1;
|
||||
return points.map(({ lat, lng, name, poi_category }, sequence) => ({
|
||||
return points.map(({ lat, lng, name, poi_category, stage_end }, sequence) => ({
|
||||
lat,
|
||||
lng,
|
||||
...(name ? { name } : {}),
|
||||
...(poi_category ? { poi_category } : {}),
|
||||
role: sequence === 0 ? 'start' : sequence === last ? 'end' : 'via',
|
||||
sequence,
|
||||
...(stage_end === true && sequence > 0 && sequence < last ? { stage_end: true } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** How many stages a hike's points make: one for a day hike. */
|
||||
export function hikeStagesOf(points: readonly HikeStop[]): number {
|
||||
return 1 + points.filter((point) => point.stage_end === true).length;
|
||||
}
|
||||
|
||||
/** The way a saved hike keeps from a plan: its whole line and walking time, or none, and how the planning went. */
|
||||
export interface KeptWay {
|
||||
line: GeometryPoint[] | null;
|
||||
@@ -93,7 +105,7 @@ export function keptWayOf({ plan, line }: PlannedHike): KeptWay {
|
||||
*/
|
||||
export function hikeCreateCommandOf(
|
||||
request: HikeCreateRequest,
|
||||
points: readonly HikePlanPoint[],
|
||||
points: readonly HikeStop[],
|
||||
way: KeptWay,
|
||||
limit: number,
|
||||
): TourCreateCommand {
|
||||
@@ -135,13 +147,15 @@ function savedPointAt(point: { lat: number; lng: number }, saved: readonly TourW
|
||||
* keeps its link to the trip's place it stands for, and its name where the
|
||||
* new one gives none. A saved point no new one stands on goes, with its link.
|
||||
*/
|
||||
export function repointedWaypoints(points: readonly HikePlanPoint[], saved: readonly TourWaypoint[]): TourWaypoint[] {
|
||||
export function repointedWaypoints(points: readonly HikeStop[], saved: readonly TourWaypoint[]): TourWaypoint[] {
|
||||
return hikeWaypointsOf(points).map((point) => {
|
||||
const same = savedPointAt(point, saved);
|
||||
if (!same) return point;
|
||||
const name = point.name ?? same.name;
|
||||
// Its key too: a stage is put on its day by the key of the stop it starts at.
|
||||
return {
|
||||
...point,
|
||||
...(same.key ? { key: same.key } : {}),
|
||||
...(name ? { name } : {}),
|
||||
...(same.linked_place_id ? { linked_place_id: same.linked_place_id } : {}),
|
||||
};
|
||||
@@ -319,7 +333,7 @@ export interface HikeProposalContext {
|
||||
*/
|
||||
export function hikeProposalOf(
|
||||
request: HikeProposeRequest,
|
||||
points: readonly HikePlanPoint[],
|
||||
points: readonly HikeStop[],
|
||||
planned: PlannedHike,
|
||||
context: HikeProposalContext,
|
||||
): HikeProposal {
|
||||
@@ -329,8 +343,9 @@ export function hikeProposalOf(
|
||||
const heights = routed?.has_heights && line ? heightRangeOf(line) : null;
|
||||
const minutes = routed && line && positions ? waypointMinutesOf(line, positions, routed.duration_minutes) : null;
|
||||
const arrivals = arrivalsOf(request.start_time ?? null, minutes ?? points.map(() => null));
|
||||
// One day's sun and hours say nothing about a hike over several days.
|
||||
const day =
|
||||
routed && context.date
|
||||
routed && context.date && hikeStagesOf(points) === 1
|
||||
? hikeScheduleOf({
|
||||
date: context.date,
|
||||
dayId: context.dayId,
|
||||
@@ -357,11 +372,12 @@ export function hikeProposalOf(
|
||||
: {}),
|
||||
sac: proposalSacOf(routed?.grades ?? null),
|
||||
high_point: routed ? highPointOf(line) : null,
|
||||
waypoints: points.map(({ lat, lng, name, poi_category }, index) => ({
|
||||
waypoints: points.map(({ lat, lng, name, poi_category, stage_end }, index) => ({
|
||||
lat,
|
||||
lng,
|
||||
name: name || null,
|
||||
poi_category: poi_category ?? null,
|
||||
stage_end: stage_end === true,
|
||||
at_m: positions?.[index] ?? null,
|
||||
snapped_m: routed ? (planned.snapped?.[index] ?? null) : null,
|
||||
minutes: minutes?.[index] ?? null,
|
||||
|
||||
@@ -55,7 +55,7 @@ export class HikePlanMcp {
|
||||
@Tool({
|
||||
name: 'propose_hike',
|
||||
description:
|
||||
"Plan a hike for a trip and show it to the traveller, without saving anything; give it a name. The way on foot runs through waypoints in order (the start, the points on the way such as a hut or a summit, each with its poi_category where it is one, the end; with round_trip it comes back to the start) and takes no path harder than max_hiking_difficulty on the SAC scale (1 for T1 hiking to 6 for T6 difficult alpine hiking, 2 when left out), avoiding steep climbs, steps or ferries where there is a way round when hiking_preferences asks so. TREK's router plans it as the tour planner does. The answer gives distance_m, ascent_m and descent_m, the walking time without breaks (duration_minutes: by DIN 33466, the rule hiking signposts use, where the heights are known, duration_basis din33466; else the router's, duration_basis router), min_ele_m, max_ele_m and high_point, the SAC grades OpenStreetMap records along the way (sac: highest, the graded stretches in metres along the way, and unknown_share, the part with no grade, which is unknown and never easy), every point with at_m (where it lies along the way), snapped_m (how far it was moved onto a way), minutes from the start and, with start_time, its arrival, and along: summits, huts, water and viewpoints near the way (null with along false or where they could not be searched). With day_id (a day of the trip, whose date it takes) or date, schedule gives the day: the arrival (next_day past midnight), sunrise and sunset, the weather by the hour at the highest point (weather forecast, or climate further out: last year's day) and, without start_time, latest_start to be back half an hour before sunset; warnings name a start before sunrise (dark_start), an arrival after sunset (dark_end) or within the hour before it (dusk), and the first walking hour with thunderstorms (thunder) or likely rain (rain). Where no way was planned, route says why: no_valhalla (this TREK has no router that knows hiking grades), router_unavailable (try again later), no_way (nothing within the limit joins the points: a higher limit or other points may), point_off_the_way (unreachable names the point and how far it is from any way) or too_long (longer than one hike is planned for: split it into shorter hikes). To compare ways, propose each one with its own variant_label, such as 'short and steep' or 'by the lake', and along false. The chat shows the route, its profile, times, weather and warnings as a card: never list coordinates, the hours or the figures, sum the hike up in a sentence and say what matters. Propose first and create the hike with create_hike only after the traveller said yes.",
|
||||
"Plan a hike for a trip and show it to the traveller, without saving anything; give it a name. A hike over several days (a trek from hut to hut) is one hike: mark each stop a day ends at with stage_end, and the card shows its stages. The way on foot runs through waypoints in order (the start, the points on the way such as a hut or a summit, each with its poi_category where it is one, the end; with round_trip it comes back to the start) and takes no path harder than max_hiking_difficulty on the SAC scale (1 for T1 hiking to 6 for T6 difficult alpine hiking, 2 when left out), avoiding steep climbs, steps or ferries where there is a way round when hiking_preferences asks so. TREK's router plans it as the tour planner does. The answer gives distance_m, ascent_m and descent_m, the walking time without breaks (duration_minutes: by DIN 33466, the rule hiking signposts use, where the heights are known, duration_basis din33466; else the router's, duration_basis router), min_ele_m, max_ele_m and high_point, the SAC grades OpenStreetMap records along the way (sac: highest, the graded stretches in metres along the way, and unknown_share, the part with no grade, which is unknown and never easy), every point with at_m (where it lies along the way), snapped_m (how far it was moved onto a way), minutes from the start and, with start_time, its arrival, and along: summits, huts, water and viewpoints near the way (null with along false or where they could not be searched). With day_id (a day of the trip, whose date it takes) or date, schedule gives the day: the arrival (next_day past midnight), sunrise and sunset, the weather by the hour at the highest point (weather forecast, or climate further out: last year's day) and, without start_time, latest_start to be back half an hour before sunset; warnings name a start before sunrise (dark_start), an arrival after sunset (dark_end) or within the hour before it (dusk), and the first walking hour with thunderstorms (thunder) or likely rain (rain). Where no way was planned, route says why: no_valhalla (this TREK has no router that knows hiking grades), router_unavailable (try again later), no_way (nothing within the limit joins the points: a higher limit or other points may), point_off_the_way (unreachable names the point and how far it is from any way) or too_long (longer than one hike is planned for: split it into shorter hikes). To compare ways, propose each one with its own variant_label, such as 'short and steep' or 'by the lake', and along false. The chat shows the route, its profile, times, weather and warnings as a card: never list coordinates, the hours or the figures, sum the hike up in a sentence and say what matters. Propose first and create the hike with create_hike only after the traveller said yes.",
|
||||
inputSchema: { tripId: idSchema, ...hikeProposeRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
@@ -69,7 +69,7 @@ export class HikePlanMcp {
|
||||
@Tool({
|
||||
name: 'create_hike',
|
||||
description:
|
||||
"Create a hike in a trip from points the traveller confirmed, usually the name and waypoints of a proposal of propose_hike with its max_hiking_difficulty, hiking_preferences and round_trip. It is saved the way the planner saves a new hike: a tour of type hike with its points in order (start, points on the way, end, with their names and poi_category), the way on foot planned by the same router as the proposal and kept whole (the very line a proposal of the last minutes showed), with its heights where every point has one, its distance, ascent, descent and walking time, its hiking limit and preferences, and planned_duration_minutes (the time out, breaks included) where given. Where the router did not answer, for the way or its heights (route planned false, why router_unavailable), the hike is saved with its points only (route_pending true) and TREK plans the way when someone opens it in the planner; no straight line is stored. Any other reason the proposal names (no_valhalla, no_way, point_off_the_way, too_long) refuses the hike with why, and nothing is saved. With day_id the hike is put on that day of the trip in the same write, start_time kept as the start of that visit (which needs day editing permission too); assignment names the visit, null without a day. The answer is the tour as list_tours reads it (tour.place_id is the tour's id, its revision 1), route and assignment. Put it on a day later with assign_place_to_day, link it to the packing list with link_tour_packing, export it with export_tour_gpx, change it with update_tour. Needs place editing permission on the trip. Create it only after the traveller said yes.",
|
||||
"Create a hike in a trip from points the traveller confirmed, usually the name and waypoints of a proposal of propose_hike with its max_hiking_difficulty, hiking_preferences and round_trip, and its stage_end marks for a hike over several days; then put each stage on its day with assign_place_to_day and tourStageKey (the key of the stop the stage starts at, from the stage_stops list_tours gives); leave day_id out for such a hike, which would put the whole hike on one day. It is saved the way the planner saves a new hike: a tour of type hike with its points in order (start, points on the way, end, with their names and poi_category), the way on foot planned by the same router as the proposal and kept whole (the very line a proposal of the last minutes showed), with its heights where every point has one, its distance, ascent, descent and walking time, its hiking limit and preferences, and planned_duration_minutes (the time out, breaks included) where given. Where the router did not answer, for the way or its heights (route planned false, why router_unavailable), the hike is saved with its points only (route_pending true) and TREK plans the way when someone opens it in the planner; no straight line is stored. Any other reason the proposal names (no_valhalla, no_way, point_off_the_way, too_long) refuses the hike with why, and nothing is saved. With day_id the hike is put on that day of the trip in the same write, start_time kept as the start of that visit (which needs day editing permission too); assignment names the visit, null without a day. The answer is the tour as list_tours reads it (tour.place_id is the tour's id, its revision 1), route and assignment. Put it on a day later with assign_place_to_day, link it to the packing list with link_tour_packing, export it with export_tour_gpx, change it with update_tour. Needs place editing permission on the trip. Create it only after the traveller said yes.",
|
||||
inputSchema: { tripId: idSchema.describe('The trip to create the hike in'), ...hikeCreateRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
@@ -88,7 +88,7 @@ export class HikePlanMcp {
|
||||
@Tool({
|
||||
name: 'update_tour',
|
||||
description:
|
||||
"Change a saved hike (tour_type hike) against the revision it was read at: expected_revision is tour.revision from get_tour_plan or list_tours. A new name, description or planned_duration_minutes keeps the hike's way. New waypoints (start to end; a round trip ends on its start), a new max_hiking_difficulty or new hiking_preferences plan the way anew as propose_hike plans it, and so does a hike still waiting for its way (route_pending); propose the new way to the traveller first. A hike changed since that revision is refused as a conflict: read it again and show the traveller what changed. Where the new way cannot be planned, or not with its heights, nothing is saved and the hike keeps its way; the error says why. A new point standing where a saved one stood (within 25 m) keeps that point's link to a place of the trip and its name. A hike with a section drawn by hand (a via ferrata, a free line, a ferry) or split into stages takes new points only in the planner, and the hand-drawn one no new limit or preferences either: those changes are refused here. A recorded track without points of its own (an import) takes only new waypoints, which plan it over and keep the recording. Any other type is refused: change a city walk with prepare_tour_change and save_tour_change. The hike stays on its days. The answer is the tour as saved, with its new revision, route and replanned. Needs place editing permission on the trip. Change it only after the traveller said yes.",
|
||||
"Change a saved hike (tour_type hike) against the revision it was read at: expected_revision is tour.revision from get_tour_plan or list_tours. A new name, description or planned_duration_minutes keeps the hike's way. New waypoints (start to end; a round trip ends on its start), a new max_hiking_difficulty or new hiking_preferences plan the way anew as propose_hike plans it, and so does a hike still waiting for its way (route_pending); propose the new way to the traveller first. A hike changed since that revision is refused as a conflict: read it again and show the traveller what changed. Where the new way cannot be planned, or not with its heights, nothing is saved and the hike keeps its way; the error says why. A new point standing where a saved one stood (within 25 m) keeps that point's link to a place of the trip and its name. A hike with a section drawn by hand (a via ferrata, a free line, a ferry) takes new points, a new limit or new preferences only in the planner: those changes are refused here. A hike split into stages takes new points only with stage_end given again on each stop a day ends at, and its stages keep their days where a stop stays where it was. A recorded track without points of its own (an import) takes only new waypoints, which plan it over and keep the recording. Any other type is refused: change a city walk with prepare_tour_change and save_tour_change. The hike stays on its days. The answer is the tour as saved, with its new revision, route and replanned. Needs place editing permission on the trip. Change it only after the traveller said yes.",
|
||||
inputSchema: { tripId: idSchema, ...tourUpdateRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
HIKE_ALONG_CATEGORIES,
|
||||
HIKE_ALONG_CORRIDOR_M,
|
||||
HIKE_PLAN_MAX_WAYPOINTS,
|
||||
hikeStageIssues,
|
||||
hikingPreferencesOf,
|
||||
TOUR_POI_MAX_LINE_POINTS,
|
||||
tourGpxImportFieldsSchema,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
type HikeAlongPlace,
|
||||
type HikeCreateRequest,
|
||||
type HikePlanPoint,
|
||||
type HikeStop,
|
||||
type HikeProposal,
|
||||
type HikeProposeRequest,
|
||||
type HikingPreferences,
|
||||
@@ -109,14 +111,21 @@ const HAND_DRAWN =
|
||||
* ends of its stages would go with the points they belong to, and a stage
|
||||
* that goes would leave its day. Null where new points take nothing away.
|
||||
*/
|
||||
function repointingRefusal(saved: readonly TourWaypoint[]): string | null {
|
||||
function repointingRefusal(saved: readonly TourWaypoint[], points: readonly HikeStop[]): string | null {
|
||||
if (saved.some((point) => point.fixed_leg)) return HAND_DRAWN;
|
||||
if (saved.some((point) => point.stage_end)) {
|
||||
return 'This hike is split into stages over several days, which new points would end: change its points in the planner. A new limit or new preferences plan it anew here, its stages kept.';
|
||||
// New points carry the stages of the hike themselves; without any, its stages would silently end.
|
||||
if (saved.some((point) => point.stage_end) && !points.some((point) => point.stage_end)) {
|
||||
return 'This hike is split into stages over several days: give stage_end again on each stop a day ends at, or the stages would end. A new limit or new preferences alone plan it anew here, its stages kept.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Stage ends that do not hold (at the start or the end, too many stages): refused with their reason. */
|
||||
function refuseBadStages(points: readonly HikeStop[], roundTrip: boolean | undefined): void {
|
||||
const issues = hikeStageIssues(points, roundTrip);
|
||||
if (issues.length > 0) throw badRequest(issues.map((issue) => issue.message).join('; '));
|
||||
}
|
||||
|
||||
/**
|
||||
* A hike planned with the traveller over MCP, end to end on the server: the
|
||||
* proposal reads only and may be asked for as often as ways are compared;
|
||||
@@ -165,6 +174,7 @@ export class HikePlanService {
|
||||
* best effort: without them the proposal still answers.
|
||||
*/
|
||||
async propose(userId: number, tripId: number, request: HikeProposeRequest): Promise<HikeProposal> {
|
||||
refuseBadStages(request.waypoints, request.round_trip);
|
||||
const day = await this.dayOf(tripId, request.day_id, request.date);
|
||||
const points = hikePointsOf(request.waypoints, request.round_trip);
|
||||
const { max_hiking_difficulty: limit, hiking_preferences: preferences } = request;
|
||||
@@ -231,6 +241,7 @@ export class HikePlanService {
|
||||
* drop onto the day tell it.
|
||||
*/
|
||||
async create(userId: number, tripId: number, request: HikeCreateRequest): Promise<HikeCreated> {
|
||||
refuseBadStages(request.waypoints, request.round_trip);
|
||||
const day = request.day_id === undefined ? null : await this.dayOf(tripId, request.day_id, undefined);
|
||||
const points = hikePointsOf(request.waypoints, request.round_trip);
|
||||
const { max_hiking_difficulty: limit, hiking_preferences: preferences } = request;
|
||||
@@ -295,7 +306,8 @@ export class HikePlanService {
|
||||
'This hike is a recorded track without points of its own: give waypoints to plan it over (the recorded track is kept), or rename it with update_place.',
|
||||
);
|
||||
}
|
||||
const repointing = request.waypoints ? repointingRefusal(saved.waypoints) : null;
|
||||
if (request.waypoints) refuseBadStages(request.waypoints, false);
|
||||
const repointing = request.waypoints ? repointingRefusal(saved.waypoints, request.waypoints) : null;
|
||||
if (repointing) throw badRequest(repointing);
|
||||
const kept = hikingPreferencesOf(tour.hiking_preferences);
|
||||
// A recorded track keeps no limit of its own; planned over, it takes the planner's.
|
||||
|
||||
@@ -202,8 +202,8 @@ function proposalFor(asked: HikeProposeRequest): HikeProposal {
|
||||
sac: { segments: [{ from_m: 0, to_m: 1112, sac: 2 }], highest: 2, unknown_share: 0 },
|
||||
high_point: { lat: 47.61, lng: 11.3, ele_m: 880, at_m: 1112 },
|
||||
waypoints: [
|
||||
{ ...WAYPOINTS[0], poi_category: null, at_m: 0, snapped_m: 2, minutes: 0, arrival: null },
|
||||
{ ...WAYPOINTS[1], at_m: 1112, snapped_m: 3, minutes: 22, arrival: null },
|
||||
{ ...WAYPOINTS[0], poi_category: null, at_m: 0, snapped_m: 2, minutes: 0, arrival: null, stage_end: false },
|
||||
{ ...WAYPOINTS[1], at_m: 1112, snapped_m: 3, minutes: 22, arrival: null, stage_end: false },
|
||||
],
|
||||
along: null,
|
||||
max_hiking_difficulty: 2,
|
||||
|
||||
@@ -180,6 +180,16 @@ describe('the card of a proposed hike', () => {
|
||||
expect(shown.warnings).toEqual([{ kind: 'thunder', at: '08:00', count: null }]);
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-001b: a hike over several days counts its stages and marks the stops they end at', () => {
|
||||
const base = proposal();
|
||||
const [start, summit] = base.waypoints as Array<Record<string, unknown>>;
|
||||
const hut = { ...start, lat: 47.605, name: 'Hut', at_m: 556, minutes: 11, arrival: '08:11', stage_end: true };
|
||||
const shown = route(answerCardOf(...input('propose_hike', { ...base, waypoints: [start, hut, summit] })));
|
||||
expect(shown.stages).toBe(2);
|
||||
expect(shown.stops.filter((stop) => stop.stageEnd).map((stop) => stop.name)).toEqual(['Hut']);
|
||||
expect(route(answerCardOf(...input('propose_hike', proposal()))).stages).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-002: lists the points and the places along the way in the order walked, timed between', () => {
|
||||
const shown = route(answerCardOf(...input('propose_hike', proposal())));
|
||||
expect(shown.stops.map((stop) => [stop.role, stop.kind, stop.atM, stop.minutes, stop.clock])).toEqual([
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
hikeCreateCommandOf,
|
||||
hikePointsOf,
|
||||
hikeProposalOf,
|
||||
hikeStagesOf,
|
||||
hikeWaypointsOf,
|
||||
keptWayOf,
|
||||
positionsAlong,
|
||||
@@ -35,7 +36,7 @@ import {
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// HIKE-PLAN-001 to HIKE-PLAN-014
|
||||
// HIKE-PLAN-001 to HIKE-PLAN-017
|
||||
|
||||
/** A plan with its line, as the router answers it: without the router's legs and snaps unless given. */
|
||||
const planned = (
|
||||
@@ -393,3 +394,52 @@ describe('where and when a proposed hike reaches its points', () => {
|
||||
expect(alongPlacesOf([], LINE)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a hike over several days', () => {
|
||||
it('HIKE-PLAN-015: keeps a stage end only on a stop between the start and the end, and the way back of a round trip ends none', () => {
|
||||
const hut = { lat: 47.002, lng: 11, name: 'Hut', stage_end: true };
|
||||
expect(hikeWaypointsOf([{ ...START, stage_end: true }, hut, SUMMIT]).map((point) => point.stage_end)).toEqual([
|
||||
undefined,
|
||||
true,
|
||||
undefined,
|
||||
]);
|
||||
expect(hikeStagesOf([START, hut, SUMMIT])).toBe(2);
|
||||
expect(hikeStagesOf([START, SUMMIT])).toBe(1);
|
||||
const loop = hikePointsOf([START, hut, SUMMIT], true);
|
||||
expect(loop.at(-1)).toMatchObject({ lat: START.lat, lng: START.lng });
|
||||
expect(loop.at(-1)?.stage_end).toBeUndefined();
|
||||
});
|
||||
|
||||
it('HIKE-PLAN-016: a proposal over several days names its stage ends and sets no schedule of a single day', () => {
|
||||
const points = [START, { lat: 47.002, lng: 11, name: 'Hut', stage_end: true }, SUMMIT];
|
||||
const day: HikeProposalContext = { dayId: 3, date: '2027-07-10', along: null, weather: null };
|
||||
const proposal = hikeProposalOf({ name: 'Trek', waypoints: points }, points, planned(ROUTED, LINE), day);
|
||||
expect(proposal.waypoints.map((point) => point.stage_end)).toEqual([false, true, false]);
|
||||
expect(proposal.schedule).toBeNull();
|
||||
expect(hikeProposalSchema.safeParse(proposal).success).toBe(true);
|
||||
const single = hikeProposalOf(
|
||||
{ name: 'Day', waypoints: [START, SUMMIT] },
|
||||
[START, SUMMIT],
|
||||
planned(ROUTED, LINE),
|
||||
day,
|
||||
);
|
||||
expect(single.schedule).not.toBeNull();
|
||||
});
|
||||
|
||||
it('HIKE-PLAN-017: new points keep the key of a saved stop they stand on, so a stage keeps its day', () => {
|
||||
const saved = [
|
||||
{ lat: 47, lng: 11, role: 'start' as const, sequence: 0, key: 'k-start' },
|
||||
{ lat: 47.002, lng: 11, role: 'via' as const, sequence: 1, key: 'k-hut', stage_end: true },
|
||||
{ lat: 47.004, lng: 11, role: 'end' as const, sequence: 2, key: 'k-end' },
|
||||
];
|
||||
const repointed = repointedWaypoints(
|
||||
[START, { lat: 47.002, lng: 11, stage_end: true }, { lat: 47.006, lng: 11 }],
|
||||
saved,
|
||||
);
|
||||
expect(repointed.map((point) => [point.key, point.stage_end])).toEqual([
|
||||
['k-start', undefined],
|
||||
['k-hut', true],
|
||||
[undefined, undefined],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -464,6 +464,7 @@ describe('HikePlanMcp: propose_hike', () => {
|
||||
snapped_m: 12,
|
||||
minutes: 0,
|
||||
arrival: '08:30',
|
||||
stage_end: false,
|
||||
},
|
||||
{
|
||||
lat: 47.505,
|
||||
@@ -474,6 +475,7 @@ describe('HikePlanMcp: propose_hike', () => {
|
||||
snapped_m: 3,
|
||||
minutes: 26,
|
||||
arrival: '08:56',
|
||||
stage_end: false,
|
||||
},
|
||||
{
|
||||
lat: 47.51,
|
||||
@@ -484,6 +486,7 @@ describe('HikePlanMcp: propose_hike', () => {
|
||||
snapped_m: 40,
|
||||
minutes: 52,
|
||||
arrival: '09:22',
|
||||
stage_end: false,
|
||||
},
|
||||
]);
|
||||
expect(proposal.high_point).toEqual({ lat: 47.51, lng: 11.1, ele_m: 1000, at_m: 1112 });
|
||||
@@ -635,6 +638,17 @@ describe('HikePlanMcp: propose_hike', () => {
|
||||
),
|
||||
).rejects.toThrow('Day not found');
|
||||
});
|
||||
|
||||
it('HIKE-MCP-022: refuses a stage end at the start or the end of a hike, which ends no day', async () => {
|
||||
const f = await fixture();
|
||||
const [first, ...rest] = WAYPOINTS;
|
||||
await expect(
|
||||
mcp.proposeHike(
|
||||
{ tripId: f.trip.id, name: 'Wrong', waypoints: [{ ...first, stage_end: true }, ...rest] },
|
||||
ctx(f.user.id),
|
||||
),
|
||||
).rejects.toThrow('a stage ends at a stop between start and end');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HikePlanMcp: create_hike', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
HIKE_MAX_WAYPOINTS,
|
||||
HIKE_PROPOSAL_SAC_SEGMENTS,
|
||||
hikeCreateRequestSchema,
|
||||
hikeStageIssues,
|
||||
hikeProposalSchema,
|
||||
hikeProposeRequestSchema,
|
||||
hikeRouteOutcomeSchema,
|
||||
@@ -50,6 +51,7 @@ const proposal = {
|
||||
snapped_m: 12,
|
||||
minutes: 0,
|
||||
arrival: '08:30',
|
||||
stage_end: false,
|
||||
},
|
||||
{
|
||||
lat: 47.41,
|
||||
@@ -60,6 +62,7 @@ const proposal = {
|
||||
snapped_m: 4,
|
||||
minutes: 200,
|
||||
arrival: '11:50',
|
||||
stage_end: false,
|
||||
},
|
||||
],
|
||||
along: [{ name: 'Hut', category: 'shelter', lat: 47.405, lng: 11.101, at_m: 4100, off_m: 40 }],
|
||||
@@ -237,3 +240,25 @@ describe('a hike created, changed or taken in from a file over MCP', () => {
|
||||
expect(Object.keys(tourGpxImportRequestSchema.shape)).toContain('tour_type');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a hike over several days', () => {
|
||||
const three = [point(0), { ...point(1), stage_end: true }, point(2)];
|
||||
|
||||
it('TOUR-HIKE-SCHEMA-010: takes a stage end on a stop between start and end, in a proposal, a create and a change', () => {
|
||||
expect(hikeProposeRequestSchema.safeParse({ name: 'Trek', waypoints: three }).success).toBe(true);
|
||||
expect(hikeCreateRequestSchema.safeParse({ name: 'Trek', waypoints: three }).success).toBe(true);
|
||||
expect(tourUpdateRequestSchema.safeParse({ placeId: 4, expected_revision: 2, waypoints: three }).success).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('TOUR-HIKE-SCHEMA-011: refuses a stage end at the start or the end; the last point given of a round trip is a stop on the way', () => {
|
||||
const atStart = [{ ...point(0), stage_end: true }, point(1)];
|
||||
const atEnd = [point(0), { ...point(1), stage_end: true }];
|
||||
expect(hikeProposeRequestSchema.safeParse({ name: 'X', waypoints: atStart }).success).toBe(false);
|
||||
expect(hikeCreateRequestSchema.safeParse({ name: 'X', waypoints: atEnd }).success).toBe(false);
|
||||
expect(hikeProposeRequestSchema.safeParse({ name: 'X', waypoints: atEnd, round_trip: true }).success).toBe(true);
|
||||
expect(hikeStageIssues(atEnd, false).map((issue) => issue.path)).toEqual([['waypoints', 1, 'stage_end']]);
|
||||
expect(hikeStageIssues(atEnd, true)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { hikeScheduleSchema, hikeWarningSchema, HIKE_WARNING_KINDS } from './hik
|
||||
import { includeGeometryFlagSchema } from './tour-display.schema';
|
||||
import { tourGpxImportFieldsSchema } from './tour-gpx-import.schema';
|
||||
import { tourPoiCategorySchema, type TourPoiCategory } from './tour-poi-category';
|
||||
import { tourStageIssues } from './tour-stages';
|
||||
import {
|
||||
hikingPreferencesSchema,
|
||||
plannedTourDurationMinutesSchema,
|
||||
@@ -54,14 +55,57 @@ export const HIKE_ALONG_MAX = 12;
|
||||
/** The largest GPX text a tool takes in, as the road trip's GPX import does. */
|
||||
export const TOUR_GPX_TEXT_MAX = 1_000_000;
|
||||
|
||||
/**
|
||||
* A point of a hike the assistant plans: the planning point, and on a hike
|
||||
* over several days whether a day's stage ends there (MDT-01), where the
|
||||
* night is spent and the next stage starts.
|
||||
*/
|
||||
const hikeStopSchema = hikePlanPointSchema
|
||||
.extend({
|
||||
stage_end: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'On a hike over several days: true on the point where a day ends (the hut or village the night is spent at), where the next stage starts. Only on points between the start and the end; leave it out on a day hike',
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
export type HikeStop = z.infer<typeof hikeStopSchema>;
|
||||
|
||||
const hikeWaypointsSchema = z
|
||||
.array(hikePlanPointSchema)
|
||||
.array(hikeStopSchema)
|
||||
.min(2)
|
||||
.max(HIKE_MAX_WAYPOINTS)
|
||||
.describe(
|
||||
'The points the hike goes through in order: its start, the points on the way (a hut, a summit, a lake) and its end. For a round trip leave the end out and set round_trip',
|
||||
'The points the hike goes through in order: its start, the points on the way (a hut, a summit, a lake) and its end. For a round trip leave the end out and set round_trip. A hike over several days marks the stop of each night with stage_end',
|
||||
);
|
||||
|
||||
/**
|
||||
* Where a hike's stage ends do not hold, as the tour's own rules read them
|
||||
* (`tourStageIssues`): never at the start or the end, at most MAX_TOUR_STAGES
|
||||
* stages. A round trip comes back to its start, so its last point given is a
|
||||
* stop on the way.
|
||||
*/
|
||||
export function hikeStageIssues(
|
||||
points: readonly Pick<HikeStop, 'lat' | 'lng' | 'stage_end'>[],
|
||||
roundTrip: boolean | undefined,
|
||||
): ReturnType<typeof tourStageIssues> {
|
||||
const last = roundTrip === true ? points.length : points.length - 1;
|
||||
return tourStageIssues(
|
||||
points.map((point, index) => ({
|
||||
role: index === 0 ? ('start' as const) : index === last ? ('end' as const) : ('via' as const),
|
||||
stage_end: point.stage_end,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function refineStages(value: { waypoints?: readonly HikeStop[]; round_trip?: boolean }, ctx: z.RefinementCtx): void {
|
||||
if (!value.waypoints) return;
|
||||
for (const issue of hikeStageIssues(value.waypoints, value.round_trip)) {
|
||||
ctx.addIssue({ code: 'custom', path: issue.path, message: issue.message });
|
||||
}
|
||||
}
|
||||
|
||||
const hikeLimitSchema = tourMaxHikingDifficultySchema.describe(
|
||||
'The hardest SAC grade the way may take, 1 (T1, hiking) to 6 (T6, difficult alpine hiking); 2 (T2, mountain hiking) when left out',
|
||||
);
|
||||
@@ -114,7 +158,8 @@ export const hikeProposeRequestSchema = z
|
||||
),
|
||||
include_geometry: includeGeometryFlagSchema,
|
||||
})
|
||||
.strict();
|
||||
.strict()
|
||||
.superRefine(refineStages);
|
||||
export type HikeProposeRequest = z.infer<typeof hikeProposeRequestSchema>;
|
||||
|
||||
/** Whether the way was planned, and why not: the plan's reasons, with the point no way reaches. */
|
||||
@@ -175,6 +220,8 @@ export const hikeProposalWaypointSchema = z.object({
|
||||
minutes: z.number().int().nonnegative().nullable(),
|
||||
/** When it is reached, HH:MM; null without a start. */
|
||||
arrival: clockTimeSchema.nullable(),
|
||||
/** Whether a day's stage ends here, on a hike over several days. */
|
||||
stage_end: z.boolean(),
|
||||
});
|
||||
export type HikeProposalWaypoint = z.infer<typeof hikeProposalWaypointSchema>;
|
||||
|
||||
@@ -240,7 +287,8 @@ export const hikeCreateRequestSchema = z
|
||||
.optional()
|
||||
.describe('When the hike starts on that day, HH:MM, kept as the start of its visit; only with day_id'),
|
||||
})
|
||||
.strict();
|
||||
.strict()
|
||||
.superRefine(refineStages);
|
||||
export type HikeCreateRequest = z.infer<typeof hikeCreateRequestSchema>;
|
||||
|
||||
export const tourUpdateRequestSchema = z
|
||||
@@ -260,7 +308,8 @@ export const tourUpdateRequestSchema = z
|
||||
hiking_preferences: hikePreferencesSchema.optional(),
|
||||
planned_duration_minutes: plannedMinutesSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
.strict()
|
||||
.superRefine(refineStages);
|
||||
export type TourUpdateRequest = z.infer<typeof tourUpdateRequestSchema>;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user