@craftrpgs/cli 0.2.0-alpha.1 → 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 +801 -192
  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,6 @@ 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";
26902
27378
  var LONG_TRAVEL = "Long-distance travel";
26903
27379
  var PLATFORM_OPTIONS = [
26904
27380
  {
@@ -26952,40 +27428,6 @@ var PLATFORM_OPTIONS = [
26952
27428
  ]
26953
27429
  }
26954
27430
  },
26955
- {
26956
- revision: 2,
26957
- option: {
26958
- key: "platform-roll-numbers",
26959
- label: ROLL_NUMBERS,
26960
- description: "Whether the story repeats the dice. Rolls always show as dice cards in the chat.",
26961
- kind: "one",
26962
- default: ["left-out"],
26963
- choices: [
26964
- {
26965
- key: "left-out",
26966
- label: "Exclude",
26967
- description: "The story tells what happens, not the numbers.",
26968
- instructions: [
26969
- {
26970
- when: "always",
26971
- 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."
26972
- }
26973
- ]
26974
- },
26975
- {
26976
- key: "shown",
26977
- label: "Include",
26978
- description: "Each total in a short bracket where it lands.",
26979
- instructions: [
26980
- {
26981
- when: "always",
26982
- 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)."
26983
- }
26984
- ]
26985
- }
26986
- ]
26987
- }
26988
- },
26989
27431
  {
26990
27432
  revision: 1,
26991
27433
  option: {
@@ -27036,7 +27478,7 @@ var GAME_OPTION_LABEL_MAX_LENGTH = 60;
27036
27478
  var GAME_OPTION_DESCRIPTION_MAX_LENGTH = 200;
27037
27479
  var GAME_OPTION_KEY_MAX_LENGTH = 60;
27038
27480
  var GAME_OPTIONS_MAX_OPTIONS = 8;
27039
- var GAME_OPTIONS_READ_MAX_OPTIONS = 12 + PLATFORM_OPTIONS.length;
27481
+ var GAME_OPTIONS_READ_MAX_OPTIONS = 15;
27040
27482
  var GAME_OPTION_MAX_CHOICES = 8;
27041
27483
  var GAME_OPTIONS_MAX_INSTRUCTIONS = 200;
27042
27484
  var GAME_OPTIONS_MAX_ALWAYS_LENGTH = 1e4;
@@ -27052,6 +27494,7 @@ var gameOptionInstructionSchema = exports_external.object({
27052
27494
  when: exports_external.enum(GAME_OPTION_INSTRUCTION_WHEN),
27053
27495
  trigger: exports_external.string().trim().max(GAME_OPTION_INSTRUCTION_NAME_MAX_LENGTH).optional(),
27054
27496
  section: exports_external.string().trim().max(GAME_OPTION_SECTION_MAX_LENGTH).optional(),
27497
+ tracker: trackerDefinitionSchema.optional(),
27055
27498
  platform: exports_external.string().max(GAME_OPTION_KEY_MAX_LENGTH).optional()
27056
27499
  });
27057
27500
  var gameOptionChoiceSchema = exports_external.object({
@@ -27123,6 +27566,12 @@ function sectionsOf(options) {
27123
27566
  return [...new Set(names)];
27124
27567
  }
27125
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
+ }
27126
27575
  if (instruction.when === "situational" && !instruction.trigger?.trim()) {
27127
27576
  return "a situational instruction needs the moment it's for.";
27128
27577
  }
@@ -27147,7 +27596,28 @@ function gameOptionsProblem(options) {
27147
27596
  }
27148
27597
  }
27149
27598
  }
27150
- 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;
27151
27621
  }
27152
27622
  function gameOptionsStructureProblem(options) {
27153
27623
  const optionKeys = new Set;
@@ -27208,6 +27678,54 @@ var storedGameOptionsSchema = storedGameOptionsShape.superRefine((options, ctx)
27208
27678
  ctx.addIssue({ code: "custom", message: problem });
27209
27679
  }
27210
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
+ }
27211
27729
  var resolvedGameOptionsSchema = gameOptionsShape.superRefine((options, ctx) => {
27212
27730
  const problem = gameOptionsProblem(options);
27213
27731
  if (problem) {
@@ -27280,7 +27798,13 @@ function withPlatform(stored) {
27280
27798
  const content = override ? overrideInstruction(override) : entry.instruction;
27281
27799
  const { section: ownSection, ...rest } = content;
27282
27800
  const section = placedIn?.trim() || ownSection?.trim() || entry.instruction.section;
27283
- 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
+ };
27284
27808
  }
27285
27809
  const instructions = [];
27286
27810
  const placed = new Set;
@@ -27356,6 +27880,12 @@ function instructionSignature(instruction) {
27356
27880
  instruction.trigger?.trim() ?? ""
27357
27881
  ]);
27358
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
+ }
27359
27889
  function optionSignature(option) {
27360
27890
  return JSON.stringify([
27361
27891
  option.label.trim(),
@@ -27381,7 +27911,7 @@ function platformInstructionStatus(instruction, stored) {
27381
27911
  if (!entry) {
27382
27912
  return null;
27383
27913
  }
27384
- if (instructionSignature(withoutPlatformTag(instruction)) === instructionSignature(entry.instruction)) {
27914
+ if (instructionSignature(withoutPlatformTag(instruction)) === instructionSignature(entry.instruction) && craftTracker(instruction, entry)) {
27385
27915
  return "platform";
27386
27916
  }
27387
27917
  const before = parseGameOptions(stored);
@@ -27416,10 +27946,11 @@ function alwaysTextLength(instructions) {
27416
27946
  return always.reduce((total, instruction) => total + instruction.body.length, 0) + [...titles].reduce((total, title) => total + title.length, 0);
27417
27947
  }
27418
27948
  function alwaysLength(instructions, place) {
27419
- 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) => ({
27420
27951
  ...instruction,
27421
27952
  title: instructionTitle(instruction, place)
27422
- })));
27953
+ }))) + trackerLines;
27423
27954
  }
27424
27955
  function situationalCount(instructions, _place) {
27425
27956
  return instructions.filter((instruction) => instruction.when === "situational").length;
@@ -36485,8 +37016,52 @@ function validateFileContentAgainstSchemaVariants(contents, schema) {
36485
37016
  return sharedErrors.length > 0 ? { status: "invalid", errors: sharedErrors } : { status: "valid" };
36486
37017
  }
36487
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
+
36488
37064
  // ../shared/src/files/file-reference-content.ts
36489
- var FILE_REFERENCE_EXTENSION_RE = /\.[^.]+$/;
36490
37065
  var STATE_PATH_LEADING_SLASH_RE = /^\/+/;
36491
37066
  var STATE_PATH_ARRAY_INDEX_RE = /\[(?:\d+|\*)\]/g;
36492
37067
  function isRecord5(value) {
@@ -36637,23 +37212,36 @@ function referenceHintsFromLayout(layout) {
36637
37212
  collectLayoutReferenceHints(layout, hints);
36638
37213
  return hints;
36639
37214
  }
36640
- function referenceCandidate(value) {
37215
+ function referenceNameParts(value) {
36641
37216
  const trimmed = value.trim();
36642
37217
  const lastSegment = trimmed.split("/").filter(Boolean).at(-1) ?? trimmed;
36643
- 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;
36644
37228
  }
36645
37229
  function normalizeFileReferenceLookupValue(value) {
36646
37230
  const candidate = referenceCandidate(value);
36647
37231
  return generateSlug(candidate) || candidate.toLowerCase();
36648
37232
  }
36649
37233
  function qualifiedLookupTypeSlug(value, fileTypeSlugs) {
37234
+ const normalizedTargets = new Set(fileTypeSlugs.map((slug) => normalizeReferenceTargetSlug(slug)).filter(Boolean));
36650
37235
  const segments = value.trim().split("/").filter(Boolean);
36651
- if (segments.length < 2) {
36652
- return null;
37236
+ if (segments.length >= 2) {
37237
+ const prefix = normalizeReferenceTargetSlug(segments[0]);
37238
+ if (normalizedTargets.has(prefix)) {
37239
+ return prefix;
37240
+ }
36653
37241
  }
36654
- const prefix = normalizeReferenceTargetSlug(segments[0]);
36655
- const normalizedTargets = new Set(fileTypeSlugs.map((slug) => normalizeReferenceTargetSlug(slug)).filter(Boolean));
36656
- 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;
36657
37245
  }
36658
37246
  function parseFileReferenceLookupValue(value, fileTypeSlugs = []) {
36659
37247
  const fileTypeSlug = qualifiedLookupTypeSlug(value, fileTypeSlugs);
@@ -40209,85 +40797,6 @@ function validateSectionsBlock(sections, errors3, options) {
40209
40797
  }
40210
40798
  }
40211
40799
  }
40212
- // ../shared/src/board-trackers.ts
40213
- var TRACKER_COUNT_STYLES = [
40214
- "number",
40215
- "bar",
40216
- "pips",
40217
- "boxes",
40218
- "clock",
40219
- "scale"
40220
- ];
40221
- var TRACKER_TONES = ["good", "bad"];
40222
- var TRACKER_ITEM_STATES = ["open", "done", "failed"];
40223
- var TRACKER_LASTS = ["here", "game"];
40224
- var TRACKER_LIMITS = {
40225
- name: 60,
40226
- label: 40,
40227
- text: 160,
40228
- rows: 6,
40229
- items: 16,
40230
- order: 20,
40231
- marks: 20,
40232
- clockMin: 2,
40233
- clockMax: 12,
40234
- scaleSteps: 21
40235
- };
40236
- var labelSchema = exports_external.string().trim().max(TRACKER_LIMITS.label).nullish();
40237
- var countRowSchema = exports_external.object({
40238
- kind: exports_external.literal("count"),
40239
- label: labelSchema,
40240
- value: exports_external.number(),
40241
- min: exports_external.number().nullish(),
40242
- max: exports_external.number().nullish(),
40243
- style: exports_external.enum(TRACKER_COUNT_STYLES).nullish(),
40244
- tone: exports_external.enum(TRACKER_TONES).nullish(),
40245
- ends: exports_external.tuple([exports_external.string(), exports_external.string()]).nullish(),
40246
- at: exports_external.record(exports_external.string(), exports_external.string()).nullish(),
40247
- start: exports_external.number().nullish()
40248
- });
40249
- var listItemSchema = exports_external.object({
40250
- text: exports_external.string().trim().min(1).max(TRACKER_LIMITS.text),
40251
- state: exports_external.enum(TRACKER_ITEM_STATES),
40252
- note: exports_external.string().trim().max(TRACKER_LIMITS.text).nullish()
40253
- });
40254
- var listRowSchema = exports_external.object({
40255
- kind: exports_external.literal("list"),
40256
- label: labelSchema,
40257
- items: exports_external.array(listItemSchema).max(TRACKER_LIMITS.items)
40258
- });
40259
- var orderEntrySchema = exports_external.object({
40260
- name: exports_external.string().trim().min(1),
40261
- score: exports_external.number().nullish()
40262
- });
40263
- var orderRowSchema = exports_external.object({
40264
- kind: exports_external.literal("order"),
40265
- label: labelSchema,
40266
- order: exports_external.array(orderEntrySchema).max(TRACKER_LIMITS.order),
40267
- current: exports_external.string().nullish(),
40268
- round: exports_external.number().int().nullish()
40269
- });
40270
- var textRowSchema = exports_external.object({
40271
- kind: exports_external.literal("text"),
40272
- label: labelSchema,
40273
- text: exports_external.string().trim().max(TRACKER_LIMITS.text)
40274
- });
40275
- var trackerRowSchema = exports_external.discriminatedUnion("kind", [
40276
- countRowSchema,
40277
- listRowSchema,
40278
- orderRowSchema,
40279
- textRowSchema
40280
- ]);
40281
- var trackerSchema = exports_external.object({
40282
- name: exports_external.string().trim().min(1).max(TRACKER_LIMITS.name),
40283
- rows: exports_external.array(trackerRowSchema).min(1).max(TRACKER_LIMITS.rows),
40284
- for: exports_external.string().trim().min(1).nullish(),
40285
- hidden: exports_external.boolean().nullish(),
40286
- lasts: exports_external.enum(TRACKER_LASTS).nullish(),
40287
- since: exports_external.string().nullish(),
40288
- changed: exports_external.string().nullish()
40289
- });
40290
-
40291
40800
  // ../shared/src/scripts/board.ts
40292
40801
  var UPDATE_KEYS = new Set([
40293
40802
  "arrive",
@@ -41061,13 +41570,14 @@ var GAME_START_MAX_CHARACTER_OPENINGS = 100;
41061
41570
  var GAME_START_GM_NOTES_MAX_LENGTH = 40000;
41062
41571
  var GAME_START_BOARD_LIMITS = {
41063
41572
  people: 30,
41064
- time: 80,
41065
41573
  purpose: 300,
41066
41574
  area: 60,
41067
41575
  hiddenAs: 160,
41068
41576
  note: 160,
41069
41577
  objectives: 16,
41070
- objective: 160
41578
+ objective: 160,
41579
+ trackers: 30,
41580
+ trackerKey: 60
41071
41581
  };
41072
41582
  var GM_INSTRUCTION_TRIGGER_PHASES = [
41073
41583
  "post_player_message",
@@ -41449,12 +41959,43 @@ var GameStartBoardPersonSchema = exports_external.object({
41449
41959
  }).nullish(),
41450
41960
  note: exports_external.string().trim().max(GAME_START_BOARD_LIMITS.note).nullish()
41451
41961
  });
41452
- 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({
41453
41994
  people: exports_external.array(GameStartBoardPersonSchema).max(GAME_START_BOARD_LIMITS.people).default([]),
41454
- time: exports_external.string().trim().max(GAME_START_BOARD_LIMITS.time).default(""),
41455
41995
  purpose: exports_external.string().trim().max(GAME_START_BOARD_LIMITS.purpose).default(""),
41456
- objectives: exports_external.array(exports_external.string().trim().min(1).max(GAME_START_BOARD_LIMITS.objective)).max(GAME_START_BOARD_LIMITS.objectives).default([])
41457
- });
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
+ }));
41458
41999
  function combinedOpeningLength(opening) {
41459
42000
  if (opening.kind !== "static" || !opening.beats) {
41460
42001
  return 0;
@@ -41473,9 +42014,9 @@ var GameStartMetadataObjectSchema = exports_external.object({
41473
42014
  openingInstructions: exports_external.string().max(GAME_START_OPENING_MAX_LENGTH).default(""),
41474
42015
  board: GameStartBoardSchema.default({
41475
42016
  people: [],
41476
- time: "",
41477
42017
  purpose: "",
41478
- objectives: []
42018
+ objectives: [],
42019
+ trackers: []
41479
42020
  }),
41480
42021
  gmNotes: exports_external.string().max(GAME_START_GM_NOTES_MAX_LENGTH).default("")
41481
42022
  });
@@ -41518,6 +42059,17 @@ var GameStartMetadataSchema = GameStartMetadataObjectSchema.superRefine((metadat
41518
42059
  }
41519
42060
  seenBoardReferenceIds.add(referenceId);
41520
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
+ });
41521
42073
  });
41522
42074
  var FileDesignationMetadataV1Schema = exports_external.object({
41523
42075
  schemaVersionId: exports_external.literal(FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V1).default(FILE_DESIGNATION_METADATA_SCHEMA_VERSION_V1),
@@ -42022,39 +42574,95 @@ interface BoardPerson {
42022
42574
  readonly details: { readonly [detail: string]: string | number | boolean };
42023
42575
  }
42024
42576
 
42025
- /** One row of a tracker: a count, a list, a turn order or a short text. */
42026
- 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
42027
42622
  | {
42028
- kind: "count";
42623
+ kind: "order";
42029
42624
  label?: string | null;
42030
- value: number;
42031
- min?: number | null;
42032
- max?: number | null;
42033
- style?: "number" | "bar" | "pips" | "boxes" | "clock" | "scale" | null;
42034
- tone?: "good" | "bad" | null;
42035
- /** A scale's two ends, low first. */
42036
- ends?: [string, string] | null;
42037
- /** What happens when the count reaches a value: { "6": "The guards arrive" }. */
42038
- 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;
42039
42630
  }
42631
+ | BoardTrackerText
42040
42632
  | {
42041
- kind: "list";
42633
+ kind: "ref";
42042
42634
  label?: string | null;
42043
- items: { text: string; state: "open" | "done" | "failed"; note?: string | null }[];
42635
+ hidden?: boolean | null;
42636
+ ref: BoardTrackerRef;
42637
+ show?: string[] | null;
42044
42638
  }
42045
42639
  | {
42046
- kind: "order";
42640
+ kind: "records";
42047
42641
  label?: string | null;
42048
- order: { name: string; score?: number | null }[];
42049
- current?: string | null;
42050
- 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;
42051
42649
  }
42052
- | { 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
+ };
42053
42661
 
42054
- /** 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. */
42055
42663
  interface BoardTracker {
42056
42664
  name: string;
42057
- rows: BoardTrackerRow[];
42665
+ fields: BoardTrackerField[];
42058
42666
  /** Someone on the board it is about. */
42059
42667
  for?: string | null;
42060
42668
  /** The GM's alone: players never see it. */
@@ -43008,7 +43616,7 @@ function parseMarkdown(source) {
43008
43616
  } catch {
43009
43617
  return { content: source };
43010
43618
  }
43011
- if (!isPlainObject2(parsed)) {
43619
+ if (!isPlainObject3(parsed)) {
43012
43620
  return { content: body };
43013
43621
  }
43014
43622
  const file2 = { content: body };
@@ -43056,7 +43664,7 @@ function parseFrontmatterObject(source) {
43056
43664
  } catch {
43057
43665
  return null;
43058
43666
  }
43059
- return isPlainObject2(parsed) ? parsed : null;
43667
+ return isPlainObject3(parsed) ? parsed : null;
43060
43668
  }
43061
43669
  function stripLeadingNewline(s) {
43062
43670
  if (s.startsWith(`\r
@@ -43069,7 +43677,7 @@ function stripLeadingNewline(s) {
43069
43677
  }
43070
43678
  return s;
43071
43679
  }
43072
- function isPlainObject2(value) {
43680
+ function isPlainObject3(value) {
43073
43681
  return typeof value === "object" && value !== null && !Array.isArray(value);
43074
43682
  }
43075
43683
  function coerceString(value) {
@@ -43085,19 +43693,6 @@ function coerceString(value) {
43085
43693
  // src/engine/vfs-name.ts
43086
43694
  import { isAbsolute, relative, resolve } from "node:path";
43087
43695
 
43088
- // ../shared/src/vfs/path.ts
43089
- var UNSAFE_DISK_SEGMENT_CHARS = /[\\/:*?"<>|\u0000-\u001f\u007f]/g;
43090
- function sanitizeVfsSegmentForDisk(segment) {
43091
- if (segment === "." || segment === ".." || segment.trim().length === 0) {
43092
- return "_";
43093
- }
43094
- const cleaned = segment.replace(UNSAFE_DISK_SEGMENT_CHARS, "_").trim();
43095
- return cleaned.length > 0 ? cleaned : "_";
43096
- }
43097
- function sanitizeVfsPathForDisk(path) {
43098
- return path.split("/").map((segment) => segment === "" ? "" : sanitizeVfsSegmentForDisk(segment)).join("/");
43099
- }
43100
-
43101
43696
  // ../shared/src/cli/vfs-name.ts
43102
43697
  function parseVfsFileName(fileName) {
43103
43698
  if (fileName.includes("/")) {
@@ -43192,10 +43787,10 @@ function deletedSidecarPath(workingPath) {
43192
43787
  }
43193
43788
 
43194
43789
  // src/engine/vfs-name.ts
43195
- var LEADING_SLASHES = /^\/+/;
43790
+ var LEADING_SLASHES2 = /^\/+/;
43196
43791
  function joinUnderWorkspaceRoot(root, vfsPath) {
43197
43792
  const rootResolved = resolve(root);
43198
- const relativePath = vfsPath.replace(LEADING_SLASHES, "");
43793
+ const relativePath = vfsPath.replace(LEADING_SLASHES2, "");
43199
43794
  if (relativePath.length === 0 || relativePath.split("/").some((segment) => segment === "." || segment === "..")) {
43200
43795
  throw new Error(`path escapes workspace root: ${vfsPath}`);
43201
43796
  }
@@ -43890,9 +44485,9 @@ async function loadBaseRecordsForMissing({
43890
44485
  return records;
43891
44486
  }
43892
44487
  var LEADING_DOT_SLASH = /^\.\//;
43893
- var TRAILING_SLASHES = /\/+$/;
44488
+ var TRAILING_SLASHES2 = /\/+$/;
43894
44489
  function normalizeVfsPath(input) {
43895
- const cleaned = input.replace(LEADING_DOT_SLASH, "").replace(TRAILING_SLASHES, "");
44490
+ const cleaned = input.replace(LEADING_DOT_SLASH, "").replace(TRAILING_SLASHES2, "");
43896
44491
  return cleaned.startsWith("/") ? cleaned : `/${cleaned}`;
43897
44492
  }
43898
44493
  function toIsoTimestamp(value) {
@@ -44361,6 +44956,17 @@ function checkGameOptions(workspace) {
44361
44956
  detail: "shape errors are reported under project-settings"
44362
44957
  };
44363
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
+ }
44364
44970
  const parsed = gameOptionsSchema.safeParse(value);
44365
44971
  if (!parsed.success) {
44366
44972
  const issue3 = parsed.error.issues[0];
@@ -45139,7 +45745,7 @@ var credentialsFileSchema = exports_external.object({
45139
45745
  version: exports_external.literal(1),
45140
45746
  remotes: exports_external.record(exports_external.string(), storedCredentialsSchema)
45141
45747
  }).strict();
45142
- var TRAILING_SLASHES2 = /\/+$/;
45748
+ var TRAILING_SLASHES3 = /\/+$/;
45143
45749
  function credentialsPath() {
45144
45750
  const dir = process.env.CRAFT_CONFIG_DIR ?? join4(homedir(), ".config", "craft");
45145
45751
  return join4(dir, "credentials.json");
@@ -45148,7 +45754,7 @@ function normalizeRemoteKey(remote) {
45148
45754
  try {
45149
45755
  return new URL(remote).origin;
45150
45756
  } catch {
45151
- return remote.replace(TRAILING_SLASHES2, "");
45757
+ return remote.replace(TRAILING_SLASHES3, "");
45152
45758
  }
45153
45759
  }
45154
45760
  async function readCredentialsFile() {
@@ -45203,7 +45809,7 @@ async function deleteStoredCredentials(remote) {
45203
45809
 
45204
45810
  // src/auth/oauth.ts
45205
45811
  import { createHash as createHash2, randomBytes as randomBytes3 } from "node:crypto";
45206
- var TRAILING_SLASHES3 = /\/+$/;
45812
+ var TRAILING_SLASHES4 = /\/+$/;
45207
45813
  async function fetchJson(fetchImpl, url2, init) {
45208
45814
  let response;
45209
45815
  try {
@@ -45221,7 +45827,7 @@ function isRecord15(value) {
45221
45827
  return typeof value === "object" && value !== null && !Array.isArray(value);
45222
45828
  }
45223
45829
  function authServerMetadataUrls(issuer) {
45224
- const base = issuer.replace(TRAILING_SLASHES3, "");
45830
+ const base = issuer.replace(TRAILING_SLASHES4, "");
45225
45831
  const url2 = new URL(base);
45226
45832
  const suffixForm = `${base}/.well-known/oauth-authorization-server`;
45227
45833
  const insertionForm = `${url2.origin}/.well-known/oauth-authorization-server${url2.pathname === "/" ? "" : url2.pathname}`;
@@ -45231,7 +45837,7 @@ async function discoverAuthorization({
45231
45837
  remote,
45232
45838
  fetchImpl = fetch
45233
45839
  }) {
45234
- const origin = remote.replace(TRAILING_SLASHES3, "");
45840
+ const origin = remote.replace(TRAILING_SLASHES4, "");
45235
45841
  const prmUrl = `${origin}/.well-known/oauth-protected-resource/mcp`;
45236
45842
  const prm = await fetchJson(fetchImpl, prmUrl);
45237
45843
  if (!(prm.ok && isRecord15(prm.json))) {
@@ -45589,7 +46195,7 @@ async function refreshWorkspaceScriptTypes({
45589
46195
  // package.json
45590
46196
  var package_default = {
45591
46197
  name: "@craftrpgs/cli",
45592
- version: "0.2.0-alpha.1",
46198
+ version: "0.2.0-alpha.2",
45593
46199
  description: "Sync Craft projects with a local folder — clone, edit with any coding agent, push back.",
45594
46200
  license: "UNLICENSED",
45595
46201
  homepage: "https://craftrpgs.com",
@@ -45610,14 +46216,14 @@ var package_default = {
45610
46216
  scripts: {
45611
46217
  build: "bun run scripts/build.ts",
45612
46218
  prepack: "bun run build",
45613
- typecheck: "tsc --noEmit"
46219
+ typecheck: "bun check"
45614
46220
  },
45615
46221
  devDependencies: {
45616
46222
  "@craft/flow": "workspace:*",
45617
46223
  "@craft/shared": "workspace:*",
45618
46224
  fflate: "^0.8.3",
45619
46225
  typescript: "7.0.2",
45620
- uuid: "11.1.0",
46226
+ uuid: "11.1.1",
45621
46227
  zod: "4.3.6"
45622
46228
  }
45623
46229
  };
@@ -45626,7 +46232,7 @@ var package_default = {
45626
46232
  var CLI_VERSION = package_default.version;
45627
46233
 
45628
46234
  // src/http.ts
45629
- var TRAILING_SLASHES4 = /\/+$/;
46235
+ var TRAILING_SLASHES5 = /\/+$/;
45630
46236
  var MAX_CONTRACT_ISSUES_SHOWN = 3;
45631
46237
  function formatContractIssues(issues) {
45632
46238
  if (!issues || issues.length === 0) {
@@ -45762,8 +46368,8 @@ function createCliClient({
45762
46368
  auth,
45763
46369
  fetchImpl = fetch
45764
46370
  }) {
45765
- const base = remote.replace(TRAILING_SLASHES4, "");
45766
- const appBase = (appRemote ?? remote).replace(TRAILING_SLASHES4, "");
46371
+ const base = remote.replace(TRAILING_SLASHES5, "");
46372
+ const appBase = (appRemote ?? remote).replace(TRAILING_SLASHES5, "");
45767
46373
  async function requestWithToken(url2, token, init) {
45768
46374
  try {
45769
46375
  return await fetchImpl(url2, {
@@ -57037,10 +57643,13 @@ set-game-start JSON: { "startingLocation": "<path|slug.location|null>",
57037
57643
  "openingInstructions": "...", "displayOrder": 0,
57038
57644
  "board": { "people": [{ "character": "<path|slug.character>", "side": "ally|enemy|neutral",
57039
57645
  "area": "...", "hidden": {"how":"unseen|sensed|disguised","as":"..."}, "note": "..." }],
57040
- "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 }] }] },
57041
57649
  "gmNotes": "..." }
57042
57650
  (board and gmNotes are read by GM Harness V2: the opening board and the facts it
57043
- 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")
57044
57653
 
57045
57654
  set-gm-trigger JSON (triggered GM rules; name \u2264120 / body \u2264500 chars):
57046
57655
  {"triggerKind":"narrative","triggerPhases":["post_player_message"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craftrpgs/cli",
3
- "version": "0.2.0-alpha.1",
3
+ "version": "0.2.0-alpha.2",
4
4
  "description": "Sync Craft projects with a local folder — clone, edit with any coding agent, push back.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://craftrpgs.com",
@@ -21,14 +21,14 @@
21
21
  "scripts": {
22
22
  "build": "bun run scripts/build.ts",
23
23
  "prepack": "bun run build",
24
- "typecheck": "tsc --noEmit"
24
+ "typecheck": "bun check"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@craft/flow": "workspace:*",
28
28
  "@craft/shared": "workspace:*",
29
29
  "fflate": "^0.8.3",
30
30
  "typescript": "7.0.2",
31
- "uuid": "11.1.0",
31
+ "uuid": "11.1.1",
32
32
  "zod": "4.3.6"
33
33
  }
34
34
  }