@craftrpgs/cli 0.2.0-alpha.0 → 0.2.0-alpha.2

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 (2) hide show
  1. package/dist/bin.js +1402 -189
  2. package/package.json +3 -3
package/dist/bin.js CHANGED
@@ -26811,6 +26811,345 @@ function contentTypeForContentExtension(extension) {
26811
26811
  // ../shared/src/constants/gm-engine.ts
26812
26812
  var GM_ENGINES = ["v1", "v2"];
26813
26813
 
26814
+ // ../shared/src/board-time.ts
26815
+ var TIME_TRACKER_KEY = "time";
26816
+
26817
+ // ../shared/src/same-json.ts
26818
+ function sortedKeys(value) {
26819
+ if (Array.isArray(value)) {
26820
+ return value.map(sortedKeys);
26821
+ }
26822
+ if (value !== null && typeof value === "object") {
26823
+ const record2 = value;
26824
+ return Object.fromEntries(Object.keys(record2).filter((key) => record2[key] !== undefined).sort().map((key) => [key, sortedKeys(record2[key])]));
26825
+ }
26826
+ return value;
26827
+ }
26828
+ function sameJson(a, b) {
26829
+ return JSON.stringify(sortedKeys(a)) === JSON.stringify(sortedKeys(b));
26830
+ }
26831
+
26832
+ // ../shared/src/board-trackers.ts
26833
+ var TRACKER_TONES = ["good", "bad"];
26834
+ var TRACKER_ITEM_STATES = ["open", "done", "failed"];
26835
+ var TRACKER_LASTS = ["here", "game", "always"];
26836
+ var COUNT_DISPLAYS = ["number", "bar", "pips"];
26837
+ var RECORDS_DISPLAYS = ["table", "roster"];
26838
+ var TRACKER_LIMITS = {
26839
+ board: 10,
26840
+ name: 60,
26841
+ label: 40,
26842
+ text: 160,
26843
+ fields: 8,
26844
+ items: 16,
26845
+ entries: 20,
26846
+ records: 12,
26847
+ links: 12,
26848
+ recordFields: 4,
26849
+ show: 6,
26850
+ types: 6,
26851
+ sourceName: 80,
26852
+ marks: 10,
26853
+ pipsMax: 20,
26854
+ clockMin: 2,
26855
+ clockMax: 12,
26856
+ scaleSteps: 21
26857
+ };
26858
+ var labelSchema = exports_external.string().trim().max(TRACKER_LIMITS.label).nullish();
26859
+ var hiddenSchema = exports_external.boolean().nullish();
26860
+ var refSchema = exports_external.object({
26861
+ name: exports_external.string().trim().min(1).max(TRACKER_LIMITS.name),
26862
+ file: exports_external.string().nullish()
26863
+ });
26864
+ var showSchema = exports_external.array(exports_external.string().trim().min(1).max(TRACKER_LIMITS.label)).max(TRACKER_LIMITS.show).nullish();
26865
+ var showSourceSchema = exports_external.object({
26866
+ value: exports_external.string().trim().min(1).max(TRACKER_LIMITS.sourceName),
26867
+ max: exports_external.string().trim().min(1).max(TRACKER_LIMITS.sourceName).nullish()
26868
+ });
26869
+ var showSourcesSchema = exports_external.record(exports_external.string().trim().min(1).max(TRACKER_LIMITS.label), exports_external.record(exports_external.string().trim().min(1).max(TRACKER_LIMITS.sourceName), showSourceSchema)).nullish();
26870
+ var entryTypesSchema = exports_external.array(exports_external.string().trim().min(1).max(TRACKER_LIMITS.sourceName)).max(TRACKER_LIMITS.types).nullish();
26871
+ var numberShape = {
26872
+ label: labelSchema,
26873
+ hidden: hiddenSchema,
26874
+ value: exports_external.number(),
26875
+ min: exports_external.number().nullish(),
26876
+ max: exports_external.number().nullish(),
26877
+ tone: exports_external.enum(TRACKER_TONES).nullish(),
26878
+ at: exports_external.record(exports_external.string(), exports_external.string()).nullish(),
26879
+ start: exports_external.number().nullish()
26880
+ };
26881
+ var countFieldSchema = exports_external.object({
26882
+ kind: exports_external.literal("count"),
26883
+ ...numberShape,
26884
+ display: exports_external.enum(COUNT_DISPLAYS).nullish()
26885
+ });
26886
+ var clockFieldSchema = exports_external.object({ kind: exports_external.literal("clock"), ...numberShape });
26887
+ var scaleFieldSchema = exports_external.object({
26888
+ kind: exports_external.literal("scale"),
26889
+ ...numberShape,
26890
+ ends: exports_external.tuple([exports_external.string(), exports_external.string()]).nullish()
26891
+ });
26892
+ var listItemSchema = exports_external.object({
26893
+ text: exports_external.string().trim().min(1).max(TRACKER_LIMITS.text),
26894
+ state: exports_external.enum(TRACKER_ITEM_STATES),
26895
+ note: exports_external.string().trim().max(TRACKER_LIMITS.text).nullish(),
26896
+ ref: refSchema.nullish(),
26897
+ hidden: hiddenSchema
26898
+ });
26899
+ var listFieldSchema = exports_external.object({
26900
+ kind: exports_external.literal("list"),
26901
+ label: labelSchema,
26902
+ hidden: hiddenSchema,
26903
+ items: exports_external.array(listItemSchema).max(TRACKER_LIMITS.items)
26904
+ });
26905
+ var orderEntrySchema = exports_external.object({
26906
+ name: exports_external.string().trim().min(1),
26907
+ score: exports_external.number().nullish()
26908
+ });
26909
+ var orderFieldSchema = exports_external.object({
26910
+ kind: exports_external.literal("order"),
26911
+ label: labelSchema,
26912
+ hidden: hiddenSchema,
26913
+ entries: exports_external.array(orderEntrySchema).max(TRACKER_LIMITS.entries),
26914
+ current: exports_external.string().nullish(),
26915
+ round: exports_external.number().int().nullish(),
26916
+ show: showSchema,
26917
+ types: entryTypesSchema,
26918
+ sources: showSourcesSchema
26919
+ });
26920
+ var textFieldSchema = exports_external.object({
26921
+ kind: exports_external.literal("text"),
26922
+ label: labelSchema,
26923
+ hidden: hiddenSchema,
26924
+ text: exports_external.string().trim().max(TRACKER_LIMITS.text)
26925
+ });
26926
+ var refFieldSchema = exports_external.object({
26927
+ kind: exports_external.literal("ref"),
26928
+ label: labelSchema,
26929
+ hidden: hiddenSchema,
26930
+ ref: refSchema,
26931
+ show: showSchema
26932
+ });
26933
+ var trackerRecordFieldSchema = exports_external.discriminatedUnion("kind", [
26934
+ countFieldSchema,
26935
+ clockFieldSchema,
26936
+ scaleFieldSchema,
26937
+ listFieldSchema,
26938
+ textFieldSchema
26939
+ ]);
26940
+ var recordSchema = exports_external.object({
26941
+ ref: refSchema,
26942
+ hidden: hiddenSchema,
26943
+ fields: exports_external.array(trackerRecordFieldSchema).max(TRACKER_LIMITS.recordFields)
26944
+ });
26945
+ var recordsFieldSchema = exports_external.object({
26946
+ kind: exports_external.literal("records"),
26947
+ label: labelSchema,
26948
+ hidden: hiddenSchema,
26949
+ entries: exports_external.array(recordSchema).max(TRACKER_LIMITS.records),
26950
+ show: showSchema,
26951
+ types: entryTypesSchema,
26952
+ sources: showSourcesSchema,
26953
+ display: exports_external.enum(RECORDS_DISPLAYS).nullish(),
26954
+ each: exports_external.array(trackerRecordFieldSchema).max(TRACKER_LIMITS.recordFields).nullish()
26955
+ });
26956
+ var linkSchema = exports_external.object({
26957
+ between: exports_external.tuple([refSchema, refSchema]),
26958
+ hidden: hiddenSchema,
26959
+ fields: exports_external.array(trackerRecordFieldSchema).max(TRACKER_LIMITS.recordFields)
26960
+ });
26961
+ var linksFieldSchema = exports_external.object({
26962
+ kind: exports_external.literal("links"),
26963
+ label: labelSchema,
26964
+ hidden: hiddenSchema,
26965
+ entries: exports_external.array(linkSchema).max(TRACKER_LIMITS.links),
26966
+ types: entryTypesSchema,
26967
+ each: exports_external.array(trackerRecordFieldSchema).max(TRACKER_LIMITS.recordFields).nullish()
26968
+ });
26969
+ var trackerFieldSchema = exports_external.discriminatedUnion("kind", [
26970
+ countFieldSchema,
26971
+ clockFieldSchema,
26972
+ scaleFieldSchema,
26973
+ listFieldSchema,
26974
+ orderFieldSchema,
26975
+ textFieldSchema,
26976
+ refFieldSchema,
26977
+ recordsFieldSchema,
26978
+ linksFieldSchema
26979
+ ]);
26980
+ var trackerShapeSchema = exports_external.object({
26981
+ name: exports_external.string().trim().min(1).max(TRACKER_LIMITS.name),
26982
+ fields: exports_external.array(trackerFieldSchema).min(1).max(TRACKER_LIMITS.fields),
26983
+ for: exports_external.string().trim().min(1).nullish(),
26984
+ hidden: exports_external.boolean().nullish(),
26985
+ lasts: exports_external.enum(TRACKER_LASTS).nullish(),
26986
+ from: exports_external.string().nullish(),
26987
+ since: exports_external.string().nullish(),
26988
+ changed: exports_external.string().nullish()
26989
+ });
26990
+ function isObject2(value) {
26991
+ return typeof value === "object" && value !== null && !Array.isArray(value);
26992
+ }
26993
+ function upgradedRow(row) {
26994
+ if (!isObject2(row)) {
26995
+ return row;
26996
+ }
26997
+ if (row.kind === "count") {
26998
+ const { style, ...rest } = row;
26999
+ if (style === "clock" || style === "scale") {
27000
+ return { ...rest, kind: style };
27001
+ }
27002
+ return rest;
27003
+ }
27004
+ if (row.kind === "order" && Array.isArray(row.order)) {
27005
+ const { order, ...rest } = row;
27006
+ return { ...rest, entries: order };
27007
+ }
27008
+ return row;
27009
+ }
27010
+ function upgradedTracker(value) {
27011
+ if (!isObject2(value) || "fields" in value || !Array.isArray(value.rows)) {
27012
+ return value;
27013
+ }
27014
+ const { rows, ...rest } = value;
27015
+ return { ...rest, fields: rows.map(upgradedRow) };
27016
+ }
27017
+ var trackerSchema = exports_external.preprocess(upgradedTracker, trackerShapeSchema);
27018
+ var TRACKER_STARTS = [
27019
+ "game-start",
27020
+ "first-change",
27021
+ "when-started"
27022
+ ];
27023
+ var TRACKER_UPDATES = ["on-trigger", "every-turn"];
27024
+ var TRACKER_DEFINITION_LIMITS = {
27025
+ key: 60,
27026
+ when: 300,
27027
+ trigger: 200,
27028
+ question: 400,
27029
+ answer: 600
27030
+ };
27031
+ var trackerDefinitionSchema = exports_external.object({
27032
+ key: exports_external.string().trim().min(1).max(TRACKER_DEFINITION_LIMITS.key).regex(/^[a-z0-9][a-z0-9-]*$/, "Keys are lowercase letters, digits and -"),
27033
+ start: trackerShapeSchema.omit({ since: true, changed: true, from: true }),
27034
+ starts: exports_external.enum(TRACKER_STARTS),
27035
+ when: exports_external.string().trim().max(TRACKER_DEFINITION_LIMITS.when).nullish(),
27036
+ updates: exports_external.enum(TRACKER_UPDATES).nullish(),
27037
+ trigger: exports_external.string().trim().max(TRACKER_DEFINITION_LIMITS.trigger).nullish(),
27038
+ watch: exports_external.object({
27039
+ question: exports_external.string().trim().min(1).max(TRACKER_DEFINITION_LIMITS.question),
27040
+ yes: exports_external.string().trim().max(TRACKER_DEFINITION_LIMITS.answer).nullish(),
27041
+ no: exports_external.string().trim().max(TRACKER_DEFINITION_LIMITS.answer).nullish(),
27042
+ remind: exports_external.string().trim().max(TRACKER_DEFINITION_LIMITS.answer).nullish()
27043
+ }).nullish(),
27044
+ perPerson: exports_external.boolean().nullish(),
27045
+ personTypes: entryTypesSchema
27046
+ });
27047
+ var NUMBER_KINDS = new Set(["count", "clock", "scale"]);
27048
+ function isNumberField(field) {
27049
+ return NUMBER_KINDS.has(field.kind);
27050
+ }
27051
+ var ARTICLE_RE = /^(?:the|a|an)\s+/;
27052
+ var POSSESSIVE_RE = /['’]s\b/g;
27053
+ var NON_WORD_RE = /[^\p{L}\p{N}]+/gu;
27054
+ function trackerKey(name) {
27055
+ return name.toLowerCase().replace(POSSESSIVE_RE, "").replace(NON_WORD_RE, " ").trim().replace(ARTICLE_RE, "").trim();
27056
+ }
27057
+ function numberProblem(field, label) {
27058
+ if (field.kind === "clock") {
27059
+ const size = field.max ?? 0;
27060
+ if (size < TRACKER_LIMITS.clockMin || size > TRACKER_LIMITS.clockMax) {
27061
+ return `${label} is a clock of ${TRACKER_LIMITS.clockMin} to ${TRACKER_LIMITS.clockMax} segments`;
27062
+ }
27063
+ }
27064
+ if (field.kind === "scale") {
27065
+ if (field.min == null || field.max == null || field.min >= field.max) {
27066
+ return `${label} is a tug of war: give it a low end below its high end`;
27067
+ }
27068
+ if (field.max - field.min + 1 > TRACKER_LIMITS.scaleSteps) {
27069
+ return `${label} spans at most ${TRACKER_LIMITS.scaleSteps} steps`;
27070
+ }
27071
+ }
27072
+ if (field.min != null && field.max != null && field.min > field.max) {
27073
+ return `${label} has a lowest value above its highest`;
27074
+ }
27075
+ if (field.min != null && field.value < field.min || field.max != null && field.value > field.max) {
27076
+ return `${label} starts outside its range`;
27077
+ }
27078
+ return null;
27079
+ }
27080
+ function fieldsProblem(fields, where) {
27081
+ const labels = fields.map((field) => field.label?.trim() ?? "");
27082
+ if (fields.length > 1 && labels.some((label) => !label)) {
27083
+ return `${where} has more than one field, so each needs a label`;
27084
+ }
27085
+ const keys = labels.filter(Boolean).map(trackerKey);
27086
+ if (new Set(keys).size !== keys.length) {
27087
+ return `${where} has two fields with the same label`;
27088
+ }
27089
+ for (const [index, field] of fields.entries()) {
27090
+ const label = labels[index] ? `"${labels[index]}"` : where;
27091
+ const problem = isNumberField(field) ? numberProblem(field, label) : null;
27092
+ if (problem) {
27093
+ return problem;
27094
+ }
27095
+ if ((field.kind === "records" || field.kind === "links") && field.each?.length) {
27096
+ const inner = fieldsProblem(field.each, `What each ${field.kind === "links" ? "link" : "record"} of ${label} starts with`);
27097
+ if (inner) {
27098
+ return inner;
27099
+ }
27100
+ }
27101
+ }
27102
+ return null;
27103
+ }
27104
+ var QUOTE_RE = /"/;
27105
+ function needsStartTrigger(definition) {
27106
+ return definition.starts === "when-started" && !definition.trigger?.trim() && !QUOTE_RE.test(definition.when ?? "");
27107
+ }
27108
+ function blankStartingName(definition) {
27109
+ for (const field of definition.start.fields) {
27110
+ if (field.kind === "links" && field.entries.some((link) => link.between.some((end) => !end.name.trim()))) {
27111
+ return "a name on each side of every pair it starts with";
27112
+ }
27113
+ const names = field.kind === "records" && field.entries.map((entry) => entry.ref.name) || field.kind === "order" && field.entries.map((entry) => entry.name) || field.kind === "list" && field.items.map((item) => item.text) || [];
27114
+ if (names.some((each) => !each.trim())) {
27115
+ return "a name for everyone and everything it starts with";
27116
+ }
27117
+ }
27118
+ return null;
27119
+ }
27120
+ function updatesEveryTurn(definition) {
27121
+ return definition.updates === "every-turn";
27122
+ }
27123
+ function trackerDefinitionProblem(definition) {
27124
+ const name = definition.start.name.trim();
27125
+ const tracker = name ? `The tracker "${name}"` : "A tracker";
27126
+ if (!name) {
27127
+ return "A tracker needs a name.";
27128
+ }
27129
+ if (definition.start.lasts === "always" && definition.starts !== "game-start") {
27130
+ return `${tracker} never goes away, so it is on the board from the game's start: its "starts" is "game-start".`;
27131
+ }
27132
+ if (needsStartTrigger(definition)) {
27133
+ return `${tracker} starts on a trigger, so it needs to say what starts it.`;
27134
+ }
27135
+ if (updatesEveryTurn(definition)) {
27136
+ if (definition.when?.trim() || definition.watch) {
27137
+ return `${tracker} updates every turn, so it has no update trigger: leave out "when" and "watch".`;
27138
+ }
27139
+ } else if (!definition.when?.trim()) {
27140
+ return `${tracker} needs to say what updates it.`;
27141
+ }
27142
+ if (definition.watch && !definition.watch.question.trim()) {
27143
+ return `${tracker} updates on a trigger, so it needs the trigger to check for.`;
27144
+ }
27145
+ const problem = fieldsProblem(definition.start.fields, tracker);
27146
+ if (problem) {
27147
+ return `${problem}.`;
27148
+ }
27149
+ const blank = blankStartingName(definition);
27150
+ return blank ? `${tracker} needs ${blank}.` : null;
27151
+ }
27152
+
26814
27153
  // ../shared/src/platform-instructions.ts
26815
27154
  var PLATFORM_INSTRUCTIONS = [
26816
27155
  {
@@ -26867,12 +27206,150 @@ End each reply on something concrete in front of the player's character that the
26867
27206
  },
26868
27207
  {
26869
27208
  id: "combat-tracker",
26870
- revision: 1,
27209
+ revision: 6,
26871
27210
  instruction: {
26872
27211
  name: "Combat tracker",
26873
27212
  when: "always",
26874
27213
  section: "Rules",
26875
- body: `When a fight begins, start a turn order on the board with everyone taking part, in the same update() as the fight's first changes: "Start a turn order, Fight at the mill: Kael 18, Lookout 1 15". When the fight is over (every foe down, fled or surrendered, or the fighting stops), end it: "Fight at the mill": "End".`
27214
+ body: `When the fight is over (every foe down, fled or surrendered, or the fighting stops), end it: "<its name>": "End".`,
27215
+ tracker: {
27216
+ key: "fight",
27217
+ start: {
27218
+ name: "Combat",
27219
+ fields: [
27220
+ {
27221
+ kind: "order",
27222
+ entries: [],
27223
+ round: 1,
27224
+ show: ["HP", "Conditions"]
27225
+ }
27226
+ ]
27227
+ },
27228
+ starts: "when-started",
27229
+ trigger: "a fight begins",
27230
+ when: "a fighter's turn ends, or someone joins or leaves the fight"
27231
+ }
27232
+ }
27233
+ },
27234
+ {
27235
+ id: "objectives-tracker",
27236
+ revision: 2,
27237
+ instruction: {
27238
+ name: "Objectives",
27239
+ when: "always",
27240
+ section: "Rules",
27241
+ body: `The party's objectives are one list for the whole game: what they are working toward beyond this scene. Add one when the story gives the party one (an order, a job or request taken on, a goal of their own that outlasts the scene), worded as the party knows it then, short, one goal to an objective: "Objectives": "Add: Find who opened the lid". Mark it done or failed when the story settles it: "Objectives": "Find who opened the lid: done". Never write a quest's later steps ahead of the story: they come as objectives of their own. One that runs over time (keep watch, log every contact) is done only when its end comes.`,
27242
+ tracker: {
27243
+ key: "objectives",
27244
+ start: {
27245
+ name: "Objectives",
27246
+ lasts: "game",
27247
+ fields: [{ kind: "list", items: [] }]
27248
+ },
27249
+ starts: "first-change",
27250
+ when: "the story gives the party an objective (an order, a job or request they take on, a goal of their own that outlasts the scene)",
27251
+ watch: {
27252
+ question: "In `earlier`, `player_turn` or `narration`, has the party been given an objective, something they are now working toward beyond this scene, that {tracker} does not hold under any wording?",
27253
+ yes: 'Yes: an order or an assignment handed to them (they act on it or do not refuse it), a request or a job they take on, or a goal they set themselves that will outlast this scene ("Find what woke the Sleeper", "Get the ledger to the Abbot", "Learn who signed the panel"), and nothing in {tracker} says it.',
27254
+ no: "No: {tracker} already holds it, however worded; or it is only floated, refused or still being weighed; or it is only what this scene is for, the next move (go to the archives, ask the clerk, cross the yard), or something done in the same moment.",
27255
+ remind: `the story gave the party an objective the board's Objectives list does not hold (add it as the party knows it now, short, one goal to an objective: "Objectives": "Add: <the objective>")`
27256
+ }
27257
+ }
27258
+ }
27259
+ },
27260
+ {
27261
+ id: "party-tracker",
27262
+ revision: 2,
27263
+ instruction: {
27264
+ name: "Party",
27265
+ when: "always",
27266
+ section: "Rules",
27267
+ body: `The Party is everyone who travels and acts with the player's character as one of them: the player's character, who is always in it, and the companions who have joined. Add someone when they join ("Party": "Add Mira"), and take them off when they leave, part ways for good or die ("Party": "Remove Mira"). Someone who only helps for a while (a guide for the afternoon, a guard who fights beside them once) is not in it unless the story has them join.`,
27268
+ tracker: {
27269
+ key: "party",
27270
+ start: {
27271
+ name: "Party",
27272
+ lasts: "game",
27273
+ fields: [
27274
+ {
27275
+ kind: "records",
27276
+ entries: [],
27277
+ show: ["HP", "Conditions"],
27278
+ display: "roster"
27279
+ }
27280
+ ]
27281
+ },
27282
+ starts: "game-start",
27283
+ when: "someone joins the party or leaves it",
27284
+ watch: {
27285
+ question: "In `earlier`, `player_turn` or `narration`, has someone joined the party (now travelling and acting with the player's character as one of them) or left it, that {tracker} does not show?",
27286
+ yes: `Yes: someone agrees to come along, is taken on, or sets out with them as one of the group ("I'll come with you", hired for the journey, rescued and staying with them); or someone {tracker} lists leaves, parts ways for good, is left behind or dies.`,
27287
+ no: "No: {tracker} already shows who is in the party; or someone only helps for a moment, offers to come but has not joined, or is only met, fought beside or travelled near.",
27288
+ remind: `someone joined or left the party and the board's Party does not show it (add or take them off: "Party": "Add <name>", "Party": "Remove <name>")`
27289
+ }
27290
+ }
27291
+ }
27292
+ },
27293
+ {
27294
+ id: "death-saves-tracker",
27295
+ revision: 3,
27296
+ instruction: {
27297
+ name: "Death saves",
27298
+ when: "always",
27299
+ section: "Rules",
27300
+ body: `Only for a world whose rules have the dying roll to stay alive. Each save is a roll on their turn: the player's for their character, yours for anyone else. Record each one as it lands ("Death saves for Sara": "Successes +1", "Death saves for Sara": "Failures +1"). When they stabilize, are healed or die, end it ("Death saves for Sara": "End").`,
27301
+ tracker: {
27302
+ key: "death-saves",
27303
+ start: {
27304
+ name: "Death saves",
27305
+ fields: [
27306
+ {
27307
+ kind: "count",
27308
+ label: "Successes",
27309
+ value: 0,
27310
+ min: 0,
27311
+ max: 3,
27312
+ tone: "good",
27313
+ display: "pips",
27314
+ at: { "3": "Stable" }
27315
+ },
27316
+ {
27317
+ kind: "count",
27318
+ label: "Failures",
27319
+ value: 0,
27320
+ min: 0,
27321
+ max: 3,
27322
+ tone: "bad",
27323
+ display: "pips",
27324
+ at: { "3": "Dies" }
27325
+ }
27326
+ ]
27327
+ },
27328
+ starts: "when-started",
27329
+ trigger: "someone falls dying, and the world's rules have them roll to stay alive",
27330
+ perPerson: true,
27331
+ when: "they make a death save"
27332
+ }
27333
+ }
27334
+ },
27335
+ {
27336
+ id: "time-tracker",
27337
+ revision: 3,
27338
+ instruction: {
27339
+ name: "Time",
27340
+ when: "always",
27341
+ section: "Rules",
27342
+ body: `The Time tracker is the in-game time, and only you move it: nothing moves it after the turn. It is always a time someone could tell by a clock or the sky, in the world's own terms: a clock hour ("10:40 pm"), a part of the day ("dawn", "late afternoon") or how long until something ("about 2 hours until sunset"); never only how long passed ("30 minutes later": later than what?). Whenever the story takes time worth counting (a journey, a rest, a wait, a long job or talk), set it to the time it is now in the update() for that stretch of story, beside its other changes: "Time": "It is now dusk". When it shows a clock hour, a span is enough ("Time": "40 minutes pass") and the clock adds it. With nothing else to record, the time is still an update() of its own. A line or two of talk needs no record. Tell it once, as the time it is now: "It is now 12:10 am", "It is now dawn", "It is now about an hour until sunset"; never "a while later" or "30 minutes later". A rest recorded with its length ("scene": "The party rests for 1 hour") already moves the clock, so no "Time": "1 hour passes" after it. Narrate the hour, the light and what is open from the board's time, never from a guess.`,
27343
+ tracker: {
27344
+ key: "time",
27345
+ start: {
27346
+ name: "Time",
27347
+ lasts: "game",
27348
+ fields: [{ kind: "text", text: "" }]
27349
+ },
27350
+ starts: "game-start",
27351
+ when: "the story takes time worth counting (a journey, a rest, a wait, a long job or talk)"
27352
+ }
26876
27353
  }
26877
27354
  },
26878
27355
  {
@@ -26898,7 +27375,7 @@ End each reply on something concrete in front of the player's character that the
26898
27375
  }
26899
27376
  ];
26900
27377
  var RESPONSE_LENGTH = "Response length";
26901
- var ROLL_NUMBERS = "Roll numbers in narration";
27378
+ var LONG_TRAVEL = "Long-distance travel";
26902
27379
  var PLATFORM_OPTIONS = [
26903
27380
  {
26904
27381
  revision: 3,
@@ -26952,33 +27429,39 @@ var PLATFORM_OPTIONS = [
26952
27429
  }
26953
27430
  },
26954
27431
  {
26955
- revision: 2,
27432
+ revision: 1,
26956
27433
  option: {
26957
- key: "platform-roll-numbers",
26958
- label: ROLL_NUMBERS,
26959
- description: "Whether the story repeats the dice. Rolls always show as dice cards in the chat.",
27434
+ key: "platform-long-distance-travel",
27435
+ label: LONG_TRAVEL,
27436
+ description: "What happens when the party travels somewhere far away.",
26960
27437
  kind: "one",
26961
- default: ["left-out"],
27438
+ default: ["up-to-gm"],
26962
27439
  choices: [
26963
27440
  {
26964
- key: "left-out",
26965
- label: "Exclude",
26966
- description: "The story tells what happens, not the numbers.",
27441
+ key: "up-to-gm",
27442
+ label: "Up to the GM",
27443
+ description: "The Game Master decides when the road is worth playing.",
27444
+ instructions: []
27445
+ },
27446
+ {
27447
+ key: "fast-travel",
27448
+ label: "Fast travel",
27449
+ description: "Skip ahead to the destination, with the trip summed up.",
26967
27450
  instructions: [
26968
27451
  {
26969
27452
  when: "always",
26970
- body: "Never write dice, totals or difficulties in the story: the dice cards show them. Tell what the roll's outcome looks and feels like."
27453
+ body: `When the party travels somewhere far (a trip of hours or more), take them straight there in the same reply: record it as arriving ("Move Kael and Mira to Harrowmere"), never "set out for", sum up the trip in a sentence or two (how long it took, how the way went), and go on from the arrival. When the player set out from the map, tell the arrival the same way.`
26971
27454
  }
26972
27455
  ]
26973
27456
  },
26974
27457
  {
26975
- key: "shown",
26976
- label: "Include",
26977
- description: "Each total in a short bracket where it lands.",
27458
+ key: "journey",
27459
+ label: "Journey",
27460
+ description: "Travel there step by step, with the road played out.",
26978
27461
  instructions: [
26979
27462
  {
26980
27463
  when: "always",
26981
- body: "Show each roll's total once, in a short bracket where it lands in the story: (Climb: 11 + 3 = 14 vs DC 12), or for an attack and its damage (15 vs AC 14, 4 piercing damage)."
27464
+ body: `When the party travels somewhere far (a trip of hours or more), play it as a journey to where the player said they are going. Set out first ("Kael and Mira set out for Harrowmere by the coast road; it takes about three days"), then tell the way in stages over several replies, for as long as the distance really takes: the road and the weather, rests and camps, people and trouble met on the way, with time passing as it would ("It is now dusk"). The party stays on the way the whole time: a camp or a rest is told with the time it takes, never a move to a camp or a stretch of road. Someone met stops the journey where they meet; when the party goes on from there, record it as "Kael and Mira set out again for Harrowmere", never as a move farther along the road. Keep heading for the place the player named unless they change course, and record the arrival ("Move Kael and Mira to Harrowmere") only once the way has been travelled. When the player set out from the map, they are already on the way: go on with the journey.`
26982
27465
  }
26983
27466
  ]
26984
27467
  }
@@ -26995,7 +27478,7 @@ var GAME_OPTION_LABEL_MAX_LENGTH = 60;
26995
27478
  var GAME_OPTION_DESCRIPTION_MAX_LENGTH = 200;
26996
27479
  var GAME_OPTION_KEY_MAX_LENGTH = 60;
26997
27480
  var GAME_OPTIONS_MAX_OPTIONS = 8;
26998
- var GAME_OPTIONS_READ_MAX_OPTIONS = 12 + PLATFORM_OPTIONS.length;
27481
+ var GAME_OPTIONS_READ_MAX_OPTIONS = 15;
26999
27482
  var GAME_OPTION_MAX_CHOICES = 8;
27000
27483
  var GAME_OPTIONS_MAX_INSTRUCTIONS = 200;
27001
27484
  var GAME_OPTIONS_MAX_ALWAYS_LENGTH = 1e4;
@@ -27011,6 +27494,7 @@ var gameOptionInstructionSchema = exports_external.object({
27011
27494
  when: exports_external.enum(GAME_OPTION_INSTRUCTION_WHEN),
27012
27495
  trigger: exports_external.string().trim().max(GAME_OPTION_INSTRUCTION_NAME_MAX_LENGTH).optional(),
27013
27496
  section: exports_external.string().trim().max(GAME_OPTION_SECTION_MAX_LENGTH).optional(),
27497
+ tracker: trackerDefinitionSchema.optional(),
27014
27498
  platform: exports_external.string().max(GAME_OPTION_KEY_MAX_LENGTH).optional()
27015
27499
  });
27016
27500
  var gameOptionChoiceSchema = exports_external.object({
@@ -27082,6 +27566,12 @@ function sectionsOf(options) {
27082
27566
  return [...new Set(names)];
27083
27567
  }
27084
27568
  function instructionProblem(instruction, base) {
27569
+ if (instruction.tracker) {
27570
+ const problem = trackerDefinitionProblem(instruction.tracker);
27571
+ if (problem) {
27572
+ return problem.charAt(0).toLowerCase() + problem.slice(1);
27573
+ }
27574
+ }
27085
27575
  if (instruction.when === "situational" && !instruction.trigger?.trim()) {
27086
27576
  return "a situational instruction needs the moment it's for.";
27087
27577
  }
@@ -27106,7 +27596,28 @@ function gameOptionsProblem(options) {
27106
27596
  }
27107
27597
  }
27108
27598
  }
27109
- return gameOptionsStructureProblem(options) ?? gameOptionsLimitProblem(options);
27599
+ return trackerKeysProblem(options) ?? gameOptionsStructureProblem(options) ?? gameOptionsLimitProblem(options);
27600
+ }
27601
+ function trackerKeysProblem(options) {
27602
+ const lists = [
27603
+ options.instructions.filter((instruction) => !instruction.platform),
27604
+ ...options.options.flatMap((option) => option.choices.map((choice) => choice.instructions))
27605
+ ];
27606
+ for (const list of lists) {
27607
+ const seen = new Map;
27608
+ for (const instruction of list) {
27609
+ const definition = instruction.tracker;
27610
+ if (!definition) {
27611
+ continue;
27612
+ }
27613
+ const other = seen.get(definition.key);
27614
+ if (other !== undefined) {
27615
+ return `The trackers "${other}" and "${definition.start.name}" share the key "${definition.key}": rename one.`;
27616
+ }
27617
+ seen.set(definition.key, definition.start.name);
27618
+ }
27619
+ }
27620
+ return null;
27110
27621
  }
27111
27622
  function gameOptionsStructureProblem(options) {
27112
27623
  const optionKeys = new Set;
@@ -27167,6 +27678,54 @@ var storedGameOptionsSchema = storedGameOptionsShape.superRefine((options, ctx)
27167
27678
  ctx.addIssue({ code: "custom", message: problem });
27168
27679
  }
27169
27680
  });
27681
+ function droppedKeys({
27682
+ raw,
27683
+ parsed,
27684
+ path = ""
27685
+ }) {
27686
+ if (Array.isArray(raw) && Array.isArray(parsed)) {
27687
+ return raw.flatMap((item, index) => droppedKeys({
27688
+ raw: item,
27689
+ parsed: parsed[index],
27690
+ path: `${path}[${index}]`
27691
+ }));
27692
+ }
27693
+ if (typeof raw !== "object" || raw === null || typeof parsed !== "object" || parsed === null) {
27694
+ return [];
27695
+ }
27696
+ return Object.entries(raw).flatMap(([key, value]) => (key in parsed) ? droppedKeys({
27697
+ raw: value,
27698
+ parsed: parsed[key],
27699
+ path: `${path}.${key}`
27700
+ }) : [`${path}.${key}`]);
27701
+ }
27702
+ var INDEX_RE = /\[\d+\]/g;
27703
+ var LEADING_DOT_RE = /^\./;
27704
+ function unknownGameOptionsKeys({
27705
+ raw,
27706
+ before
27707
+ }) {
27708
+ const parsed = storedGameOptionsShape.safeParse(raw);
27709
+ if (!parsed.success) {
27710
+ return [];
27711
+ }
27712
+ const stored = before === undefined || before === null ? null : storedGameOptionsShape.safeParse(before);
27713
+ const old = new Set(stored?.success ? droppedKeys({ raw: before, parsed: stored.data }).map((path) => path.replace(INDEX_RE, "[]")) : []);
27714
+ return droppedKeys({ raw, parsed: parsed.data }).filter((path) => !old.has(path.replace(INDEX_RE, "[]"))).map((path) => path.replace(LEADING_DOT_RE, ""));
27715
+ }
27716
+ function unknownGameOptionsKeysProblem({
27717
+ raw,
27718
+ before,
27719
+ where
27720
+ }) {
27721
+ const unknown2 = unknownGameOptionsKeys({ raw, before });
27722
+ if (unknown2.length === 0) {
27723
+ return null;
27724
+ }
27725
+ const listed = unknown2.slice(0, 5).map((path) => `${where}.${path}`).join(", ");
27726
+ const more = unknown2.length > 5 ? ` and ${unknown2.length - 5} more` : "";
27727
+ return `${listed}${more}: the format has no ${unknown2.length === 1 ? "such key" : "such keys"} there, and it would be dropped without effect. Check the field names and where each sits (a tracker's "lasts", "name", "fields" and "hidden" go inside its "start").`;
27728
+ }
27170
27729
  var resolvedGameOptionsSchema = gameOptionsShape.superRefine((options, ctx) => {
27171
27730
  const problem = gameOptionsProblem(options);
27172
27731
  if (problem) {
@@ -27239,7 +27798,13 @@ function withPlatform(stored) {
27239
27798
  const content = override ? overrideInstruction(override) : entry.instruction;
27240
27799
  const { section: ownSection, ...rest } = content;
27241
27800
  const section = placedIn?.trim() || ownSection?.trim() || entry.instruction.section;
27242
- return { ...rest, ...section ? { section } : {}, platform: entry.id };
27801
+ const tracker = rest.tracker ?? entry.instruction.tracker;
27802
+ return {
27803
+ ...rest,
27804
+ ...tracker ? { tracker } : {},
27805
+ ...section ? { section } : {},
27806
+ platform: entry.id
27807
+ };
27243
27808
  }
27244
27809
  const instructions = [];
27245
27810
  const placed = new Set;
@@ -27315,6 +27880,12 @@ function instructionSignature(instruction) {
27315
27880
  instruction.trigger?.trim() ?? ""
27316
27881
  ]);
27317
27882
  }
27883
+ function sameTracker(a, b) {
27884
+ return sameJson(a ?? null, b ?? null);
27885
+ }
27886
+ function craftTracker(instruction, entry) {
27887
+ return sameTracker(instruction.tracker ?? entry.instruction.tracker, entry.instruction.tracker);
27888
+ }
27318
27889
  function optionSignature(option) {
27319
27890
  return JSON.stringify([
27320
27891
  option.label.trim(),
@@ -27340,7 +27911,7 @@ function platformInstructionStatus(instruction, stored) {
27340
27911
  if (!entry) {
27341
27912
  return null;
27342
27913
  }
27343
- if (instructionSignature(withoutPlatformTag(instruction)) === instructionSignature(entry.instruction)) {
27914
+ if (instructionSignature(withoutPlatformTag(instruction)) === instructionSignature(entry.instruction) && craftTracker(instruction, entry)) {
27344
27915
  return "platform";
27345
27916
  }
27346
27917
  const before = parseGameOptions(stored);
@@ -27375,10 +27946,11 @@ function alwaysTextLength(instructions) {
27375
27946
  return always.reduce((total, instruction) => total + instruction.body.length, 0) + [...titles].reduce((total, title) => total + title.length, 0);
27376
27947
  }
27377
27948
  function alwaysLength(instructions, place) {
27378
- return alwaysTextLength(instructions.map((instruction) => ({
27949
+ const trackerLines = instructions.reduce((total, instruction) => instruction.tracker ? total + instruction.tracker.start.name.length + (instruction.tracker.when?.length ?? 0) + 4 : total, 0);
27950
+ return alwaysTextLength(instructions.filter((instruction) => !instruction.tracker).map((instruction) => ({
27379
27951
  ...instruction,
27380
27952
  title: instructionTitle(instruction, place)
27381
- })));
27953
+ }))) + trackerLines;
27382
27954
  }
27383
27955
  function situationalCount(instructions, _place) {
27384
27956
  return instructions.filter((instruction) => instruction.when === "situational").length;
@@ -36444,8 +37016,52 @@ function validateFileContentAgainstSchemaVariants(contents, schema) {
36444
37016
  return sharedErrors.length > 0 ? { status: "invalid", errors: sharedErrors } : { status: "valid" };
36445
37017
  }
36446
37018
 
37019
+ // ../shared/src/vfs/path.ts
37020
+ var LEADING_SLASHES = /^\/+/;
37021
+ var TRAILING_SLASHES = /\/+$/;
37022
+ function parseVfsPath(path) {
37023
+ const trimmed = path.replace(LEADING_SLASHES, "").replace(TRAILING_SLASHES, "");
37024
+ const isDir = path.endsWith("/") || path === "/";
37025
+ if (!trimmed) {
37026
+ return { kind: "folder", segments: [] };
37027
+ }
37028
+ const parts = trimmed.split("/").map((part) => part.trim());
37029
+ if (parts.some((part) => part.length === 0)) {
37030
+ throw new Error(`Invalid path "${path}": empty path segment`);
37031
+ }
37032
+ if (parts.some((part) => part === "." || part === "..")) {
37033
+ throw new Error(`Invalid path "${path}": "." and ".." path segments are not allowed`);
37034
+ }
37035
+ const lastPart = parts.at(-1);
37036
+ if (isDir || !lastPart || !lastPart.includes(".")) {
37037
+ return { kind: "folder", segments: parts };
37038
+ }
37039
+ const dotIdx = lastPart.lastIndexOf(".");
37040
+ const finalSuffix = lastPart.slice(dotIdx + 1);
37041
+ const stem = lastPart.slice(0, dotIdx);
37042
+ const stemDotIdx = stem.lastIndexOf(".");
37043
+ const hasContentExtension = isContentExtension(finalSuffix) && stemDotIdx > 0;
37044
+ return {
37045
+ kind: "file",
37046
+ segments: parts.slice(0, -1),
37047
+ baseName: hasContentExtension ? stem.slice(0, stemDotIdx) : stem,
37048
+ extension: hasContentExtension ? stem.slice(stemDotIdx + 1) : finalSuffix,
37049
+ ...hasContentExtension && { contentExtension: finalSuffix }
37050
+ };
37051
+ }
37052
+ var UNSAFE_DISK_SEGMENT_CHARS = /[\\/:*?"<>|\u0000-\u001f\u007f]/g;
37053
+ function sanitizeVfsSegmentForDisk(segment) {
37054
+ if (segment === "." || segment === ".." || segment.trim().length === 0) {
37055
+ return "_";
37056
+ }
37057
+ const cleaned = segment.replace(UNSAFE_DISK_SEGMENT_CHARS, "_").trim();
37058
+ return cleaned.length > 0 ? cleaned : "_";
37059
+ }
37060
+ function sanitizeVfsPathForDisk(path) {
37061
+ return path.split("/").map((segment) => segment === "" ? "" : sanitizeVfsSegmentForDisk(segment)).join("/");
37062
+ }
37063
+
36447
37064
  // ../shared/src/files/file-reference-content.ts
36448
- var FILE_REFERENCE_EXTENSION_RE = /\.[^.]+$/;
36449
37065
  var STATE_PATH_LEADING_SLASH_RE = /^\/+/;
36450
37066
  var STATE_PATH_ARRAY_INDEX_RE = /\[(?:\d+|\*)\]/g;
36451
37067
  function isRecord5(value) {
@@ -36596,23 +37212,36 @@ function referenceHintsFromLayout(layout) {
36596
37212
  collectLayoutReferenceHints(layout, hints);
36597
37213
  return hints;
36598
37214
  }
36599
- function referenceCandidate(value) {
37215
+ function referenceNameParts(value) {
36600
37216
  const trimmed = value.trim();
36601
37217
  const lastSegment = trimmed.split("/").filter(Boolean).at(-1) ?? trimmed;
36602
- return lastSegment.replace(FILE_REFERENCE_EXTENSION_RE, "");
37218
+ try {
37219
+ const parsed = parseVfsPath(lastSegment);
37220
+ if (parsed.kind === "file") {
37221
+ return { name: parsed.baseName, type: parsed.extension };
37222
+ }
37223
+ } catch {}
37224
+ return { name: lastSegment, type: null };
37225
+ }
37226
+ function referenceCandidate(value) {
37227
+ return referenceNameParts(value).name;
36603
37228
  }
36604
37229
  function normalizeFileReferenceLookupValue(value) {
36605
37230
  const candidate = referenceCandidate(value);
36606
37231
  return generateSlug(candidate) || candidate.toLowerCase();
36607
37232
  }
36608
37233
  function qualifiedLookupTypeSlug(value, fileTypeSlugs) {
37234
+ const normalizedTargets = new Set(fileTypeSlugs.map((slug) => normalizeReferenceTargetSlug(slug)).filter(Boolean));
36609
37235
  const segments = value.trim().split("/").filter(Boolean);
36610
- if (segments.length < 2) {
36611
- return null;
37236
+ if (segments.length >= 2) {
37237
+ const prefix = normalizeReferenceTargetSlug(segments[0]);
37238
+ if (normalizedTargets.has(prefix)) {
37239
+ return prefix;
37240
+ }
36612
37241
  }
36613
- const prefix = normalizeReferenceTargetSlug(segments[0]);
36614
- const normalizedTargets = new Set(fileTypeSlugs.map((slug) => normalizeReferenceTargetSlug(slug)).filter(Boolean));
36615
- return normalizedTargets.has(prefix) ? prefix : null;
37242
+ const ending = referenceNameParts(value).type;
37243
+ const type = ending ? normalizeReferenceTargetSlug(ending) : "";
37244
+ return type && normalizedTargets.has(type) ? type : null;
36616
37245
  }
36617
37246
  function parseFileReferenceLookupValue(value, fileTypeSlugs = []) {
36618
37247
  const fileTypeSlug = qualifiedLookupTypeSlug(value, fileTypeSlugs);
@@ -40168,85 +40797,6 @@ function validateSectionsBlock(sections, errors3, options) {
40168
40797
  }
40169
40798
  }
40170
40799
  }
40171
- // ../shared/src/board-trackers.ts
40172
- var TRACKER_COUNT_STYLES = [
40173
- "number",
40174
- "bar",
40175
- "pips",
40176
- "boxes",
40177
- "clock",
40178
- "scale"
40179
- ];
40180
- var TRACKER_TONES = ["good", "bad"];
40181
- var TRACKER_ITEM_STATES = ["open", "done", "failed"];
40182
- var TRACKER_LASTS = ["here", "game"];
40183
- var TRACKER_LIMITS = {
40184
- name: 60,
40185
- label: 40,
40186
- text: 160,
40187
- rows: 6,
40188
- items: 16,
40189
- order: 20,
40190
- marks: 20,
40191
- clockMin: 2,
40192
- clockMax: 12,
40193
- scaleSteps: 21
40194
- };
40195
- var labelSchema = exports_external.string().trim().max(TRACKER_LIMITS.label).nullish();
40196
- var countRowSchema = exports_external.object({
40197
- kind: exports_external.literal("count"),
40198
- label: labelSchema,
40199
- value: exports_external.number(),
40200
- min: exports_external.number().nullish(),
40201
- max: exports_external.number().nullish(),
40202
- style: exports_external.enum(TRACKER_COUNT_STYLES).nullish(),
40203
- tone: exports_external.enum(TRACKER_TONES).nullish(),
40204
- ends: exports_external.tuple([exports_external.string(), exports_external.string()]).nullish(),
40205
- at: exports_external.record(exports_external.string(), exports_external.string()).nullish(),
40206
- start: exports_external.number().nullish()
40207
- });
40208
- var listItemSchema = exports_external.object({
40209
- text: exports_external.string().trim().min(1).max(TRACKER_LIMITS.text),
40210
- state: exports_external.enum(TRACKER_ITEM_STATES),
40211
- note: exports_external.string().trim().max(TRACKER_LIMITS.text).nullish()
40212
- });
40213
- var listRowSchema = exports_external.object({
40214
- kind: exports_external.literal("list"),
40215
- label: labelSchema,
40216
- items: exports_external.array(listItemSchema).max(TRACKER_LIMITS.items)
40217
- });
40218
- var orderEntrySchema = exports_external.object({
40219
- name: exports_external.string().trim().min(1),
40220
- score: exports_external.number().nullish()
40221
- });
40222
- var orderRowSchema = exports_external.object({
40223
- kind: exports_external.literal("order"),
40224
- label: labelSchema,
40225
- order: exports_external.array(orderEntrySchema).max(TRACKER_LIMITS.order),
40226
- current: exports_external.string().nullish(),
40227
- round: exports_external.number().int().nullish()
40228
- });
40229
- var textRowSchema = exports_external.object({
40230
- kind: exports_external.literal("text"),
40231
- label: labelSchema,
40232
- text: exports_external.string().trim().max(TRACKER_LIMITS.text)
40233
- });
40234
- var trackerRowSchema = exports_external.discriminatedUnion("kind", [
40235
- countRowSchema,
40236
- listRowSchema,
40237
- orderRowSchema,
40238
- textRowSchema
40239
- ]);
40240
- var trackerSchema = exports_external.object({
40241
- name: exports_external.string().trim().min(1).max(TRACKER_LIMITS.name),
40242
- rows: exports_external.array(trackerRowSchema).min(1).max(TRACKER_LIMITS.rows),
40243
- for: exports_external.string().trim().min(1).nullish(),
40244
- hidden: exports_external.boolean().nullish(),
40245
- lasts: exports_external.enum(TRACKER_LASTS).nullish(),
40246
- since: exports_external.string().nullish(),
40247
- changed: exports_external.string().nullish()
40248
- });
40249
-
40250
40800
  // ../shared/src/scripts/board.ts
40251
40801
  var UPDATE_KEYS = new Set([
40252
40802
  "arrive",
@@ -41012,19 +41562,22 @@ var FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V6 = "file-designation-metadata:v6"
41012
41562
  var FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V7 = "file-designation-metadata:v7";
41013
41563
  var FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V8 = "file-designation-metadata:v8";
41014
41564
  var FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V9 = "file-designation-metadata:v9";
41565
+ var FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V10 = "file-designation-metadata:v10";
41566
+ var FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V11 = "file-designation-metadata:v11";
41015
41567
  var GAME_START_OPENING_MAX_LENGTH = 1e4;
41016
41568
  var GAME_START_MAX_OPENING_BEATS = 50;
41017
41569
  var GAME_START_MAX_CHARACTER_OPENINGS = 100;
41018
41570
  var GAME_START_GM_NOTES_MAX_LENGTH = 40000;
41019
41571
  var GAME_START_BOARD_LIMITS = {
41020
41572
  people: 30,
41021
- time: 80,
41022
41573
  purpose: 300,
41023
41574
  area: 60,
41024
41575
  hiddenAs: 160,
41025
41576
  note: 160,
41026
41577
  objectives: 16,
41027
- objective: 160
41578
+ objective: 160,
41579
+ trackers: 30,
41580
+ trackerKey: 60
41028
41581
  };
41029
41582
  var GM_INSTRUCTION_TRIGGER_PHASES = [
41030
41583
  "post_player_message",
@@ -41212,6 +41765,142 @@ var MapDataV6Schema = MapDataV5Schema.extend({
41212
41765
  var MapDataV8Schema = MapDataV6Schema.extend({
41213
41766
  grid: MapGridSchema.nullable().default(null)
41214
41767
  });
41768
+ var MAP_EXIT_KINDS = [
41769
+ "door",
41770
+ "gate",
41771
+ "stairs",
41772
+ "ladder",
41773
+ "hatch",
41774
+ "path",
41775
+ "road",
41776
+ "dock",
41777
+ "portal",
41778
+ "other"
41779
+ ];
41780
+ var MAP_EXIT_DIRECTIONS = [
41781
+ "up",
41782
+ "down",
41783
+ "in",
41784
+ "out",
41785
+ "through"
41786
+ ];
41787
+ var MapExitSchema = exports_external.object({
41788
+ kind: exports_external.enum(MAP_EXIT_KINDS).default("door"),
41789
+ to: exports_external.string().uuid().nullable().default(null),
41790
+ direction: exports_external.enum(MAP_EXIT_DIRECTIONS).optional(),
41791
+ arriveAt: exports_external.string().uuid().nullable().optional(),
41792
+ main: exports_external.boolean().optional(),
41793
+ locked: exports_external.boolean().optional(),
41794
+ hidden: exports_external.boolean().optional()
41795
+ });
41796
+ var MapAreaV10Schema = MapAreaSchema.extend({
41797
+ exit: MapExitSchema.optional()
41798
+ });
41799
+ var MapPointV10Schema = MapPointSchema.extend({
41800
+ exit: MapExitSchema.optional()
41801
+ });
41802
+ var MapDataV10Schema = MapDataV8Schema.extend({
41803
+ areas: exports_external.array(MapAreaV10Schema),
41804
+ points: exports_external.array(MapPointV10Schema)
41805
+ });
41806
+ var COMPASS_DIRECTIONS = [
41807
+ "north",
41808
+ "northeast",
41809
+ "east",
41810
+ "southeast",
41811
+ "south",
41812
+ "southwest",
41813
+ "west",
41814
+ "northwest"
41815
+ ];
41816
+ var PLACE_WAY_KINDS = [
41817
+ ...MAP_EXIT_KINDS,
41818
+ "bridge",
41819
+ "ferry",
41820
+ "tunnel",
41821
+ "ritual"
41822
+ ];
41823
+ var PLACE_WAY_DIRECTIONS = [
41824
+ ...MAP_EXIT_DIRECTIONS,
41825
+ ...COMPASS_DIRECTIONS
41826
+ ];
41827
+ var relationWords = (max) => exports_external.string().trim().min(1).max(max);
41828
+ var PlaceWaySchema = MapExitSchema.extend({
41829
+ kind: exports_external.enum(PLACE_WAY_KINDS).default("door"),
41830
+ direction: exports_external.enum(PLACE_WAY_DIRECTIONS).optional(),
41831
+ oneWay: exports_external.boolean().optional(),
41832
+ when: relationWords(200).optional(),
41833
+ requires: relationWords(200).optional(),
41834
+ by: exports_external.string().uuid().optional(),
41835
+ label: relationWords(120).optional(),
41836
+ distance: relationWords(60).optional(),
41837
+ time: relationWords(60).optional()
41838
+ });
41839
+ var MapAreaV11Schema = MapAreaSchema.extend({
41840
+ exit: PlaceWaySchema.optional()
41841
+ });
41842
+ var MapPointV11Schema = MapPointSchema.extend({
41843
+ exit: PlaceWaySchema.optional()
41844
+ });
41845
+ var MapDataV11Schema = MapDataV8Schema.extend({
41846
+ areas: exports_external.array(MapAreaV11Schema),
41847
+ points: exports_external.array(MapPointV11Schema)
41848
+ });
41849
+ var RelationIdSchema = exports_external.string().uuid();
41850
+ var RelationTargetSchema = exports_external.string().uuid();
41851
+ var InsideRelationSchema = exports_external.object({
41852
+ id: RelationIdSchema,
41853
+ type: exports_external.literal("inside"),
41854
+ to: RelationTargetSchema,
41855
+ level: exports_external.number().int().min(-100).max(500).optional()
41856
+ });
41857
+ var ConnectsRelationSchema = PlaceWaySchema.extend({
41858
+ id: RelationIdSchema,
41859
+ type: exports_external.literal("connects")
41860
+ });
41861
+ var OtherRelationSchema = exports_external.object({
41862
+ id: RelationIdSchema,
41863
+ type: exports_external.literal("other"),
41864
+ to: RelationTargetSchema,
41865
+ label: relationWords(120)
41866
+ });
41867
+ var PlaceRelationSchema = exports_external.discriminatedUnion("type", [
41868
+ InsideRelationSchema,
41869
+ ConnectsRelationSchema,
41870
+ OtherRelationSchema
41871
+ ]);
41872
+ var MAX_PLACE_RELATIONS = 100;
41873
+ var TRANSPORT_KINDS = [
41874
+ "ship",
41875
+ "boat",
41876
+ "airship",
41877
+ "wagon",
41878
+ "caravan",
41879
+ "carriage",
41880
+ "train",
41881
+ "car",
41882
+ "bus",
41883
+ "plane",
41884
+ "mount",
41885
+ "other"
41886
+ ];
41887
+ var TRANSPORT_MEDIUMS = [
41888
+ "water",
41889
+ "land",
41890
+ "air",
41891
+ "rail",
41892
+ "underground",
41893
+ "space"
41894
+ ];
41895
+ var TransportMetadataSchema = exports_external.object({
41896
+ kind: exports_external.enum(TRANSPORT_KINDS),
41897
+ travels: exports_external.enum(TRANSPORT_MEDIUMS).optional(),
41898
+ speed: exports_external.object({
41899
+ value: exports_external.number().positive(),
41900
+ unit: exports_external.enum(["mph", "km/h", "knots"])
41901
+ }).optional(),
41902
+ capacity: exports_external.number().int().positive().optional()
41903
+ });
41215
41904
  var GmInstructionTriggerPhaseSchema = exports_external.enum(GM_INSTRUCTION_TRIGGER_PHASES);
41216
41905
  var GmInstructionToolEventSchema = exports_external.enum(GM_INSTRUCTION_TOOL_EVENTS);
41217
41906
  var GmInstructionNarrativeMetadataSchema = exports_external.object({
@@ -41270,12 +41959,43 @@ var GameStartBoardPersonSchema = exports_external.object({
41270
41959
  }).nullish(),
41271
41960
  note: exports_external.string().trim().max(GAME_START_BOARD_LIMITS.note).nullish()
41272
41961
  });
41273
- var GameStartBoardSchema = exports_external.object({
41962
+ var GameStartTrackerSchema = exports_external.object({
41963
+ key: exports_external.string().trim().min(1).max(GAME_START_BOARD_LIMITS.trackerKey),
41964
+ on: exports_external.boolean(),
41965
+ fields: exports_external.array(trackerFieldSchema).max(TRACKER_LIMITS.fields).nullish()
41966
+ });
41967
+ function isPlainObject2(value) {
41968
+ return typeof value === "object" && value !== null && !Array.isArray(value);
41969
+ }
41970
+ function boardWithTimeAsTracker(value) {
41971
+ if (!(isPlainObject2(value) && ("time" in value))) {
41972
+ return value;
41973
+ }
41974
+ const { time: time3, ...rest } = value;
41975
+ const words = typeof time3 === "string" ? time3.trim() : "";
41976
+ const trackers = Array.isArray(rest.trackers) ? rest.trackers : [];
41977
+ const set2 = trackers.some((entry) => isPlainObject2(entry) && entry.key === TIME_TRACKER_KEY);
41978
+ if (!words || set2) {
41979
+ return rest;
41980
+ }
41981
+ return {
41982
+ ...rest,
41983
+ trackers: [
41984
+ ...trackers,
41985
+ {
41986
+ key: TIME_TRACKER_KEY,
41987
+ on: true,
41988
+ fields: [{ kind: "text", text: words }]
41989
+ }
41990
+ ]
41991
+ };
41992
+ }
41993
+ var GameStartBoardSchema = exports_external.preprocess(boardWithTimeAsTracker, exports_external.object({
41274
41994
  people: exports_external.array(GameStartBoardPersonSchema).max(GAME_START_BOARD_LIMITS.people).default([]),
41275
- time: exports_external.string().trim().max(GAME_START_BOARD_LIMITS.time).default(""),
41276
41995
  purpose: exports_external.string().trim().max(GAME_START_BOARD_LIMITS.purpose).default(""),
41277
- objectives: exports_external.array(exports_external.string().trim().min(1).max(GAME_START_BOARD_LIMITS.objective)).max(GAME_START_BOARD_LIMITS.objectives).default([])
41278
- });
41996
+ objectives: exports_external.array(exports_external.string().trim().min(1).max(GAME_START_BOARD_LIMITS.objective)).max(GAME_START_BOARD_LIMITS.objectives).default([]),
41997
+ trackers: exports_external.array(GameStartTrackerSchema).max(GAME_START_BOARD_LIMITS.trackers).default([])
41998
+ }));
41279
41999
  function combinedOpeningLength(opening) {
41280
42000
  if (opening.kind !== "static" || !opening.beats) {
41281
42001
  return 0;
@@ -41294,9 +42014,9 @@ var GameStartMetadataObjectSchema = exports_external.object({
41294
42014
  openingInstructions: exports_external.string().max(GAME_START_OPENING_MAX_LENGTH).default(""),
41295
42015
  board: GameStartBoardSchema.default({
41296
42016
  people: [],
41297
- time: "",
41298
42017
  purpose: "",
41299
- objectives: []
42018
+ objectives: [],
42019
+ trackers: []
41300
42020
  }),
41301
42021
  gmNotes: exports_external.string().max(GAME_START_GM_NOTES_MAX_LENGTH).default("")
41302
42022
  });
@@ -41339,6 +42059,17 @@ var GameStartMetadataSchema = GameStartMetadataObjectSchema.superRefine((metadat
41339
42059
  }
41340
42060
  seenBoardReferenceIds.add(referenceId);
41341
42061
  });
42062
+ const seenTrackerKeys = new Set;
42063
+ metadata.board.trackers.forEach((tracker, index) => {
42064
+ if (seenTrackerKeys.has(tracker.key)) {
42065
+ ctx.addIssue({
42066
+ code: "custom",
42067
+ path: ["board", "trackers", index, "key"],
42068
+ message: "Each tracker can only be set once."
42069
+ });
42070
+ }
42071
+ seenTrackerKeys.add(tracker.key);
42072
+ });
41342
42073
  });
41343
42074
  var FileDesignationMetadataV1Schema = exports_external.object({
41344
42075
  schemaVersionId: exports_external.literal(FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V1).default(FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V1),
@@ -41403,24 +42134,36 @@ var FileDesignationMetadataV9Schema = FileDesignationMetadataV8Schema.extend({
41403
42134
  schemaVersionId: exports_external.literal(FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V9).default(FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V9),
41404
42135
  script: ScriptMetadataSchema.optional()
41405
42136
  });
41406
- var MapSchema = FileDesignationMetadataV9Schema.shape.map.unwrap();
41407
- var LocationDesignationMetadataSchema = FileDesignationMetadataV9Schema.pick({
42137
+ var FileDesignationMetadataV10Schema = FileDesignationMetadataV9Schema.extend({
42138
+ schemaVersionId: exports_external.literal(FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V10).default(FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V10),
42139
+ map: MapDataV10Schema.optional(),
42140
+ transport: TransportMetadataSchema.optional()
42141
+ });
42142
+ var FileDesignationMetadataV11Schema = FileDesignationMetadataV10Schema.extend({
42143
+ schemaVersionId: exports_external.literal(FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V11).default(FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V11),
42144
+ map: MapDataV11Schema.optional(),
42145
+ relations: exports_external.array(PlaceRelationSchema).max(MAX_PLACE_RELATIONS).optional()
42146
+ });
42147
+ var MapSchema = FileDesignationMetadataV11Schema.shape.map.unwrap();
42148
+ var LocationDesignationMetadataSchema = FileDesignationMetadataV11Schema.pick({
41408
42149
  schemaVersionId: true,
41409
- map: true
42150
+ map: true,
42151
+ transport: true,
42152
+ relations: true
41410
42153
  });
41411
- var CharacterDesignationMetadataSchema = FileDesignationMetadataV9Schema.pick({
42154
+ var CharacterDesignationMetadataSchema = FileDesignationMetadataV11Schema.pick({
41412
42155
  schemaVersionId: true,
41413
42156
  playable: true
41414
42157
  });
41415
- var GmInstructionDesignationMetadataSchema = FileDesignationMetadataV9Schema.pick({
42158
+ var GmInstructionDesignationMetadataSchema = FileDesignationMetadataV11Schema.pick({
41416
42159
  schemaVersionId: true,
41417
42160
  gmInstruction: true
41418
42161
  });
41419
- var GameStartDesignationMetadataSchema = FileDesignationMetadataV9Schema.pick({
42162
+ var GameStartDesignationMetadataSchema = FileDesignationMetadataV11Schema.pick({
41420
42163
  schemaVersionId: true,
41421
42164
  gameStart: true
41422
42165
  });
41423
- var ScriptDesignationMetadataSchema = FileDesignationMetadataV9Schema.pick({
42166
+ var ScriptDesignationMetadataSchema = FileDesignationMetadataV11Schema.pick({
41424
42167
  schemaVersionId: true,
41425
42168
  script: true
41426
42169
  });
@@ -41428,10 +42171,17 @@ function upgradeFileDesignationMetadata(input) {
41428
42171
  if (input === null || input === undefined) {
41429
42172
  return null;
41430
42173
  }
41431
- if (readSchemaVersionId(input) === FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V9) {
41432
- return FileDesignationMetadataV9Schema.parse(input);
42174
+ const schemaVersionId = readSchemaVersionId(input);
42175
+ if (schemaVersionId === FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V11) {
42176
+ return FileDesignationMetadataV11Schema.parse(input);
42177
+ }
42178
+ if (schemaVersionId === FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V10) {
42179
+ return upgradeV10MetadataToV11(FileDesignationMetadataV10Schema.parse(input));
42180
+ }
42181
+ if (schemaVersionId === FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V9) {
42182
+ return upgradeV10MetadataToV11(upgradeV9MetadataToV10(FileDesignationMetadataV9Schema.parse(input)));
41433
42183
  }
41434
- return upgradeV8MetadataToV9(upgradeMetadataToV8(input));
42184
+ return upgradeV10MetadataToV11(upgradeV9MetadataToV10(upgradeV8MetadataToV9(upgradeMetadataToV8(input))));
41435
42185
  }
41436
42186
  function upgradeMetadataToV8(input) {
41437
42187
  const schemaVersionId = readSchemaVersionId(input);
@@ -41518,6 +42268,18 @@ function upgradeV8MetadataToV9(metadata) {
41518
42268
  schemaVersionId: FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V9
41519
42269
  });
41520
42270
  }
42271
+ function upgradeV9MetadataToV10(metadata) {
42272
+ return FileDesignationMetadataV10Schema.parse({
42273
+ ...metadata,
42274
+ schemaVersionId: FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V10
42275
+ });
42276
+ }
42277
+ function upgradeV10MetadataToV11(metadata) {
42278
+ return FileDesignationMetadataV11Schema.parse({
42279
+ ...metadata,
42280
+ schemaVersionId: FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V11
42281
+ });
42282
+ }
41521
42283
  function normalizeLegacyDefaultGrid(map2) {
41522
42284
  if (map2.grid?.mode !== "default") {
41523
42285
  return map2;
@@ -41812,39 +42574,95 @@ interface BoardPerson {
41812
42574
  readonly details: { readonly [detail: string]: string | number | boolean };
41813
42575
  }
41814
42576
 
41815
- /** One row of a tracker: a count, a list, a turn order or a short text. */
41816
- type BoardTrackerRow =
42577
+ /** A file or person a tracker points at, by name; its file is filled in by the game. */
42578
+ type BoardTrackerRef = { name: string; file?: string | null };
42579
+
42580
+ /** A number: a count, a clock (pressure filling toward trouble) or a scale (a tug of war between two ends). */
42581
+ type BoardTrackerNumber = {
42582
+ kind: "count" | "clock" | "scale";
42583
+ label?: string | null;
42584
+ /** The GM's alone: players never see this field. */
42585
+ hidden?: boolean | null;
42586
+ value: number;
42587
+ min?: number | null;
42588
+ max?: number | null;
42589
+ tone?: "good" | "bad" | null;
42590
+ /** A scale's two ends, low first. */
42591
+ ends?: [string, string] | null;
42592
+ /** What happens when the number reaches a value: { "6": "The guards arrive" }. */
42593
+ at?: { [value: string]: string } | null;
42594
+ };
42595
+
42596
+ type BoardTrackerList = {
42597
+ kind: "list";
42598
+ label?: string | null;
42599
+ hidden?: boolean | null;
42600
+ items: {
42601
+ text: string;
42602
+ state: "open" | "done" | "failed";
42603
+ note?: string | null;
42604
+ /** Who or what the item is about. */
42605
+ ref?: BoardTrackerRef | null;
42606
+ hidden?: boolean | null;
42607
+ }[];
42608
+ };
42609
+
42610
+ type BoardTrackerText = { kind: "text"; label?: string | null; hidden?: boolean | null; text: string };
42611
+
42612
+ /**
42613
+ * One field of a tracker: a number, a list, a turn order, a short text, a
42614
+ * file or person it points at, records (one per person or file, each with
42615
+ * fields of its own) or links (one per pair of them, with fields between
42616
+ * the two). show names live values drawn beside each person a field names:
42617
+ * a stat the board shows ("HP") or "where".
42618
+ */
42619
+ type BoardTrackerField =
42620
+ | BoardTrackerNumber
42621
+ | BoardTrackerList
41817
42622
  | {
41818
- kind: "count";
42623
+ kind: "order";
41819
42624
  label?: string | null;
41820
- value: number;
41821
- min?: number | null;
41822
- max?: number | null;
41823
- style?: "number" | "bar" | "pips" | "boxes" | "clock" | "scale" | null;
41824
- tone?: "good" | "bad" | null;
41825
- /** A scale's two ends, low first. */
41826
- ends?: [string, string] | null;
41827
- /** What happens when the count reaches a value: { "6": "The guards arrive" }. */
41828
- at?: { [value: string]: string } | null;
42625
+ hidden?: boolean | null;
42626
+ entries: { name: string; score?: number | null }[];
42627
+ current?: string | null;
42628
+ round?: number | null;
42629
+ show?: string[] | null;
41829
42630
  }
42631
+ | BoardTrackerText
41830
42632
  | {
41831
- kind: "list";
42633
+ kind: "ref";
41832
42634
  label?: string | null;
41833
- items: { text: string; state: "open" | "done" | "failed"; note?: string | null }[];
42635
+ hidden?: boolean | null;
42636
+ ref: BoardTrackerRef;
42637
+ show?: string[] | null;
41834
42638
  }
41835
42639
  | {
41836
- kind: "order";
42640
+ kind: "records";
41837
42641
  label?: string | null;
41838
- order: { name: string; score?: number | null }[];
41839
- current?: string | null;
41840
- round?: number | null;
42642
+ hidden?: boolean | null;
42643
+ entries: {
42644
+ ref: BoardTrackerRef;
42645
+ hidden?: boolean | null;
42646
+ fields: (BoardTrackerNumber | BoardTrackerList | BoardTrackerText)[];
42647
+ }[];
42648
+ show?: string[] | null;
41841
42649
  }
41842
- | { kind: "text"; label?: string | null; text: string };
42650
+ | {
42651
+ kind: "links";
42652
+ label?: string | null;
42653
+ hidden?: boolean | null;
42654
+ entries: {
42655
+ /** The two it links ("Kael" and "Mira"); a pair is the same either way round. */
42656
+ between: [BoardTrackerRef, BoardTrackerRef];
42657
+ hidden?: boolean | null;
42658
+ fields: (BoardTrackerNumber | BoardTrackerList | BoardTrackerText)[];
42659
+ }[];
42660
+ };
41843
42661
 
41844
- /** A tracker on the board: a turn order, a clock, a quest's steps, a count. */
42662
+ /** A tracker on the board: a turn order, a clock, the objectives, suspects, a count. */
41845
42663
  interface BoardTracker {
41846
42664
  name: string;
41847
- rows: BoardTrackerRow[];
42665
+ fields: BoardTrackerField[];
41848
42666
  /** Someone on the board it is about. */
41849
42667
  for?: string | null;
41850
42668
  /** The GM's alone: players never see it. */
@@ -42798,7 +43616,7 @@ function parseMarkdown(source) {
42798
43616
  } catch {
42799
43617
  return { content: source };
42800
43618
  }
42801
- if (!isPlainObject2(parsed)) {
43619
+ if (!isPlainObject3(parsed)) {
42802
43620
  return { content: body };
42803
43621
  }
42804
43622
  const file2 = { content: body };
@@ -42846,7 +43664,7 @@ function parseFrontmatterObject(source) {
42846
43664
  } catch {
42847
43665
  return null;
42848
43666
  }
42849
- return isPlainObject2(parsed) ? parsed : null;
43667
+ return isPlainObject3(parsed) ? parsed : null;
42850
43668
  }
42851
43669
  function stripLeadingNewline(s) {
42852
43670
  if (s.startsWith(`\r
@@ -42859,7 +43677,7 @@ function stripLeadingNewline(s) {
42859
43677
  }
42860
43678
  return s;
42861
43679
  }
42862
- function isPlainObject2(value) {
43680
+ function isPlainObject3(value) {
42863
43681
  return typeof value === "object" && value !== null && !Array.isArray(value);
42864
43682
  }
42865
43683
  function coerceString(value) {
@@ -42875,19 +43693,6 @@ function coerceString(value) {
42875
43693
  // src/engine/vfs-name.ts
42876
43694
  import { isAbsolute, relative, resolve } from "node:path";
42877
43695
 
42878
- // ../shared/src/vfs/path.ts
42879
- var UNSAFE_DISK_SEGMENT_CHARS = /[\\/:*?"<>|\u0000-\u001f\u007f]/g;
42880
- function sanitizeVfsSegmentForDisk(segment) {
42881
- if (segment === "." || segment === ".." || segment.trim().length === 0) {
42882
- return "_";
42883
- }
42884
- const cleaned = segment.replace(UNSAFE_DISK_SEGMENT_CHARS, "_").trim();
42885
- return cleaned.length > 0 ? cleaned : "_";
42886
- }
42887
- function sanitizeVfsPathForDisk(path) {
42888
- return path.split("/").map((segment) => segment === "" ? "" : sanitizeVfsSegmentForDisk(segment)).join("/");
42889
- }
42890
-
42891
43696
  // ../shared/src/cli/vfs-name.ts
42892
43697
  function parseVfsFileName(fileName) {
42893
43698
  if (fileName.includes("/")) {
@@ -42982,10 +43787,10 @@ function deletedSidecarPath(workingPath) {
42982
43787
  }
42983
43788
 
42984
43789
  // src/engine/vfs-name.ts
42985
- var LEADING_SLASHES = /^\/+/;
43790
+ var LEADING_SLASHES2 = /^\/+/;
42986
43791
  function joinUnderWorkspaceRoot(root, vfsPath) {
42987
43792
  const rootResolved = resolve(root);
42988
- const relativePath = vfsPath.replace(LEADING_SLASHES, "");
43793
+ const relativePath = vfsPath.replace(LEADING_SLASHES2, "");
42989
43794
  if (relativePath.length === 0 || relativePath.split("/").some((segment) => segment === "." || segment === "..")) {
42990
43795
  throw new Error(`path escapes workspace root: ${vfsPath}`);
42991
43796
  }
@@ -43680,9 +44485,9 @@ async function loadBaseRecordsForMissing({
43680
44485
  return records;
43681
44486
  }
43682
44487
  var LEADING_DOT_SLASH = /^\.\//;
43683
- var TRAILING_SLASHES = /\/+$/;
44488
+ var TRAILING_SLASHES2 = /\/+$/;
43684
44489
  function normalizeVfsPath(input) {
43685
- const cleaned = input.replace(LEADING_DOT_SLASH, "").replace(TRAILING_SLASHES, "");
44490
+ const cleaned = input.replace(LEADING_DOT_SLASH, "").replace(TRAILING_SLASHES2, "");
43686
44491
  return cleaned.startsWith("/") ? cleaned : `/${cleaned}`;
43687
44492
  }
43688
44493
  function toIsoTimestamp(value) {
@@ -44151,6 +44956,17 @@ function checkGameOptions(workspace) {
44151
44956
  detail: "shape errors are reported under project-settings"
44152
44957
  };
44153
44958
  }
44959
+ const unknown2 = unknownGameOptionsKeysProblem({
44960
+ raw: value,
44961
+ where: "settings.gameOptions"
44962
+ });
44963
+ if (unknown2) {
44964
+ return {
44965
+ ...base,
44966
+ status: "warn",
44967
+ detail: `${unknown2} Push drops them, so they do nothing (format: .craft/instructions/upgrading-to-gm-harness-2.md)`
44968
+ };
44969
+ }
44154
44970
  const parsed = gameOptionsSchema.safeParse(value);
44155
44971
  if (!parsed.success) {
44156
44972
  const issue3 = parsed.error.issues[0];
@@ -44929,7 +45745,7 @@ var credentialsFileSchema = exports_external.object({
44929
45745
  version: exports_external.literal(1),
44930
45746
  remotes: exports_external.record(exports_external.string(), storedCredentialsSchema)
44931
45747
  }).strict();
44932
- var TRAILING_SLASHES2 = /\/+$/;
45748
+ var TRAILING_SLASHES3 = /\/+$/;
44933
45749
  function credentialsPath() {
44934
45750
  const dir = process.env.CRAFT_CONFIG_DIR ?? join4(homedir(), ".config", "craft");
44935
45751
  return join4(dir, "credentials.json");
@@ -44938,7 +45754,7 @@ function normalizeRemoteKey(remote) {
44938
45754
  try {
44939
45755
  return new URL(remote).origin;
44940
45756
  } catch {
44941
- return remote.replace(TRAILING_SLASHES2, "");
45757
+ return remote.replace(TRAILING_SLASHES3, "");
44942
45758
  }
44943
45759
  }
44944
45760
  async function readCredentialsFile() {
@@ -44993,7 +45809,7 @@ async function deleteStoredCredentials(remote) {
44993
45809
 
44994
45810
  // src/auth/oauth.ts
44995
45811
  import { createHash as createHash2, randomBytes as randomBytes3 } from "node:crypto";
44996
- var TRAILING_SLASHES3 = /\/+$/;
45812
+ var TRAILING_SLASHES4 = /\/+$/;
44997
45813
  async function fetchJson(fetchImpl, url2, init) {
44998
45814
  let response;
44999
45815
  try {
@@ -45011,7 +45827,7 @@ function isRecord15(value) {
45011
45827
  return typeof value === "object" && value !== null && !Array.isArray(value);
45012
45828
  }
45013
45829
  function authServerMetadataUrls(issuer) {
45014
- const base = issuer.replace(TRAILING_SLASHES3, "");
45830
+ const base = issuer.replace(TRAILING_SLASHES4, "");
45015
45831
  const url2 = new URL(base);
45016
45832
  const suffixForm = `${base}/.well-known/oauth-authorization-server`;
45017
45833
  const insertionForm = `${url2.origin}/.well-known/oauth-authorization-server${url2.pathname === "/" ? "" : url2.pathname}`;
@@ -45021,7 +45837,7 @@ async function discoverAuthorization({
45021
45837
  remote,
45022
45838
  fetchImpl = fetch
45023
45839
  }) {
45024
- const origin = remote.replace(TRAILING_SLASHES3, "");
45840
+ const origin = remote.replace(TRAILING_SLASHES4, "");
45025
45841
  const prmUrl = `${origin}/.well-known/oauth-protected-resource/mcp`;
45026
45842
  const prm = await fetchJson(fetchImpl, prmUrl);
45027
45843
  if (!(prm.ok && isRecord15(prm.json))) {
@@ -45379,7 +46195,7 @@ async function refreshWorkspaceScriptTypes({
45379
46195
  // package.json
45380
46196
  var package_default = {
45381
46197
  name: "@craftrpgs/cli",
45382
- version: "0.2.0-alpha.0",
46198
+ version: "0.2.0-alpha.2",
45383
46199
  description: "Sync Craft projects with a local folder — clone, edit with any coding agent, push back.",
45384
46200
  license: "UNLICENSED",
45385
46201
  homepage: "https://craftrpgs.com",
@@ -45400,14 +46216,14 @@ var package_default = {
45400
46216
  scripts: {
45401
46217
  build: "bun run scripts/build.ts",
45402
46218
  prepack: "bun run build",
45403
- typecheck: "tsc --noEmit"
46219
+ typecheck: "bun check"
45404
46220
  },
45405
46221
  devDependencies: {
45406
46222
  "@craft/flow": "workspace:*",
45407
46223
  "@craft/shared": "workspace:*",
45408
46224
  fflate: "^0.8.3",
45409
46225
  typescript: "7.0.2",
45410
- uuid: "11.1.0",
46226
+ uuid: "11.1.1",
45411
46227
  zod: "4.3.6"
45412
46228
  }
45413
46229
  };
@@ -45416,7 +46232,7 @@ var package_default = {
45416
46232
  var CLI_VERSION = package_default.version;
45417
46233
 
45418
46234
  // src/http.ts
45419
- var TRAILING_SLASHES4 = /\/+$/;
46235
+ var TRAILING_SLASHES5 = /\/+$/;
45420
46236
  var MAX_CONTRACT_ISSUES_SHOWN = 3;
45421
46237
  function formatContractIssues(issues) {
45422
46238
  if (!issues || issues.length === 0) {
@@ -45439,6 +46255,17 @@ var cliMetaWriteResponseSchema = exports_external.object({
45439
46255
  version: exports_external.object({ id: exports_external.string().min(1) }),
45440
46256
  linkStateChanged: exports_external.boolean().optional()
45441
46257
  });
46258
+ var cliPlacePairWriteResponseSchema = exports_external.object({
46259
+ changedFileIds: exports_external.array(exports_external.string().min(1))
46260
+ });
46261
+ var cliLinkedLocationsResponseSchema = exports_external.object({
46262
+ parent: exports_external.object({ referenceId: exports_external.string() }).nullable(),
46263
+ places: exports_external.array(exports_external.object({
46264
+ referenceId: exports_external.string(),
46265
+ name: exports_external.string(),
46266
+ pair: exports_external.record(exports_external.string(), exports_external.unknown())
46267
+ }))
46268
+ });
45442
46269
  var cliFileMetaResponseSchema = exports_external.object({
45443
46270
  currentVersion: exports_external.object({
45444
46271
  id: exports_external.string().min(1),
@@ -45541,8 +46368,8 @@ function createCliClient({
45541
46368
  auth,
45542
46369
  fetchImpl = fetch
45543
46370
  }) {
45544
- const base = remote.replace(TRAILING_SLASHES4, "");
45545
- const appBase = (appRemote ?? remote).replace(TRAILING_SLASHES4, "");
46371
+ const base = remote.replace(TRAILING_SLASHES5, "");
46372
+ const appBase = (appRemote ?? remote).replace(TRAILING_SLASHES5, "");
45546
46373
  async function requestWithToken(url2, token, init) {
45547
46374
  try {
45548
46375
  return await fetchImpl(url2, {
@@ -45829,6 +46656,40 @@ function createCliClient({
45829
46656
  async putGmInstruction({ fileId, gmInstruction }) {
45830
46657
  return await putMetadata(`/api/files/${fileId}/gm-instruction`, { gmInstruction }, "GM instruction trigger save");
45831
46658
  },
46659
+ async fetchLinkedLocations({ fileId }) {
46660
+ const response = await request(`/api/files/${fileId}/relations`);
46661
+ if (response.status === 404) {
46662
+ return null;
46663
+ }
46664
+ if (!response.ok) {
46665
+ const { code, message, details } = await readErrorBody(response);
46666
+ throw new CliApiError({
46667
+ message,
46668
+ code,
46669
+ status: response.status,
46670
+ exitCode: 1,
46671
+ details
46672
+ });
46673
+ }
46674
+ return await parseJson(response, cliLinkedLocationsResponseSchema, "linked locations");
46675
+ },
46676
+ async putFileTransport({ fileId, transport }) {
46677
+ return await putMetadata(`/api/files/${fileId}/transport`, { transport }, "transport save");
46678
+ },
46679
+ async putPlacePair({ fileId, otherFileId, pair }) {
46680
+ const response = await request(`/api/files/${fileId}/relations/${otherFileId}`, pair === null ? { method: "DELETE" } : { method: "PUT", body: JSON.stringify(pair) });
46681
+ if (!response.ok) {
46682
+ const { code, message, details } = await readErrorBody(response);
46683
+ throw new CliApiError({
46684
+ message,
46685
+ code,
46686
+ status: response.status,
46687
+ exitCode: response.status === 400 || response.status === 422 ? 2 : 1,
46688
+ details
46689
+ });
46690
+ }
46691
+ return await parseJson(response, cliPlacePairWriteResponseSchema, "linked location save");
46692
+ },
45832
46693
  async putDesignationFlag({ fileId, flag, value }) {
45833
46694
  return await putMetadata(`/api/files/${fileId}/designation`, { flag, value }, "designation flag save");
45834
46695
  },
@@ -48549,11 +49410,66 @@ async function runLogout(argv) {
48549
49410
 
48550
49411
  // src/commands/map.ts
48551
49412
  import { readFile as readFile11 } from "node:fs/promises";
49413
+
49414
+ // src/commands/place-metadata.ts
49415
+ function pathOrId(ledger, referenceId) {
49416
+ return referenceIdToPath({ ledger, referenceId }) ?? referenceId;
49417
+ }
49418
+ function exitForOutput(ledger, exit) {
49419
+ return {
49420
+ ...exit,
49421
+ ...typeof exit.to === "string" ? { to: pathOrId(ledger, exit.to) } : {},
49422
+ ...typeof exit.by === "string" ? { by: pathOrId(ledger, exit.by) } : {}
49423
+ };
49424
+ }
49425
+ function relationsForOutput(ledger, relations) {
49426
+ return relations.map((relation) => exitForOutput(ledger, relation));
49427
+ }
49428
+ function listMapExitsForOutput(ledger, map2) {
49429
+ return [...map2.areas, ...map2.points].flatMap((element) => element.exit ? [
49430
+ {
49431
+ element: element.label ?? element.id,
49432
+ ...exitForOutput(ledger, element.exit)
49433
+ }
49434
+ ] : []);
49435
+ }
49436
+ function resolveExitInput({
49437
+ ledger,
49438
+ exit,
49439
+ where,
49440
+ failures
49441
+ }) {
49442
+ if (typeof exit !== "object" || exit === null || Array.isArray(exit)) {
49443
+ return exit;
49444
+ }
49445
+ const record2 = { ...exit };
49446
+ for (const key of ["to", "by"]) {
49447
+ const value = record2[key];
49448
+ if (typeof value !== "string") {
49449
+ continue;
49450
+ }
49451
+ const resolution = resolveLink({ ledger, link: value });
49452
+ if (resolution.status === "resolved") {
49453
+ record2[key] = resolution.referenceId;
49454
+ } else {
49455
+ failures.push({
49456
+ where: `${where}.${key}`,
49457
+ link: value,
49458
+ reason: resolution.status
49459
+ });
49460
+ }
49461
+ }
49462
+ return record2;
49463
+ }
49464
+
49465
+ // src/commands/map.ts
48552
49466
  var MAP_USAGE = `Usage: craft map get <location-path> [--json]
48553
49467
  ` + ` craft map set <location-path> --file <map.json>
48554
49468
  ` + `
48555
- ` + "The map JSON is the stored MapData shape; in `set` input, areas/points may\n" + 'use `"link": "<workspace path | slug.type | referenceId>"` instead of\n' + `fileReferenceId, and element ids are minted when omitted. Read
48556
- ` + ".craft/instructions/authoring-maps.md for the format and quality bar.";
49469
+ ` + "The map JSON is the stored MapData shape; in `set` input, areas/points may\n" + 'use `"link": "<workspace path | slug.type | referenceId>"` instead of\n' + `fileReferenceId, and element ids are minted when omitted. An element's
49470
+ ` + '`exit` (a door, stairs, a road to another location) takes `"to"` as a\n' + "workspace path too; no `to` is the way out to the parent map. `get` prints\n" + `links and exit targets as workspace paths. Read
49471
+ ` + `.craft/instructions/authoring-maps.md for the format and
49472
+ ` + ".craft/instructions/linking-locations.md for exits.";
48557
49473
  async function runMap(argv) {
48558
49474
  const [subcommand, ...rest] = argv;
48559
49475
  if (subcommand === "get") {
@@ -48575,16 +49491,22 @@ function hydrateMapForOutput(workspace, map2) {
48575
49491
  if (typeof element !== "object" || element === null) {
48576
49492
  return element;
48577
49493
  }
48578
- const record2 = element;
49494
+ let record2 = element;
49495
+ if (typeof record2.exit === "object" && record2.exit !== null) {
49496
+ record2 = {
49497
+ ...record2,
49498
+ exit: exitForOutput(workspace.ledger, record2.exit)
49499
+ };
49500
+ }
48579
49501
  if (typeof record2.fileReferenceId !== "string") {
48580
- return element;
49502
+ return record2;
48581
49503
  }
48582
49504
  const path = referenceIdToPath({
48583
49505
  ledger: workspace.ledger,
48584
49506
  referenceId: record2.fileReferenceId
48585
49507
  });
48586
49508
  if (!path) {
48587
- return element;
49509
+ return record2;
48588
49510
  }
48589
49511
  const { fileReferenceId: _dropped, ...rest } = record2;
48590
49512
  return { ...rest, link: path };
@@ -48677,6 +49599,14 @@ function normalizeMapInput(workspace, raw) {
48677
49599
  if (record2.fileReferenceId === undefined) {
48678
49600
  record2.fileReferenceId = null;
48679
49601
  }
49602
+ if (record2.exit !== undefined) {
49603
+ record2.exit = resolveExitInput({
49604
+ ledger: workspace.ledger,
49605
+ exit: record2.exit,
49606
+ where: `${kindLabel2}[${index}].exit`,
49607
+ failures
49608
+ });
49609
+ }
48680
49610
  for (const [key, value] of Object.entries(defaults)) {
48681
49611
  if (record2[key] === undefined) {
48682
49612
  record2[key] = value;
@@ -48945,13 +49875,75 @@ function translateGameStartAuthoringInput({
48945
49875
  return input;
48946
49876
  }
48947
49877
 
49878
+ // ../shared/src/place-pairs.ts
49879
+ var PLACE_PAIR_KINDS = [
49880
+ "same_place",
49881
+ "contains",
49882
+ "part_of",
49883
+ "separate",
49884
+ "other"
49885
+ ];
49886
+ var PairWaySchema = PlaceWaySchema.omit({
49887
+ to: true,
49888
+ arriveAt: true,
49889
+ main: true,
49890
+ oneWay: true
49891
+ }).extend({
49892
+ oneWay: exports_external.enum(["there", "back"]).optional()
49893
+ });
49894
+ var PairWordsSchema = exports_external.object({
49895
+ text: exports_external.string().trim().min(1).max(120),
49896
+ first: exports_external.enum(["a", "b"])
49897
+ });
49898
+ var PlacePairSchema = exports_external.object({
49899
+ kind: exports_external.enum(PLACE_PAIR_KINDS),
49900
+ parentRef: exports_external.string().uuid().optional(),
49901
+ way: PairWaySchema.nullable(),
49902
+ words: PairWordsSchema.nullable()
49903
+ });
49904
+ function placePairProblem({
49905
+ pair,
49906
+ aRef,
49907
+ bRef
49908
+ }) {
49909
+ if (aRef === bRef) {
49910
+ return "A location can't be linked to itself.";
49911
+ }
49912
+ if (pair.kind === "same_place") {
49913
+ if (!pair.parentRef) {
49914
+ return "Pick the location they're both in.";
49915
+ }
49916
+ if (pair.parentRef === aRef || pair.parentRef === bRef) {
49917
+ return "A location can't be inside itself.";
49918
+ }
49919
+ } else if (pair.parentRef) {
49920
+ return "Only locations in one location name the location they're in.";
49921
+ }
49922
+ if (pair.kind === "separate" && !pair.way) {
49923
+ return "Separate locations need a way between them.";
49924
+ }
49925
+ if (pair.kind === "other" && !pair.words) {
49926
+ return "Say how they relate.";
49927
+ }
49928
+ if (pair.kind !== "other" && pair.words) {
49929
+ return "Only something else has words of its own.";
49930
+ }
49931
+ return null;
49932
+ }
49933
+
48948
49934
  // src/commands/meta.ts
49935
+ var PAIR_KIND_FIELD_RE = /^kind/;
48949
49936
  var META_USAGE = `Usage: craft meta get <path> [--json]
48950
49937
  ` + ` craft meta set-game-start <game-start-path> --file <json>
48951
49938
  ` + ` craft meta set-gm-trigger <gm-instruction-path> (--file <json> | --clear)
48952
49939
  ` + ` craft meta set-playable <character-path> <true|false>
49940
+ ` + ` craft meta set-transport <location-path> (--file <json> | --clear)
49941
+ ` + ` craft meta link <location-path> <other-location-path> --file <json>
49942
+ ` + ` craft meta unlink <location-path> <other-location-path>
48953
49943
  ` + `
48954
- ` + "See .craft/instructions/making-it-playable.md for the JSON shapes.";
49944
+ ` + `See .craft/instructions/making-it-playable.md for game starts, triggers and
49945
+ ` + `playable, and .craft/instructions/linking-locations.md for transports and
49946
+ ` + "linked locations. `get` on a location shows its transport, its linked\n" + "locations (read from both ends) and its map's exits, with workspace paths.";
48955
49947
  async function runMeta(argv) {
48956
49948
  const [subcommand, ...rest] = argv;
48957
49949
  if (subcommand === "get") {
@@ -48966,6 +49958,15 @@ async function runMeta(argv) {
48966
49958
  if (subcommand === "set-playable") {
48967
49959
  return await runSetPlayable(rest);
48968
49960
  }
49961
+ if (subcommand === "set-transport") {
49962
+ return await runSetTransport(rest);
49963
+ }
49964
+ if (subcommand === "link") {
49965
+ return await runLink(rest, "link");
49966
+ }
49967
+ if (subcommand === "unlink") {
49968
+ return await runLink(rest, "unlink");
49969
+ }
48969
49970
  throw new CliError(META_USAGE, 2);
48970
49971
  }
48971
49972
  async function readJsonFile2(inputPath) {
@@ -49028,6 +50029,8 @@ async function runMetaGet(argv) {
49028
50029
  if (gameStart) {
49029
50030
  gameStart.startingLocationFileReferenceId = undefined;
49030
50031
  }
50032
+ const isLocation = meta3.fileType?.designation === "location" || Boolean(parsed?.map || parsed?.transport || parsed?.relations);
50033
+ const linkedLocations = isLocation ? await linkedLocationsForOutput(workspace, client, entry.fileId) : undefined;
49031
50034
  printJson({
49032
50035
  path,
49033
50036
  fileType: entry.fileType,
@@ -49039,11 +50042,38 @@ async function runMetaGet(argv) {
49039
50042
  areas: parsed.map.areas.length,
49040
50043
  points: parsed.map.points.length,
49041
50044
  useParentMap: parsed.map.useParentMap ?? false,
50045
+ exits: listMapExitsForOutput(workspace.ledger, parsed.map),
49042
50046
  hint: "Full map via `craft map get`."
49043
- } : null
50047
+ } : null,
50048
+ ...isLocation ? {
50049
+ transport: parsed?.transport ?? null,
50050
+ relations: relationsForOutput(workspace.ledger, parsed?.relations ?? []),
50051
+ ...linkedLocations === undefined ? {} : { linkedLocations }
50052
+ } : {}
49044
50053
  });
49045
50054
  return 0;
49046
50055
  }
50056
+ async function linkedLocationsForOutput(workspace, client, fileId) {
50057
+ const linked = await client.fetchLinkedLocations({ fileId });
50058
+ if (!linked) {
50059
+ return;
50060
+ }
50061
+ return linked.places.map((place) => {
50062
+ const pair = place.pair;
50063
+ return {
50064
+ location: pathForReference(workspace, place.referenceId) ?? place.name,
50065
+ relation: pair.kind,
50066
+ ...pair.parentRef ? { in: pathForReference(workspace, pair.parentRef) } : {},
50067
+ ...pair.way ? {
50068
+ way: exitForOutput(workspace.ledger, pair.way)
50069
+ } : {},
50070
+ ...pair.words ? {
50071
+ words: pair.words.text,
50072
+ wordsFirst: pair.words.first === "a" ? "location" : "other"
50073
+ } : {}
50074
+ };
50075
+ });
50076
+ }
49047
50077
  function projectOpeningForOutput(workspace, opening) {
49048
50078
  if (opening.kind !== "static" || !opening.beats?.length) {
49049
50079
  return opening;
@@ -49220,6 +50250,159 @@ async function runSetPlayable(argv) {
49220
50250
  printLines([`Set playable: ${value} on ${path}.`]);
49221
50251
  return 0;
49222
50252
  }
50253
+ async function runSetTransport(argv) {
50254
+ const args = parseCommandArgs({
50255
+ argv,
50256
+ flags: {
50257
+ file: { type: "string" },
50258
+ clear: { type: "boolean" },
50259
+ json: { type: "boolean" }
50260
+ },
50261
+ maxPositionals: 1
50262
+ });
50263
+ const path = args.positionals[0];
50264
+ const inputPath = stringFlag(args, "file");
50265
+ const clear = args.booleans.has("clear");
50266
+ const exactlyOneMode = clear ? !inputPath : Boolean(inputPath);
50267
+ if (!(path && exactlyOneMode)) {
50268
+ throw new CliError(META_USAGE, 2);
50269
+ }
50270
+ let transport = null;
50271
+ if (inputPath) {
50272
+ const parsed = LocationDesignationMetadataSchema.shape.transport.unwrap().safeParse(await readJsonFile2(inputPath));
50273
+ if (!parsed.success) {
50274
+ throw new CliError(`The transport JSON failed validation:
50275
+ ` + parsed.error.issues.map((issue3) => ` ${issue3.path.join(".") || "(root)"}: ${issue3.message}`).join(`
50276
+ `), 2);
50277
+ }
50278
+ transport = parsed.data;
50279
+ }
50280
+ const workspace = await loadWorkspaceContext();
50281
+ const entry = resolveTrackedEntry({ ledger: workspace.ledger, input: path });
50282
+ const client = await workspaceClient(workspace);
50283
+ const result = await client.putFileTransport({
50284
+ fileId: entry.fileId,
50285
+ transport
50286
+ });
50287
+ await freshenLedgerAfterMetaWrite({
50288
+ root: workspace.root,
50289
+ ledger: workspace.ledger,
50290
+ fileId: entry.fileId,
50291
+ versionId: result.version.id
50292
+ });
50293
+ if (args.booleans.has("json")) {
50294
+ printJson({ path, saved: true, transport });
50295
+ return 0;
50296
+ }
50297
+ printLines([
50298
+ transport ? `Saved transport on ${path}: ${transport.kind}${transport.travels ? ` (${transport.travels})` : ""}.` : `Cleared transport on ${path}.`
50299
+ ]);
50300
+ return 0;
50301
+ }
50302
+ function translatePlacePairInput({
50303
+ workspace,
50304
+ raw,
50305
+ aRef,
50306
+ bRef
50307
+ }) {
50308
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
50309
+ throw new CliError("The link JSON must be a single object.", 2);
50310
+ }
50311
+ const input = raw;
50312
+ const failures = [];
50313
+ let parentRef;
50314
+ if (typeof input.in === "string") {
50315
+ const resolution = resolveLink({
50316
+ ledger: workspace.ledger,
50317
+ link: input.in
50318
+ });
50319
+ if (resolution.status === "resolved") {
50320
+ parentRef = resolution.referenceId;
50321
+ } else {
50322
+ failures.push({ where: "in", link: input.in, reason: resolution.status });
50323
+ }
50324
+ }
50325
+ const way = input.way === undefined || input.way === null ? null : resolveExitInput({
50326
+ ledger: workspace.ledger,
50327
+ exit: input.way,
50328
+ where: "way",
50329
+ failures
50330
+ });
50331
+ if (failures.length > 0) {
50332
+ throw new CliError(`Nothing was saved — some links do not resolve to pushed files:
50333
+ ` + failures.map((failure2) => ` ${failure2.where}: "${failure2.link}"` + `${failure2.reason === "unpushed" ? " (tracked but not pushed yet)" : " (no tracked file matches)"}`).join(`
50334
+ `), 2);
50335
+ }
50336
+ const parsedWay = way === null ? null : PairWaySchema.safeParse(way);
50337
+ const parsed = PlacePairSchema.safeParse({
50338
+ kind: input.relation,
50339
+ ...parentRef ? { parentRef } : {},
50340
+ way: parsedWay?.success ? parsedWay.data : way,
50341
+ words: typeof input.words === "string" ? { text: input.words, first: input.wordsFirst === "other" ? "b" : "a" } : null
50342
+ });
50343
+ if (!parsed.success) {
50344
+ throw new CliError(`The link JSON failed validation:
50345
+ ` + parsed.error.issues.map((issue3) => {
50346
+ const field = issue3.path.join(".").replace(PAIR_KIND_FIELD_RE, "relation");
50347
+ return ` ${field || "(root)"}: ${issue3.message}`;
50348
+ }).join(`
50349
+ `), 2);
50350
+ }
50351
+ const problem = placePairProblem({ pair: parsed.data, aRef, bRef });
50352
+ if (problem) {
50353
+ throw new CliError(problem, 2);
50354
+ }
50355
+ return parsed.data;
50356
+ }
50357
+ async function runLink(argv, mode) {
50358
+ const args = parseCommandArgs({
50359
+ argv,
50360
+ flags: { file: { type: "string" }, json: { type: "boolean" } },
50361
+ maxPositionals: 2
50362
+ });
50363
+ const [path, otherPath] = args.positionals;
50364
+ const inputPath = stringFlag(args, "file");
50365
+ if (!(path && otherPath) || mode === "link" !== Boolean(inputPath)) {
50366
+ throw new CliError(META_USAGE, 2);
50367
+ }
50368
+ const workspace = await loadWorkspaceContext();
50369
+ const entry = resolveTrackedEntry({ ledger: workspace.ledger, input: path });
50370
+ const other = resolveTrackedEntry({
50371
+ ledger: workspace.ledger,
50372
+ input: otherPath
50373
+ });
50374
+ const pair = inputPath ? translatePlacePairInput({
50375
+ workspace,
50376
+ raw: await readJsonFile2(inputPath),
50377
+ aRef: entry.referenceId,
50378
+ bRef: other.referenceId
50379
+ }) : null;
50380
+ const client = await workspaceClient(workspace);
50381
+ const { changedFileIds } = await client.putPlacePair({
50382
+ fileId: entry.fileId,
50383
+ otherFileId: other.fileId,
50384
+ pair
50385
+ });
50386
+ for (const fileId of changedFileIds) {
50387
+ const meta3 = await client.fetchFileMeta({ fileId });
50388
+ await freshenLedgerAfterMetaWrite({
50389
+ root: workspace.root,
50390
+ ledger: workspace.ledger,
50391
+ fileId,
50392
+ versionId: meta3.currentVersion.id
50393
+ });
50394
+ }
50395
+ if (args.booleans.has("json")) {
50396
+ printJson({ path, other: otherPath, saved: true, pair, changedFileIds });
50397
+ return 0;
50398
+ }
50399
+ const changed = changedFileIds.length === 0 ? "nothing changed" : `${changedFileIds.length} location${changedFileIds.length === 1 ? "" : "s"} changed`;
50400
+ printLines([
50401
+ mode === "link" ? `Linked ${path} and ${otherPath} (${pair?.kind}): ${changed}.` : `Unlinked ${path} and ${otherPath}: ${changed}.`,
50402
+ "See where each location now sits with `craft meta get`."
50403
+ ]);
50404
+ return 0;
50405
+ }
49223
50406
 
49224
50407
  // src/commands/mv.ts
49225
50408
  import { existsSync as existsSync8 } from "node:fs";
@@ -56055,9 +57238,12 @@ var HELP_COMMAND_LINES = ` clone <projectIdOrUrl> [dir] Download a project as
56055
57238
  map get|set <location-path> Read/author the interactive map stored on a
56056
57239
  location file (see "craft help map")
56057
57240
  meta get <path> Show a file's designation metadata (game start
56058
- config, GM triggers, playable flag, map summary)
57241
+ config, GM triggers, playable flag, map summary,
57242
+ a location's transport and linked locations)
56059
57243
  meta set-game-start|set-gm-trigger|set-playable
56060
57244
  Configure playability metadata (see "craft help meta")
57245
+ meta set-transport|link|unlink Make a location a ship/caravan, or link two
57246
+ locations (see "craft help meta")
56061
57247
  vn list|create|pull|check|push|publish|assets
56062
57248
  Write visual novels: pull a story to a JSON
56063
57249
  file, edit it, push it back, publish it
@@ -56389,6 +57575,10 @@ Authoring format = the stored MapData shape with conveniences:
56389
57575
  - element "id"s are minted when omitted; point style defaults are applied.
56390
57576
  - required: "aspectRatio" matching the background image (4:3 for a default
56391
57577
  landscape-4-3 generation). background/scale/grid default to null.
57578
+ - an area/point may carry "exit": a way to another location, e.g.
57579
+ {"kind":"stairs","to":"Places/The Inn Upstairs.location.json","direction":"up"}.
57580
+ "to" takes a workspace path (or slug.type / referenceId); leave it out for
57581
+ the way out to the parent map. \`get\` prints links and exit "to"s as paths.
56392
57582
 
56393
57583
  The background image URL must be Craft-hosted \u2014 generate one with
56394
57584
  \`craft image generate\` (or upload with \`craft image upload\`) and paste
@@ -56440,6 +57630,9 @@ chapters, where cutouts stand (desktop and phone), and pictures on pages.
56440
57630
  craft meta set-game-start <game-start-path> --file <json>
56441
57631
  craft meta set-gm-trigger <gm-instruction-path> (--file <json> | --clear)
56442
57632
  craft meta set-playable <character-path> <true|false>
57633
+ craft meta set-transport <location-path> (--file <json> | --clear)
57634
+ craft meta link <location-path> <other-location-path> --file <json>
57635
+ craft meta unlink <location-path> <other-location-path>
56443
57636
 
56444
57637
  Designation metadata is app-managed and lives outside the working tree;
56445
57638
  these commands write the server directly (the target file must be pushed).
@@ -56450,10 +57643,13 @@ set-game-start JSON: { "startingLocation": "<path|slug.location|null>",
56450
57643
  "openingInstructions": "...", "displayOrder": 0,
56451
57644
  "board": { "people": [{ "character": "<path|slug.character>", "side": "ally|enemy|neutral",
56452
57645
  "area": "...", "hidden": {"how":"unseen|sensed|disguised","as":"..."}, "note": "..." }],
56453
- "time": "...", "purpose": "...", "objectives": ["..."] },
57646
+ "purpose": "...", "objectives": ["..."],
57647
+ "trackers": [{ "key": "time", "on": true, "fields": [{ "kind": "text", "text": "Dusk" }] },
57648
+ { "key": "<tracker key>", "on": true, "fields": [{ "kind": "count", "value": 2 }] }] },
56454
57649
  "gmNotes": "..." }
56455
57650
  (board and gmNotes are read by GM Harness V2: the opening board and the facts it
56456
- reads on its first turn instead of looking them up)
57651
+ reads on its first turn instead of looking them up; the time it opens at is the
57652
+ Time tracker's, key "time")
56457
57653
 
56458
57654
  set-gm-trigger JSON (triggered GM rules; name \u2264120 / body \u2264500 chars):
56459
57655
  {"triggerKind":"narrative","triggerPhases":["post_player_message"]}
@@ -56461,6 +57657,23 @@ set-gm-trigger JSON (triggered GM rules; name \u2264120 / body \u2264500 chars):
56461
57657
 
56462
57658
  Read .craft/instructions/making-it-playable.md for how these fit together.
56463
57659
 
57660
+ set-transport JSON (a location that carries people; its deck plan is its map):
57661
+ {"kind":"ship","travels":"water","speed":{"value":6,"unit":"knots"},"capacity":40}
57662
+ kind: ship|boat|airship|wagon|caravan|carriage|train|car|bus|plane|mount|other
57663
+
57664
+ link JSON \u2014 how <location> relates to <other>, the shape \`meta get\` prints
57665
+ under linkedLocations (it replaces whatever lay between the two):
57666
+ {"relation":"part_of","way":{"kind":"stairs","direction":"up"}}
57667
+ relation: same_place (both parts of "in": "<path>") | contains (other is part
57668
+ of location) | part_of (location is part of other) | separate (needs "way") |
57669
+ other (needs "words": "is the Shadowfell reflection of", "wordsFirst":
57670
+ "location"|"other"). way: {kind, direction from location, oneWay:
57671
+ "there"|"back", locked, hidden, when, requires, label, distance, time}.
57672
+ A location is inside at most one place and never inside itself; linking can
57673
+ move a place in or out of another. unlink removes everything between them.
57674
+ Read .craft/instructions/linking-locations.md for exits, transports and
57675
+ linked locations.
57676
+
56464
57677
  upload: send a local image file (max 10MB; 50 per 24h, 1,000 per 24h for
56465
57678
  subscribers) to the project's media
56466
57679
  storage; the server re-encodes it to webp and returns the same pasteable