@curless/hotel-storefront 0.53.0 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/about.d.ts +24 -0
  2. package/dist/about.d.ts.map +1 -0
  3. package/dist/about.js +28 -0
  4. package/dist/about.js.map +1 -0
  5. package/dist/aggregate/make.js +2 -2
  6. package/dist/aggregate/make.js.map +1 -1
  7. package/dist/backend.d.ts +15 -0
  8. package/dist/backend.d.ts.map +1 -1
  9. package/dist/backend.js.map +1 -1
  10. package/dist/card.generated.d.ts +1 -1
  11. package/dist/card.generated.js +2 -2
  12. package/dist/card.generated.js.map +1 -1
  13. package/dist/config.d.ts +33 -2
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js.map +1 -1
  16. package/dist/coupon-words.d.ts +12 -0
  17. package/dist/coupon-words.d.ts.map +1 -0
  18. package/dist/coupon-words.js +19 -0
  19. package/dist/coupon-words.js.map +1 -0
  20. package/dist/dates.js +1 -1
  21. package/dist/dates.js.map +1 -1
  22. package/dist/demo-hotels.d.ts +6 -0
  23. package/dist/demo-hotels.d.ts.map +1 -1
  24. package/dist/demo-hotels.js +57 -17
  25. package/dist/demo-hotels.js.map +1 -1
  26. package/dist/fence.d.ts.map +1 -1
  27. package/dist/fence.js +4 -3
  28. package/dist/fence.js.map +1 -1
  29. package/dist/huazhu/backend.d.ts +6 -0
  30. package/dist/huazhu/backend.d.ts.map +1 -1
  31. package/dist/huazhu/backend.js +25 -22
  32. package/dist/huazhu/backend.js.map +1 -1
  33. package/dist/huazhu/catalog.d.ts +6 -6
  34. package/dist/huazhu/catalog.d.ts.map +1 -1
  35. package/dist/huazhu/catalog.js +33 -15
  36. package/dist/huazhu/catalog.js.map +1 -1
  37. package/dist/huazhu/client.js +1 -1
  38. package/dist/huazhu/client.js.map +1 -1
  39. package/dist/huazhu/make.d.ts.map +1 -1
  40. package/dist/huazhu/make.js +1 -0
  41. package/dist/huazhu/make.js.map +1 -1
  42. package/dist/huazhu/sandbox.d.ts.map +1 -1
  43. package/dist/huazhu/sandbox.js +5 -3
  44. package/dist/huazhu/sandbox.js.map +1 -1
  45. package/dist/legal.d.ts.map +1 -1
  46. package/dist/legal.js +104 -80
  47. package/dist/legal.js.map +1 -1
  48. package/dist/lib.d.ts +1 -1
  49. package/dist/lib.d.ts.map +1 -1
  50. package/dist/lib.js +1 -1
  51. package/dist/lib.js.map +1 -1
  52. package/dist/otaless/backend.d.ts +31 -0
  53. package/dist/otaless/backend.d.ts.map +1 -0
  54. package/dist/otaless/backend.js +502 -0
  55. package/dist/otaless/backend.js.map +1 -0
  56. package/dist/otaless/client.d.ts +103 -0
  57. package/dist/otaless/client.d.ts.map +1 -0
  58. package/dist/otaless/client.js +110 -0
  59. package/dist/otaless/client.js.map +1 -0
  60. package/dist/otaless/fake.d.ts +27 -0
  61. package/dist/otaless/fake.d.ts.map +1 -0
  62. package/dist/otaless/fake.js +239 -0
  63. package/dist/otaless/fake.js.map +1 -0
  64. package/dist/otaless/make.d.ts +4 -0
  65. package/dist/otaless/make.d.ts.map +1 -0
  66. package/dist/otaless/make.js +71 -0
  67. package/dist/otaless/make.js.map +1 -0
  68. package/dist/paid.d.ts.map +1 -1
  69. package/dist/paid.js +9 -9
  70. package/dist/paid.js.map +1 -1
  71. package/dist/present.d.ts +6 -1
  72. package/dist/present.d.ts.map +1 -1
  73. package/dist/present.js +20 -5
  74. package/dist/present.js.map +1 -1
  75. package/dist/reference.d.ts.map +1 -1
  76. package/dist/reference.js +10 -3
  77. package/dist/reference.js.map +1 -1
  78. package/dist/server.d.ts +2 -2
  79. package/dist/server.d.ts.map +1 -1
  80. package/dist/server.js +8 -6
  81. package/dist/server.js.map +1 -1
  82. package/dist/supplier.d.ts +6 -0
  83. package/dist/supplier.d.ts.map +1 -1
  84. package/dist/supplier.js +4 -0
  85. package/dist/supplier.js.map +1 -1
  86. package/dist/surface.d.ts.map +1 -1
  87. package/dist/surface.js +100 -71
  88. package/dist/surface.js.map +1 -1
  89. package/dist/tools.d.ts +19 -0
  90. package/dist/tools.d.ts.map +1 -1
  91. package/dist/tools.js +204 -102
  92. package/dist/tools.js.map +1 -1
  93. package/dist/types.d.ts +58 -0
  94. package/dist/types.d.ts.map +1 -1
  95. package/dist/vocab.d.ts +0 -14
  96. package/dist/vocab.d.ts.map +1 -1
  97. package/dist/vocab.js +19 -22
  98. package/dist/vocab.js.map +1 -1
  99. package/package.json +1 -1
package/dist/tools.js CHANGED
@@ -1,4 +1,6 @@
1
+ import { aboutLines } from './about.js';
1
2
  import { DEFAULT_PAGE, FULL_CAPABILITIES, LASTING_REQUESTS, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX, guestTooLong, notPricedForNoAnswer, unansweredBy, unitWords, } from './backend.js';
3
+ import { usedOnWords } from './coupon-words.js';
2
4
  import { MAX_GUESTS, addDays, isDay, nights as nightsBetween } from './dates.js';
3
5
  import { FACILITIES } from './facilities.js';
4
6
  import { DESCRIPTION_MAX, POLICY_MAX, hotelName, hotelText } from './fence.js';
@@ -12,7 +14,7 @@ import { regionOf } from './regions.js';
12
14
  import { activeKeysOf, byRoomType, facetsOf, keyWords, matchesTerms, optionsOf, roomTermsOf, termWords, } from './room-filters.js';
13
15
  import { SAME_BOOKING_MS } from './same-booking.js';
14
16
  import { MAX_EXTRA_QUANTITY, MAX_ROOMS_PER_LINE, clampCount, pickedKey, shownOf, stampOf, tripKey, } from './trip.js';
15
- import { DEMO_NOTE, branded, catalogue, noteFor, upFirst } from './vocab.js';
17
+ import { branded, catalogue, upFirst } from './vocab.js';
16
18
  // The eleven things a guest can do: find hotels, look at one on dates, see what
17
19
  // else it offers, build a trip, ask for the rooms, and deal with a booking they
18
20
  // already have.
@@ -143,7 +145,29 @@ export const withheldTools = (n, caps) => new Set([
143
145
  ...(caps.changes || caps.extras ? [] : [n.previewChange, n.changeBooking]),
144
146
  ...(caps.policies === false ? [n.policies] : []),
145
147
  ...(caps.unionpay ? [] : [n.payWithUnionPay]),
148
+ // Nothing can be booked here yet (`readOnly`, Otaless 2026-11-03): no tool
149
+ // that books, finds, changes or cancels a booking - the card's own two
150
+ // for Reserve included, so no button that books is drawn.
151
+ ...(caps.readOnly
152
+ ? [
153
+ n.requestBooking,
154
+ n.pickRoom,
155
+ n.bookPicked,
156
+ n.listBookings,
157
+ n.previewCancellation,
158
+ n.cancelBooking,
159
+ n.previewChange,
160
+ n.changeBooking,
161
+ n.payWithUnionPay,
162
+ ]
163
+ : []),
146
164
  ]);
165
+ /**
166
+ * What to tell a guest who wants to book where nothing can be booked yet
167
+ * (`capabilities.readOnly`) - the same words in the instructions and in each
168
+ * answer that would otherwise say how to book.
169
+ */
170
+ export const readOnlyWords = (v) => `Nothing can be booked through ${v.brand} yet: it shows ${v.noun}s, rooms and prices only. When the guest wants to book, say so plainly - nothing is booked or held - and suggest booking with the ${v.noun}'s own group directly, on its website or app, with the ${v.noun}, room and dates from here.`;
147
171
  const forCapabilities = (tools, n, v, caps) => {
148
172
  const withheld = withheldTools(n, caps);
149
173
  const without = (schema, ...keys) => {
@@ -294,18 +318,16 @@ const forCapabilities = (tools, n, v, caps) => {
294
318
  if (t.name === n.requestBooking && caps.booking === 'pay-at-hotel' && !v.market) {
295
319
  out = {
296
320
  ...out,
297
- // ⚠️ A SANDBOX BOOKS NOTHING REAL, and its description says so
298
- // itself: the instructions carried "test environment" while this
299
- // line still promised "the hotel's own reservation" (2026-09-30).
300
- description: v.test
301
- ? `Makes a test booking in ${v.brand}'s own test system, for testing: each stay gets its own confirmation code there, and none of it holds a room for anyone or is a stay anyone goes to. ${caps.prepaid
302
- ? 'A rate marked paid in advance is booked on its own, and not at once: the answer gives a card page in test mode and the time it is paid by, and the stay is booked once it is paid - no card is collected here. Every other rate is paid by nobody.'
321
+ // ⚠️ A BOOKING THAT IS NOT A RESERVATION SAYS SO ITSELF (2026-09-30: the
322
+ // description promised "the hotel's own reservation" on the sandbox) -
323
+ // as the fact, never "test" or "simulated" (robin, 2026-11-03).
324
+ description: v.test || v.demo
325
+ ? `Books the trip's rooms for a named guest: each stay gets its own confirmation code, and ${v.test
326
+ ? 'none of it holds a room for anyone or is a stay anyone goes to'
327
+ : `none of it is sent to ${v.brand} or holds a real room`}. ${caps.prepaid
328
+ ? 'A rate marked paid in advance is booked on its own, and not at once: the answer gives a card page where no real money moves and the time it is paid by, and the stay is booked once it is paid - no card is collected here. Every other rate is paid by nobody.'
303
329
  : 'No payment is taken and no card is collected.'} All of the trip is booked or none of it. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests.`
304
- : v.sandbox
305
- ? `Makes a booking in a simulated copy of ${v.brand}'s booking system, for testing: each stay gets its own confirmation code there, and none of it is a ${v.brand} reservation or holds a real room. ${caps.prepaid
306
- ? 'A rate marked paid in advance is booked on its own, and not at once: the answer gives a card page in test mode and the time it is paid by, and the stay is booked once it is paid - no card is collected here. Every other rate is paid by nobody.'
307
- : 'No payment is taken and no card is collected.'} All of the trip is booked or none of it. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests.`
308
- : `Books the trip's rooms with ${v.brand} for a named guest: each stay becomes the hotel's own reservation, with its own confirmation code, paid at the hotel. No payment is taken here and no card is collected. All of the trip is booked or none of it. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests the desk tries to meet.`,
330
+ : `Books the trip's rooms with ${v.brand} for a named guest: each stay becomes the hotel's own reservation, with its own confirmation code, paid at the hotel. No payment is taken here and no card is collected. All of the trip is booked or none of it. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests the desk tries to meet.`,
309
331
  };
310
332
  }
311
333
  }
@@ -592,11 +614,12 @@ const allTools = (n, v) => {
592
614
  // could take the code to the real hotel.
593
615
  description: v.market
594
616
  ? // ⚠️ THE GROUP HOLDS THE BOOKING, NOT THE MARKET - and what a booking
595
- // makes is the group's: a hold, a test booking, or a reservation paid
596
- // at the hotel. The answer says which, stay by stay.
597
- `Books the trip's rooms for a named guest with the hotel group each stay belongs to, one group at a time: a trip holding two groups' hotels is booked group by group. What a booking makes is that group's - a demonstration hold that lapses on its own, a test booking in a simulated system, or the hotel's own reservation paid at the hotel - and the answer says which for every stay. No payment is taken here and no card is collected. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests.`
617
+ // makes is the group's: a hold, a booking that holds no room, or a
618
+ // reservation paid at the hotel. The answer says which, stay by stay.
619
+ // ⚠️ NO "DEMONSTRATION" / "TEST" / "SIMULATED" (robin, 2026-11-03).
620
+ `Books the trip's rooms for a named guest with the hotel group each stay belongs to, one group at a time: a trip holding two groups' hotels is booked group by group. What a booking makes is that group's - a hold that lapses on its own and is not sent to the group, a booking that holds no room for anyone, or the hotel's own reservation paid at the hotel - and the answer says which for every stay. No payment is taken here and no card is collected. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests.`
598
621
  : v.demo
599
- ? `Makes a demonstration booking request for the trip's rooms, for a named guest: it returns a confirmation code and the moment the demonstration hold lapses on its own. ${v.brand} does not receive it and no real room is held. No payment is taken and no card is collected. Preferences - a bed type, a floor, a late arrival - ride along on the request.`
622
+ ? `Makes a booking request for the trip's rooms, for a named guest: it returns a confirmation code and the moment the hold lapses on its own. ${v.brand} does not receive it and no real room is held. No payment is taken and no card is collected. Preferences - a bed type, a floor, a late arrival - ride along on the request.`
600
623
  : `Asks ${v.brand} to hold the trip's rooms for a named guest and returns a confirmation code and the moment the hold lapses. No payment is taken and no card is collected; the rooms are held, not paid for. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests the desk tries to meet.`,
601
624
  annotations: books(v.market ? `Request a booking on ${v.brand}` : `Request a ${v.brand} booking`, open),
602
625
  inputSchema: {
@@ -1706,6 +1729,7 @@ const holderOf = (deps, supplier) => {
1706
1729
  demo: s.mode !== 'live',
1707
1730
  sandbox: s.mode === 'sandbox',
1708
1731
  test: s.mode === 'test',
1732
+ notReal: s.mode !== 'live' && s.mode !== 'test',
1709
1733
  };
1710
1734
  return {
1711
1735
  name: deps.vocab.brand,
@@ -1713,6 +1737,7 @@ const holderOf = (deps, supplier) => {
1713
1737
  demo: deps.vocab.demo,
1714
1738
  sandbox: !!deps.vocab.sandbox,
1715
1739
  test: !!deps.vocab.test,
1740
+ notReal: deps.vocab.demo && !deps.vocab.test,
1716
1741
  };
1717
1742
  };
1718
1743
  /**
@@ -1791,10 +1816,13 @@ const paidLine = (deps, b, fee) => {
1791
1816
  const back = formatMoney(p.refundAmount ?? p.amount);
1792
1817
  const whole = !p.refundAmount || p.refundAmount.minor === p.amount.minor;
1793
1818
  const unionpay = p.method === 'unionpay';
1794
- const test = h.sandbox
1795
- ? ` This is a test: ${unionpay ? "UnionPay's test system" : 'the card page in test mode'}, no real money moves, and the booking is in the simulated system.`
1819
+ // ⚠️ THE FACT, NO ENVIRONMENT'S NAME (robin, 2026-11-03): no real money moves, and
1820
+ // the booking was not sent to the group - or, in its own test system, holds no
1821
+ // room for anyone.
1822
+ const test = h.notReal
1823
+ ? ` No real money moves, and this booking was not sent to ${h.name}.`
1796
1824
  : h.test
1797
- ? ` This is a test: ${unionpay ? "UnionPay's test system" : 'the card page in test mode'}, no real money moves, and the booking is a test booking in ${h.name}'s test system.`
1825
+ ? ' No real money moves, and this booking holds no room for anyone.'
1798
1826
  : '';
1799
1827
  const ended = b.status === 'cancelled' || b.status === 'expired' || b.status === 'failed';
1800
1828
  const said = (() => {
@@ -1826,7 +1854,7 @@ const paidLine = (deps, b, fee) => {
1826
1854
  case 'refunded':
1827
1855
  return `${whole ? `All ${back}` : `${back} of the ${amount} paid`} has gone back to the card it was paid with${p.refundReason ? ` - ${p.refundReason}` : ''}.`;
1828
1856
  case 'hotel-refunds':
1829
- return `The money is with ${h.name}, who charged the one-time card UnionPay issued: what its terms give back${fee && fee.minor > 0 ? ` after its fee of ${formatMoney(fee)}` : ''}, it returns to that card - not this connector${p.refundReason ? ` (${p.refundReason})` : ''}.`;
1857
+ return `The money is with ${h.name}, who charged the one-time card UnionPay issued: what its terms give back${fee && fee.minor > 0 ? ` after its fee of ${formatMoney(fee)}` : ''}, it returns to that card - nothing is refunded from here${p.refundReason ? ` (${p.refundReason})` : ''}.`;
1830
1858
  }
1831
1859
  }
1832
1860
  switch (p.status) {
@@ -1860,7 +1888,7 @@ const paidLine = (deps, b, fee) => {
1860
1888
  case 'refunded':
1861
1889
  return `${whole ? `All ${back}` : `${back} of the ${amount} paid`} has gone back to the card it was paid with${p.refundReason ? ` - ${p.refundReason}` : ''}.`;
1862
1890
  case 'hotel-refunds':
1863
- return `The money is with ${h.name}, who returns what its terms give back to the card it charged - not this connector.`;
1891
+ return `The money is with ${h.name}, who returns what its terms give back to the card it charged - nothing is refunded from here.`;
1864
1892
  }
1865
1893
  })();
1866
1894
  return `${said}${test}`;
@@ -1902,10 +1930,11 @@ const heldLine = (deps, b) => {
1902
1930
  const total = formatMoney(b.quote.total);
1903
1931
  const h = holderOf(deps, b.supplier);
1904
1932
  if (h.caps.booking === 'pay-at-hotel') {
1905
- if (h.sandbox)
1906
- return `${total} in the simulated system. **This is a test booking** - not a ${h.name} reservation: no room is held and nothing is paid, now or at any hotel.`;
1933
+ // ⚠️ THE FACT, IN NO ENVIRONMENT'S WORD (robin, 2026-11-03).
1934
+ if (h.notReal)
1935
+ return `${total} all in. **This booking was not sent to ${h.name}** - no room is held for anyone and nothing is paid, now or at any hotel.`;
1907
1936
  if (h.test)
1908
- return `${total} in ${h.name}'s test system. **This is a test booking** - it holds no room for anyone, nothing is paid, now or at any hotel, and nobody needs to go there.`;
1937
+ return `${total} all in. **This booking holds no room for anyone** - nothing is paid, now or at any hotel, and nobody needs to go there.`;
1909
1938
  return b.status === 'requested'
1910
1939
  ? `${total} all in, to be paid at the hotel. ${h.name} has not confirmed this booking yet - that takes up to eight minutes, and ${deps.tools.listBookings} shows when it has. Nothing has been charged.`
1911
1940
  : `${total} all in, paid at the hotel. **Nothing has been charged here** - this is ${h.name}'s own reservation, kept until it is cancelled, and the guest pays at check-in.`;
@@ -1940,10 +1969,11 @@ const demoHoldWords = (b, now) => {
1940
1969
  : minutes < 48 * 60
1941
1970
  ? `in about ${Math.round(minutes / 60)} hours`
1942
1971
  : `in about ${Math.round(minutes / (24 * 60))} days`;
1943
- return `This is a demonstration: it lapses by itself ${lasting} (demo only - no real room is held; the stay's own dates are not affected).`;
1972
+ // No "demonstration" (robin, 2026-11-03): only that it lapses and holds no room.
1973
+ return `It lapses by itself ${lasting} (no real room is held; the stay's own dates are not affected).`;
1944
1974
  };
1945
- /** What a demonstration booking is, in one sentence - see `demoBookingNote`. */
1946
- const demoBookingWords = (h) => `This is a demonstration booking: ${h.name} has not received it and is not holding any room.`;
1975
+ /** What a booking that is not sent to the group is, in one sentence - see `demoBookingNote`. */
1976
+ const demoBookingWords = (h) => `This booking was not sent to ${h.name}, and ${h.name} is not holding any room.`;
1947
1977
  /**
1948
1978
  * Said on every answer about a demonstration booking - made, changed or
1949
1979
  * cancelled - and on nothing else: a hold of the demo's that a guest took for
@@ -1992,7 +2022,10 @@ const common = (deps, key) => {
1992
2022
  ? { market: { name: deps.vocab.brand, suppliers: deps.backend.suppliers() } }
1993
2023
  : {}),
1994
2024
  vocab: { noun: deps.vocab.noun, nounPlural: deps.vocab.nounPlural },
1995
- ...(deps.vocab.demo ? { demo: true, demoNote: noteFor(deps.vocab) } : { demo: false }),
2025
+ // ⚠️ NO NOTE ABOUT THE DATA (robin, 2026-11-03: 「不用特意写不是真的」): `demo`
2026
+ // still tells the card a booking here is not a real one, which it says as a
2027
+ // booking's facts; nothing labels the environment.
2028
+ demo: deps.vocab.demo,
1996
2029
  ...(key ? memberBadge(deps, key) : {}),
1997
2030
  ...(langOf(deps, key) ? { lang: langOf(deps, key) } : {}),
1998
2031
  };
@@ -2012,6 +2045,8 @@ const langOf = (deps, _key) => deps.lang;
2012
2045
  * hotel's own clock when the backend knows it, else the instant in UTC - never
2013
2046
  * the date alone (see `atHotelTime`).
2014
2047
  */
2048
+ /** A rate whose own name already says it cannot be refunded - see T10 where a booking is said. */
2049
+ const SAYS_NON_REFUNDABLE = /non-?refundable|不可取消|不可退|不能取消/i;
2015
2050
  const freeUntilText = (r) => r.freeCancellationLocal ??
2016
2051
  `${(r.freeCancellationUntil ?? '').slice(0, 16).replace('T', ' ')} UTC`;
2017
2052
  /** Any Han character: a name already in Chinese needs none beside it. */
@@ -2089,11 +2124,15 @@ const quoteForCard = (quote, groupOf) => ({
2089
2124
  // stepper read 3 and sent 3 ± 1 as the new count (2026-09-28 audit).
2090
2125
  ...(l.ordered !== undefined ? { ordered: l.ordered } : {}),
2091
2126
  unit: formatMoney(l.unit),
2127
+ ...(l.publicUnit ? { publicUnit: formatMoney(l.publicUnit) } : {}),
2092
2128
  total: formatMoney(l.total),
2093
2129
  // The line with its taxes in, where the backend knows them per line: what the
2094
2130
  // review and the trip show, as the hotel's page does (r10 #23).
2095
2131
  ...(l.totalWithTaxes ? { totalWithTaxes: formatMoney(l.totalWithTaxes) } : {}),
2096
2132
  refundable: l.refundable,
2133
+ // The supplier's own sentence about cancelling, where it gives only that
2134
+ // (`QuoteLine.terms`, Otaless): the card says it as it came.
2135
+ ...(l.terms !== undefined ? { terms: l.terms } : {}),
2097
2136
  freeCancellationUntil: l.freeCancellationUntil,
2098
2137
  // The deadline on the hotel's clock, where the backend knows it - the
2099
2138
  // review restates the terms beside the total (round-2 jury).
@@ -2118,6 +2157,24 @@ const quoteForCard = (quote, groupOf) => ({
2118
2157
  // The review's own order - public, member, coupon, total - each
2119
2158
  // figure the server's, so the card adds nothing up (10-30, ①).
2120
2159
  publicTotal: formatMoney(quote.member.publicTotal),
2160
+ // The room charges at the public rate, the taxes a row of their own -
2161
+ // the receipt every guest gets: room, taxes, less the tier, less the
2162
+ // coupon, is the total (0.54.0, M1: a member's started from the
2163
+ // public rate taxes in, a guest's from the room before them).
2164
+ // ⚠️ THE PUBLIC RATE'S OWN ROOM CHARGE (0.55.0, review r3 N7) where the
2165
+ // backend gives it: the public total less the taxes CHARGED held the public
2166
+ // rate's larger tax as well, and the line's 「公开价每晚 ¥478」 for 2 nights and
2167
+ // 2 rooms (¥1,912) did not meet 「房费(公开价)¥1,922」. The tier's row is then
2168
+ // what it took off the rooms (`roomSaved`), and room, taxes, less the tier,
2169
+ // less the coupon is still the total.
2170
+ publicSubtotal: formatMoney(quote.member.publicSubtotal ??
2171
+ money(quote.member.publicTotal.minor -
2172
+ (quote.taxesIncluded ? 0 : quote.taxesAndFees.minor), quote.total.currency)),
2173
+ ...(quote.member.publicSubtotal
2174
+ ? {
2175
+ roomSaved: formatMoney(money(quote.member.publicSubtotal.minor - quote.subtotal.minor, quote.total.currency)),
2176
+ }
2177
+ : {}),
2121
2178
  memberTotal: formatMoney(money(quote.member.publicTotal.minor - quote.member.saved.minor, quote.total.currency)),
2122
2179
  },
2123
2180
  }
@@ -2158,7 +2215,7 @@ const couponTerms = (c, brand) => [
2158
2215
  c.state === 'expired'
2159
2216
  ? `ran out on ${c.validUntil} - cannot be used`
2160
2217
  : c.state === 'used'
2161
- ? 'already used on a booking'
2218
+ ? `already used ${usedOnWords(c.usedOn)}`
2162
2219
  : c.state === 'not-yet'
2163
2220
  ? `usable from ${c.validFrom} to ${c.validUntil}`
2164
2221
  : `until ${c.validUntil}`,
@@ -2248,13 +2305,11 @@ const memberTripLines = (q) => {
2248
2305
  const couponRefusedLine = (q) => q.couponRefused
2249
2306
  ? `The coupon ${q.couponRefused.name ? couponName({ name: q.couponRefused.name, names: q.couponRefused.names }) : q.couponRefused.id} was NOT taken off: ${q.couponRefused.why} The total is without it.`
2250
2307
  : '';
2251
- const tripText = (quote, demo, noCards) => {
2308
+ const tripText = (quote, noCards) => {
2252
2309
  // ⚠️ NOT "EMPTY" WHEN SOMETHING WAS LEFT OUT: a trip whose only group did not
2253
2310
  // answer is not an empty trip (2026-11-03 review, C5).
2254
2311
  if (quote.lines.length === 0 && quote.dropped?.length)
2255
- return [...quote.dropped.map((d) => `Left out: ${d.title} — ${d.why}`), demo ? DEMO_NOTE : '']
2256
- .filter(Boolean)
2257
- .join('\n');
2312
+ return quote.dropped.map((d) => `Left out: ${d.title} — ${d.why}`).join('\n');
2258
2313
  if (quote.lines.length === 0)
2259
2314
  return 'The trip is empty.';
2260
2315
  const lines = quote.lines.map((l) => l.kind === 'room'
@@ -2266,7 +2321,6 @@ const tripText = (quote, demo, noCards) => {
2266
2321
  couponRefusedLine(quote),
2267
2322
  ...memberTripLines(quote),
2268
2323
  ...(quote.dropped ?? []).map((d) => `Left out: ${d.title} — ${d.why}`),
2269
- demo ? DEMO_NOTE : '',
2270
2324
  ]
2271
2325
  .filter(Boolean)
2272
2326
  .join('\n');
@@ -2515,6 +2569,8 @@ const labelWords = (r) => {
2515
2569
  ];
2516
2570
  return words.length ? ` [${words.join(', ')}]` : '';
2517
2571
  };
2572
+ /** What a list and a page both say of a hotel with nothing on sale here: other nights would not help. */
2573
+ const NOT_SOLD_OUT = 'not sold out, and other nights make no difference';
2518
2574
  /**
2519
2575
  * What is said of rates listed but not booked here (`Room.notSoldHere`) - in
2520
2576
  * the text and, word for word, in the structure (`notSoldHere`): the model reads
@@ -2522,15 +2578,34 @@ const labelWords = (r) => {
2522
2578
  * card says no more than 预付 on them (robin, 2026-10-30), so the assistant is
2523
2579
  * the one who knows - and says it when the guest wants one.
2524
2580
  */
2525
- export const notSoldHereWords = (group) => `Rates marked paid in advance are listed as ${group} sells them, but a stay paid in advance cannot be booked here yet: this connector books rates paid at the hotel. If the guest wants one, say so plainly - nothing is booked or charged - and offer a rate paid at the hotel, other dates, or booking it with ${group} directly.`;
2581
+ export const notSoldHereWords = (group) => `Rates marked paid in advance are listed as ${group} sells them, but a stay paid in advance cannot be booked here yet; only rates paid at the hotel can be. If the guest wants one, say so plainly - nothing is booked or charged - and offer a rate paid at the hotel, other dates, or booking it with ${group} directly.`;
2582
+ /**
2583
+ * A hotel none of whose rooms are on sale here (`none-on-sale`), said on its page
2584
+ * with the way out (2026-11-03 review, T5). The page's text had one sentence -
2585
+ * "This hotel has no rooms on sale here." - and the card said where to book it
2586
+ * instead, so a client that draws no card left the guest with a dead end. The
2587
+ * clause is the list's (see `NOT_SOLD_OUT`), so the list and the page say one
2588
+ * thing; the group is the deployment's own, or the hotel's in a market, never a
2589
+ * name written into the sentence. In the text and, word for word, in the
2590
+ * structure (`noRooms`): the model reads the structure.
2591
+ */
2592
+ export const notOnSaleWords = (group, why) => `${why.replace(/[.\s]+$/, '')} - ${NOT_SOLD_OUT}. To book it, the guest can use ${group}'s own channels: its app, its website or by phone.`;
2526
2593
  /** The group a hotel is with, by name - the deployment's own brand outside a market. */
2527
2594
  const groupNameOf = (deps, hotelId) => holderOf(deps, deps.backend.supplierOf?.(hotelId)).name;
2595
+ /** A rate's cancellation terms in the supplier's own words (`Room.terms`), quoted - or that none were given. */
2596
+ export const termsWords = (terms) => terms
2597
+ ? `cancellation terms, in the hotel group's own words: "${terms}"`
2598
+ : 'no cancellation terms given';
2528
2599
  /** What a rate says about cancelling, as the text puts it. */
2529
- const cancelWords = (r) => r.freeCancellationUntil
2530
- ? `free to cancel until ${freeUntilText(r)}`
2531
- : r.refundable
2532
- ? 'the free cancellation window has closed - cancelling now has a fee'
2533
- : 'non-refundable';
2600
+ const cancelWords = (r) =>
2601
+ // The supplier's own sentence, as it came - see `Room.terms`.
2602
+ r.terms !== undefined
2603
+ ? termsWords(r.terms)
2604
+ : r.freeCancellationUntil
2605
+ ? `free to cancel until ${freeUntilText(r)}`
2606
+ : r.refundable
2607
+ ? 'the free cancellation window has closed - cancelling now has a fee'
2608
+ : 'non-refundable';
2534
2609
  /**
2535
2610
  * What a room is, in a parenthesis: its beds, how many it sleeps and - for one
2536
2611
  * rate, not a type whose rates differ - breakfast, unless the rate's labels
@@ -2691,7 +2766,11 @@ const heldRoomsWords = (b) => b.quote.lines
2691
2766
  : l.freeCancellationUntil
2692
2767
  ? `, free to cancel until ${freeUntilText(l)}`
2693
2768
  : l.refundable === false
2694
- ? ', non-refundable'
2769
+ ? // ⚠️ NOT TWICE (0.55.0, review r3 T10): 「… · Non-refundable(… · 不可取消),
2770
+ // non-refundable」 - a rate whose name says it is not told it again.
2771
+ SAYS_NON_REFUNDABLE.test(`${l.title} ${Object.values(l.titleNames ?? {}).join(' ')}`)
2772
+ ? ''
2773
+ : ', non-refundable'
2695
2774
  : l.refundable
2696
2775
  ? ', past its free cancellation - cancelling has a fee'
2697
2776
  : ''}`)
@@ -2921,12 +3000,19 @@ const listAnd = (xs) => xs.length > 1 ? `${xs.slice(0, -1).join(', ')} and ${xs.
2921
3000
  */
2922
3001
  const degradedOf = (deps, answer,
2923
3002
  /** What is missing from the list: the hotels of a search, the bookings of "my bookings". */
2924
- noun = deps.vocab.nounPlural) => {
3003
+ noun = deps.vocab.nounPlural,
3004
+ /**
3005
+ * The client draws no card: each group's own Chinese name goes beside its
3006
+ * name, as a hotel's does (`withZh`) - told only "JI Hotel", a model with no
3007
+ * card called it 锦江之星, another group (11-03, text-only eval). A card host
3008
+ * is told the English name alone; the card says the group in its own words.
3009
+ */
3010
+ noCards = false) => {
2925
3011
  const down = answer.degraded ?? [];
2926
3012
  if (down.length === 0)
2927
3013
  return { structured: {}, said: '' };
2928
3014
  const one = down.length === 1;
2929
- const said = `${listAnd(down.map((d) => d.name))} did not answer in time, so none of ${one ? 'its' : 'their'} ${noun} is in this list - this does not mean ${one ? 'it has' : 'they have'} none here; asking again in a moment may bring them.`;
3015
+ const said = `${listAnd(down.map((d) => withZh(d.name, d.names, noCards)))} did not answer in time, so none of ${one ? 'its' : 'their'} ${noun} is in this list - this does not mean ${one ? 'it has' : 'they have'} none here; asking again in a moment may bring them.`;
2930
3016
  return {
2931
3017
  structured: {
2932
3018
  degraded: down.map((d) => ({ supplier: d.supplier, name: d.name })),
@@ -3234,7 +3320,9 @@ const currentTrip = async (deps, key, who, store = key, coupon, ctx) => {
3234
3320
  // came back "needs a phone number". Whose need it is is the trip's groups',
3235
3321
  // as `requestBooking` judges it.
3236
3322
  const nextStep = ctx?.noCards && parts.some((p) => p.lines.some((l) => l.kind === 'room'))
3237
- ? `Nothing in the trip is booked yet. To book it, ${deps.tools.requestBooking} takes the guest's own ${guestDetailsFor(deps, lines.filter((l) => l.kind === 'room'))} - ask for them if the guest has not typed them in this conversation.`
3323
+ ? (deps.backend.capabilities ?? FULL_CAPABILITIES).readOnly
3324
+ ? readOnlyWords(deps.vocab)
3325
+ : `Nothing in the trip is booked yet. To book it, ${deps.tools.requestBooking} takes the guest's own ${guestDetailsFor(deps, lines.filter((l) => l.kind === 'room'))} - ask for them if the guest has not typed them in this conversation.`
3238
3326
  : '';
3239
3327
  if (parts.length === 1 && only) {
3240
3328
  return {
@@ -3248,13 +3336,7 @@ const currentTrip = async (deps, key, who, store = key, coupon, ctx) => {
3248
3336
  count: lines.length,
3249
3337
  ...(nextStep ? { nextStep } : {}),
3250
3338
  },
3251
- text: [
3252
- tripText(only, false, ctx?.noCards),
3253
- ...overlaps.map((o) => o.said),
3254
- orphans.line,
3255
- nextStep,
3256
- deps.vocab.demo && lines.length ? noteFor(deps.vocab) : '',
3257
- ]
3339
+ text: [tripText(only, ctx?.noCards), ...overlaps.map((o) => o.said), orphans.line, nextStep]
3258
3340
  .filter(Boolean)
3259
3341
  .join('\n'),
3260
3342
  };
@@ -3296,11 +3378,10 @@ const currentTrip = async (deps, key, who, store = key, coupon, ctx) => {
3296
3378
  },
3297
3379
  text: [
3298
3380
  `This trip is in ${parts.length} currencies, priced separately and not converted - each hotel is booked and paid in its own.`,
3299
- ...parts.map((q) => tripText(q, false, ctx?.noCards)),
3381
+ ...parts.map((q) => tripText(q, ctx?.noCards)),
3300
3382
  ...overlaps.map((o) => o.said),
3301
3383
  orphans.line,
3302
3384
  nextStep,
3303
- deps.vocab.demo ? noteFor(deps.vocab) : '',
3304
3385
  ]
3305
3386
  .filter(Boolean)
3306
3387
  .join('\n\n'),
@@ -3709,11 +3790,13 @@ const comparedInWords = (c) => {
3709
3790
  const priced = p
3710
3791
  ? [
3711
3792
  `${withZh(p.room, p.roomNames, true)} at ${p.nightly} ${nightWords(true)}, ${p.stayTotal} for the stay incl. taxes and fees`,
3712
- p.freeCancellationUntil
3713
- ? `free to cancel until ${freeUntilText(p)}`
3714
- : p.refundable
3715
- ? 'no free cancellation'
3716
- : 'non-refundable',
3793
+ p.terms !== undefined
3794
+ ? termsWords(p.terms)
3795
+ : p.freeCancellationUntil
3796
+ ? `free to cancel until ${freeUntilText(p)}`
3797
+ : p.refundable
3798
+ ? 'no free cancellation'
3799
+ : 'non-refundable',
3717
3800
  p.payment === 'prepaid'
3718
3801
  ? p.notSoldHere
3719
3802
  ? 'paid in advance - not booked here'
@@ -4023,7 +4106,6 @@ const bookedFromThisForm = async (deps, ctx, key, args) => {
4023
4106
  notUsed?.said ?? '',
4024
4107
  `**${confirming(deps, b) ? 'Being confirmed' : b.confirmationCode}** · ${b.hotelName} · ${stayWords(b.stay, b.hourly)}`,
4025
4108
  bookedLine(deps, b) ?? '',
4026
- deps.vocab.demo ? noteFor(deps.vocab) : '',
4027
4109
  ]
4028
4110
  .filter(Boolean)
4029
4111
  .join('\n'),
@@ -4087,7 +4169,7 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
4087
4169
  const prepaidNotHere = (room, open, stay, when) => {
4088
4170
  const group = groupNameOf(deps, hotelId);
4089
4171
  const said = [
4090
- `Nothing was booked or charged: ${room.name} is paid in advance, and a stay paid in advance cannot be booked here yet - this connector books rates paid at the hotel.`,
4172
+ `Nothing was booked or charged: ${room.name} is paid in advance, and a stay paid in advance cannot be booked here yet - only rates paid at the hotel can be.`,
4091
4173
  open.length
4092
4174
  ? `Paid at the hotel at ${detail.hotel.name} ${when}: ${open.map((r) => `${r.name} (\`${r.id}\`)`).join(', ')}.`
4093
4175
  : `No rate at ${detail.hotel.name} ${when} is paid at the hotel.`,
@@ -4604,7 +4686,6 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
4604
4686
  ...failedText,
4605
4687
  remembered?.line ?? '',
4606
4688
  others ? `The trip's other stays are still in it, not booked:\n${rest?.text ?? ''}` : '',
4607
- deps.vocab.demo ? noteFor(deps.vocab) : '',
4608
4689
  ]
4609
4690
  .filter(Boolean)
4610
4691
  .join('\n'),
@@ -4764,7 +4845,15 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4764
4845
  // backend contract, but "the next backend honours it" is not something the
4765
4846
  // card can be built on - and the cost of it being wrong is the whole group
4766
4847
  // in the model's context.
4767
- const hotels = answer.hotels.slice(0, MAX_PAGE);
4848
+ // ⚠️ A HOTEL NOT ON SALE HERE SAYS WHERE IT IS (review r2, T5): its page said
4849
+ // the group's own channels, its tile on a list - the answer a model often
4850
+ // stops at - only that nothing was on sale. In the text and the structure.
4851
+ const hotels = answer.hotels.slice(0, MAX_PAGE).map((h) => h.noRoomsForStayCode === 'none-on-sale' && h.noRoomsForStay
4852
+ ? {
4853
+ ...h,
4854
+ noRoomsForStay: `${h.noRoomsForStay.replace(/[.\s]+$/, '')} - it can be booked through ${groupNameOf(deps, h.id)}'s own channels: its app, its website or by phone.`,
4855
+ }
4856
+ : h);
4768
4857
  const offset = asked.offset ?? 0;
4769
4858
  const total = Math.max(answer.total, offset + hotels.length);
4770
4859
  const cities = [...new Set(hotels.map((h) => h.city))];
@@ -4936,7 +5025,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4936
5025
  // assistant ask which city to list the rest in (2026-10-15 smoke run).
4937
5026
  // And not "tell me another city": the second run said there was one
4938
5027
  // hotel, then offered to look in another city (2026-10-15).
4939
- const inAll = `This connector has one ${branded(deps.vocab, deps.vocab.noun)} in all, ${sole.name}${sole.city ? ` in ${sole.city}` : ''}. There is no other hotel and no other city here - nothing else to search for or to offer.`;
5028
+ const inAll = `There is one ${branded(deps.vocab, deps.vocab.noun)} here in all, ${sole.name}${sole.city ? ` in ${sole.city}` : ''}. There is no other hotel and no other city here - nothing else to search for or to offer.`;
4940
5029
  return {
4941
5030
  ...page,
4942
5031
  // In both, in the same words: a host may hand its model this rather
@@ -4980,7 +5069,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4980
5069
  : `${c.name} (${c.count})`)
4981
5070
  .join(', ');
4982
5071
  const n = deps.vocab.nounPlural;
4983
- const degraded = degradedOf(deps, answer);
5072
+ const degraded = degradedOf(deps, answer, undefined, ctx?.noCards);
4984
5073
  if (namedCountry) {
4985
5074
  const [only] = countries;
4986
5075
  return {
@@ -5017,13 +5106,19 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5017
5106
  ask: { ...stayArgs },
5018
5107
  ...(ask.stay ? { stay: ask.stay } : {}),
5019
5108
  ...(today ? { today } : {}),
5109
+ // ⚠️ IN A MARKET, HOW MANY HOTELS EACH GROUP HAS HERE (11-03): the card
5110
+ // says "from N groups" of the groups that have one - not of every group
5111
+ // on the market's list, six of which may have none. Only the counts.
5112
+ ...(deps.backend.suppliers && answer.refine?.suppliers
5113
+ ? { refine: { suppliers: answer.refine.suppliers } }
5114
+ : {}),
5020
5115
  ...degraded.structured,
5021
5116
  },
5022
5117
  text: `${total} ${branded(deps.vocab, n)} in ${facets.cities.length} cities across ${countries.length} ${countries.length === 1 ? 'country' : 'countries'}, to choose from by region, country and city - ${regions.map((r) => `${r.name}: ${ctx.noCards ? everyWithCities(r.countries) : every(r.countries)}`).join('; ')}. A city or a country lists its ${n}. ${degraded.said ? `${degraded.said} ` : ''}${forPlans}`,
5023
5118
  };
5024
5119
  }
5025
5120
  if (hotels.length === 0) {
5026
- const degraded = degradedOf(deps, answer);
5121
+ const degraded = degradedOf(deps, answer, undefined, ctx?.noCards);
5027
5122
  const nearest = (answer.nearest ?? []).map((h) => ({
5028
5123
  ...h,
5029
5124
  summary: hotelText('description', h.name, h.summary, DESCRIPTION_MAX).text,
@@ -5158,7 +5253,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5158
5253
  : ''}${
5159
5254
  // The other kind, said as its own (U11): other nights will not help.
5160
5255
  hotels.some((h) => h.noRoomsForStayCode === 'none-on-sale')
5161
- ? `; ${hotels.filter((h) => h.noRoomsForStayCode === 'none-on-sale').length} in this answer ${hotels.filter((h) => h.noRoomsForStayCode === 'none-on-sale').length === 1 ? 'has' : 'have'} no rooms on sale through this connector - not sold out, and other nights make no difference`
5256
+ ? `; ${hotels.filter((h) => h.noRoomsForStayCode === 'none-on-sale').length} in this answer ${hotels.filter((h) => h.noRoomsForStayCode === 'none-on-sale').length === 1 ? 'has' : 'have'} no rooms on sale here - ${NOT_SOLD_OUT}`
5162
5257
  : ''}.`
5163
5258
  : '',
5164
5259
  ].filter(Boolean);
@@ -5170,9 +5265,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5170
5265
  priceNotes.push(notSold);
5171
5266
  // Every one by name and city, and that there are no others - or, told
5172
5267
  // only "2 hotels", the model asked for the list again to learn which.
5173
- const degraded = degradedOf(deps, answer);
5268
+ const degraded = degradedOf(deps, answer, undefined, ctx?.noCards);
5174
5269
  const inAll = homeList
5175
- ? `This connector has ${total} ${branded(deps.vocab, deps.vocab.nounPlural)} in all, and this answer lists every one: ${hotels
5270
+ ? `There are ${total} ${branded(deps.vocab, deps.vocab.nounPlural)} here in all, and this answer lists every one: ${hotels
5176
5271
  .map((h) => `${hotelName(h.name)}${h.city ? ` in ${h.city}` : ''}`)
5177
5272
  .join('; ')}. There is no other ${deps.vocab.noun} and no other city here - nothing else to list, search for or offer.`
5178
5273
  : '';
@@ -5276,7 +5371,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5276
5371
  };
5277
5372
  }
5278
5373
  const h = detail.hotel;
5279
- const description = hotelText('description', h.name, h.summary, DESCRIPTION_MAX, deps.vocab.demo);
5374
+ const description = hotelText('description', h.name,
5375
+ // Its 【…】 parts a line each, the words as they came (`aboutLines`).
5376
+ aboutLines(h.summary), DESCRIPTION_MAX, deps.vocab.demo);
5280
5377
  const policies = await deps.backend.policies(h.id);
5281
5378
  const extras = await deps.backend.extrasFor(h.id, stay);
5282
5379
  const base = {
@@ -5323,17 +5420,23 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5323
5420
  : [];
5324
5421
  const flex = dateFlex(dates, stay);
5325
5422
  if (detail.noRooms || !detail.rooms) {
5423
+ // ⚠️ NOTHING ON SALE HERE SAYS WHERE TO BOOK IT (T5): the same sentence
5424
+ // in the text and in `noRooms`, which the card does not draw for this code
5425
+ // (it says it in the guest's language from `noRoomsCode`).
5426
+ const said = detail.noRooms && detail.noRoomsCode === 'none-on-sale'
5427
+ ? notOnSaleWords(groupNameOf(deps, h.id), detail.noRooms)
5428
+ : detail.noRooms;
5326
5429
  return {
5327
5430
  structured: {
5328
5431
  view: 'no-rooms',
5329
5432
  ...base,
5330
5433
  stay,
5331
- noRooms: detail.noRooms,
5434
+ noRooms: said,
5332
5435
  ...(detail.noRoomsCode ? { noRoomsCode: detail.noRoomsCode } : {}),
5333
5436
  ...(detail.noRoomsArgs ? { noRoomsArgs: detail.noRoomsArgs } : {}),
5334
5437
  ...flex.structured,
5335
5438
  },
5336
- text: [detail.noRooms ?? 'Nothing is available for those nights.', flex.text]
5439
+ text: [said ?? 'Nothing is available for those nights.', flex.text]
5337
5440
  .filter(Boolean)
5338
5441
  .join(' '),
5339
5442
  };
@@ -5368,12 +5471,21 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5368
5471
  const notSold = enough.some((r) => r.notSoldHere)
5369
5472
  ? notSoldHereWords(groupNameOf(deps, h.id))
5370
5473
  : '';
5371
- const rooms = enough.map((r) => ({
5474
+ const rooms = enough.map(({ roomsLeft, refundable, ...r }) => ({
5372
5475
  ...r,
5373
- facets: facetsOf(r),
5476
+ // ⚠️ NO COUNT WHERE THE SUPPLIER GIVES NONE, and no yes / no on
5477
+ // cancelling beside its own sentence (`Room.uncounted`, `Room.terms`).
5478
+ ...(r.uncounted ? {} : { roomsLeft }),
5479
+ ...(r.terms !== undefined ? {} : { refundable }),
5480
+ facets: facetsOf({ ...r, roomsLeft, refundable }),
5374
5481
  nightly: formatMoney(r.nightly),
5375
5482
  stayTotal: formatMoney(r.stayTotal),
5376
- taxesAndFees: formatMoney(r.taxesAndFees),
5483
+ // ⚠️ "INCLUDED", NOT "¥0", where the supplier's price has its taxes in and
5484
+ // gives no split (`Room.terms`, Otaless): told "taxesAndFees ¥0", the
5485
+ // model said "税费 ¥0" - no tax - of a price the group says includes it.
5486
+ taxesAndFees: r.terms !== undefined && r.taxesAndFees.minor === 0
5487
+ ? 'Included'
5488
+ : formatMoney(r.taxesAndFees),
5377
5489
  // ⚠️ THE NUMBER THE GUEST IS LOOKING AT HAS TO BE THE ONE THEY ASKED FOR.
5378
5490
  // With two rooms picked, the card was still showing one room's total and
5379
5491
  // nothing said so - robin changed the count and watched the price not
@@ -5422,7 +5534,6 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5422
5534
  ? `${flex.text} (That is the cheapest room of any kind, not only one with ${termWords(terms)}.)`
5423
5535
  : flex.text,
5424
5536
  notSold,
5425
- deps.vocab.demo ? noteFor(deps.vocab) : '',
5426
5537
  ]
5427
5538
  .filter(Boolean)
5428
5539
  .join('\n'),
@@ -5465,7 +5576,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5465
5576
  total = found.total;
5466
5577
  degradedFound = found;
5467
5578
  }
5468
- const degraded = degradedOf(deps, degradedFound);
5579
+ const degraded = degradedOf(deps, degradedFound, undefined, ctx?.noCards);
5469
5580
  const unique = [...new Set(ids)];
5470
5581
  // A name that fits more than one is said with the ones it fits; the rest are not found.
5471
5582
  const notFound = dropped.filter((d) => !candidates.some((c) => c.for === d));
@@ -5501,7 +5612,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5501
5612
  continue;
5502
5613
  const h = detail.hotel;
5503
5614
  read.push({ id: h.id, name: h.name });
5504
- const about = hotelText('description', h.name, [h.summary, h.amenities.length ? `Has: ${h.amenities.join(', ')}.` : '']
5615
+ const about = hotelText('description', h.name, [aboutLines(h.summary), h.amenities.length ? `Has: ${h.amenities.join(', ')}.` : '']
5505
5616
  .filter(Boolean)
5506
5617
  .join('\n'), DESCRIPTION_MAX, deps.vocab.demo);
5507
5618
  const extras = await deps.backend.extrasFor(h.id, stay);
@@ -5530,7 +5641,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5530
5641
  // answer says them.
5531
5642
  ...(ctx.noCards
5532
5643
  ? roomTypeLines(rooms, roomsWanted, undefined, true)
5533
- : rooms.map((r) => `- ${r.name} · sleeps ${r.sleeps}${r.breakfast ? ' · breakfast' : ''} · ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees${roomsWanted > 1 ? ` (${formatMoney(multiplyMoney(r.stayTotal, roomsWanted))} for ${roomsWanted} rooms)` : ''}${r.freeCancellationUntil ? `, free to cancel until ${freeUntilText(r)}` : r.refundable ? '' : ', non-refundable'}`)),
5644
+ : rooms.map((r) => `- ${r.name} · sleeps ${r.sleeps}${r.breakfast ? ' · breakfast' : ''} · ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees${roomsWanted > 1 ? ` (${formatMoney(multiplyMoney(r.stayTotal, roomsWanted))} for ${roomsWanted} rooms)` : ''}${r.terms !== undefined ? `, ${termsWords(r.terms)}` : r.freeCancellationUntil ? `, free to cancel until ${freeUntilText(r)}` : r.refundable ? '' : ', non-refundable'}`)),
5534
5645
  ].join('\n')
5535
5646
  : (detail.noRooms ?? `No room type here has ${roomsWanted} left for those nights.`),
5536
5647
  extras.length
@@ -5572,7 +5683,6 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5572
5683
  ctx.noCards
5573
5684
  ? 'answer with what the guest is told, for a guest who asked to see a list, a hotel or its add-ons.'
5574
5685
  : 'would each draw a card of their own.'}`,
5575
- deps.vocab.demo ? noteFor(deps.vocab) : '',
5576
5686
  ]
5577
5687
  .filter(Boolean)
5578
5688
  .join('\n\n'),
@@ -5656,7 +5766,6 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5656
5766
  text: extras.length
5657
5767
  ? extras
5658
5768
  .map((e) => `- ${e.name}${ctx.noCards ? ` (\`${e.id}\`)` : ''} — ${formatMoney(e.price)} ${unitWords(e.per)}${held[e.id] ? ` (${booked?.confirmationCode} holds ${held[e.id]})` : ''}`)
5659
- .concat(deps.vocab.demo ? [noteFor(deps.vocab)] : [])
5660
5769
  .join('\n')
5661
5770
  : `${detail.hotel.name} has no add-ons listed here.`,
5662
5771
  };
@@ -5743,7 +5852,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5743
5852
  const here = await deps.backend.listBookings(who, typeof args.limit === 'number' ? args.limit : undefined);
5744
5853
  // Read here, before a filter or a spread drops it: the groups whose
5745
5854
  // bookings are not in `here` (2026-11-03 review, C5).
5746
- const missing = degradedOf(deps, { degraded: here.degraded }, 'bookings');
5855
+ const missing = degradedOf(deps, { degraded: here.degraded }, 'bookings', ctx?.noCards);
5747
5856
  // Elsewhere, only with the code AND the email - see `listBookings`.
5748
5857
  const code = str(args.booking);
5749
5858
  const { found: there, tooMany } = code && str(args.email)
@@ -5959,7 +6068,6 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5959
6068
  preview.rule,
5960
6069
  `Say the word and it moves (${t.changeBooking} with \`confirmed\`).`,
5961
6070
  demoBookingNote(deps, preview.supplier),
5962
- deps.vocab.demo ? noteFor(deps.vocab) : '',
5963
6071
  ]
5964
6072
  .filter(Boolean)
5965
6073
  .join(' ')
@@ -6022,7 +6130,6 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6022
6130
  `${changed.confirmationCode} now holds ${changed.stay.checkIn} to ${changed.stay.checkOut}, ${formatMoney(changed.quote.total)} all in.`,
6023
6131
  'The confirmation code has not changed. **Nothing has been charged.**',
6024
6132
  demoBookingNote(deps, changed.supplier),
6025
- deps.vocab.demo ? noteFor(deps.vocab) : '',
6026
6133
  ]
6027
6134
  .filter(Boolean)
6028
6135
  .join(' '),
@@ -6154,7 +6261,6 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6154
6261
  ? `Say the word and it is cancelled (${t.cancelBooking} with \`confirmed\`).`
6155
6262
  : '',
6156
6263
  demoBookingNote(deps, preview.supplier),
6157
- deps.vocab.demo ? noteFor(deps.vocab) : '',
6158
6264
  ]
6159
6265
  .filter(Boolean)
6160
6266
  .join(' '),
@@ -6230,18 +6336,15 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6230
6336
  cancelled.payment
6231
6337
  ? // Paid here in advance: what becomes of the money is the answer.
6232
6338
  `${cancelled.confirmationCode} is ${cancelled.status === 'expired' ? 'called off before it was paid' : 'cancelled'}. ${paidLine(deps, cancelled, charged)}`
6233
- : h.sandbox
6234
- ? // A test booking cancelled in the simulation: no hotel, no charge (2026-10-05 audit, P2).
6235
- `${cancelled.confirmationCode} is cancelled in the simulated system. ${charged.minor === 0 ? 'No fee.' : `A real booking on this rate would be charged ${formatMoney(charged)}; this one is a test, and nothing is charged.`}`
6339
+ : h.notReal
6340
+ ? // The fact, in no environment's word (robin, 2026-11-03); nobody charges
6341
+ // a booking that was never sent anything (2026-10-05 audit, P2).
6342
+ `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `A real booking on this rate would be charged ${formatMoney(charged)}; this booking was never sent to ${h.name}, and nothing is charged.`}`
6236
6343
  : h.test
6237
- ? `${cancelled.confirmationCode} is cancelled in ${h.name}'s test system. ${charged.minor === 0 ? 'No fee.' : `A live booking on this rate would be charged ${formatMoney(charged)}; this one is a test, and nothing is charged.`}`
6238
- : h.demo
6239
- ? // Nobody charges a demonstration anything (2026-10-05 audit, P2).
6240
- `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `A real booking on this rate would be charged ${formatMoney(charged)}; this is a demonstration, and nothing is charged.`}`
6241
- : `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(charged)}.`}`,
6344
+ ? `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `A real booking on this rate would be charged ${formatMoney(charged)}; this booking held no room for anyone, and nothing is charged.`}`
6345
+ : `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(charged)}.`}`,
6242
6346
  couponNow?.said ?? '',
6243
6347
  demoBookingNote(deps, cancelled.supplier),
6244
- deps.vocab.demo ? noteFor(deps.vocab) : '',
6245
6348
  ]
6246
6349
  .filter(Boolean)
6247
6350
  .join(' '),
@@ -6325,7 +6428,6 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6325
6428
  : '',
6326
6429
  ...comparison.dropped.map((d) => `Left out: ${d.name} — ${d.why}`),
6327
6430
  ...fitsManyOf(deps, comparison.dropped),
6328
- deps.vocab.demo ? noteFor(deps.vocab) : '',
6329
6431
  ]
6330
6432
  .filter(Boolean)
6331
6433
  .join('\n'),
@@ -6391,11 +6493,13 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6391
6493
  // With no card, until when it is free to cancel too: the reply is told to
6392
6494
  // give each stay's terms (2026-11-02 audit, P2-3).
6393
6495
  const termsOf = (x) => [
6394
- x.refundable === false
6395
- ? 'non-refundable'
6396
- : ctx.noCards && x.freeCancellationUntil
6397
- ? `free to cancel until ${freeUntilText(x)}`
6398
- : '',
6496
+ x.terms
6497
+ ? `cancellation: "${x.terms}"`
6498
+ : x.refundable === false
6499
+ ? 'non-refundable'
6500
+ : ctx.noCards && x.freeCancellationUntil
6501
+ ? `free to cancel until ${freeUntilText(x)}`
6502
+ : '',
6399
6503
  x.payment === 'prepaid'
6400
6504
  ? x.notSoldHere
6401
6505
  ? 'paid in advance - not booked here'
@@ -6451,7 +6555,6 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6451
6555
  ...fitsManyOf(deps, plan.dropped),
6452
6556
  uncheckedSaid,
6453
6557
  ctx.noCards ? PLAN_NOT_DRAWN : PLAN_DRAWN_ONCE,
6454
- deps.vocab.demo ? noteFor(deps.vocab) : '',
6455
6558
  ]
6456
6559
  .filter(Boolean)
6457
6560
  .join('\n'),
@@ -6488,7 +6591,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6488
6591
  // would find out at the desk.
6489
6592
  return {
6490
6593
  structured: { view: 'member-not-found', ...common(deps, key), query: number },
6491
- text: `No ${deps.vocab.brand} membership matches that number and surname. Check both as they are printed; nothing else is needed - this connector never asks for a password.`,
6594
+ text: `No ${deps.vocab.brand} membership matches that number and surname. Check both as they are printed; nothing else is needed - a password is never asked for here.`,
6492
6595
  };
6493
6596
  }
6494
6597
  deps.members.set(key, member);
@@ -6507,7 +6610,6 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6507
6610
  member.preferences?.length
6508
6611
  ? `Saved preferences on this membership: ${member.preferences.map((k) => REQUEST_WORDS[k]).join(', ')}. They are ticked on the booking form; mention them, and ask before booking with them.`
6509
6612
  : '',
6510
- deps.vocab.demo ? noteFor(deps.vocab) : '',
6511
6613
  ]
6512
6614
  .filter(Boolean)
6513
6615
  .join('\n'),