diff --git a/internal/check-series-isolation.mjs b/internal/check-series-isolation.mjs index 878cebb8f..af3f2bd1c 100644 --- a/internal/check-series-isolation.mjs +++ b/internal/check-series-isolation.mjs @@ -63,8 +63,19 @@ const HAND_VOCAB = { 'Project 700', 'How this list was found', ], + // The phrases that NAME a series. Derived vocabulary drops any sentence two series share, so a verbatim + // copy of one series' framing into another would go unseen without these (the copy becomes "shared"). country: [ 'PRAY FOR THIS COUNTRY', + 'Country Prayer Map', + 'one sheet for each country', + 'which country God has put', + ], + indiaState: [ + 'PRAY FOR THIS STATE', + 'State Prayer Map', + 'one sheet for each state', + 'which state God has put', ], } @@ -100,13 +111,13 @@ function deriveVocab(series) { ...strings(e.poster || {}), ].map((s) => s.trim()).filter((s) => s.length >= 12)) } - // Drop anything two series share — it cannot identify either of them. - for (const a of ids) { - for (const b of ids) { - if (a === b) continue - for (const s of own[b]) own[a].delete(s) - } - } + // Drop anything two series share — it cannot identify either of them. The shared set is found on the + // ORIGINAL sets first: deleting while still comparing pairs removed a shared string from the earlier + // series only, so the later one kept it as a "fingerprint" and the earlier one failed for carrying it. + // Invisible with two series; a third (indiaState, sharing country's networks) exposed it. + const shared = new Set() + for (const a of ids) for (const b of ids) if (a !== b) for (const s of own[a]) if (own[b].has(s)) shared.add(s) + for (const id of ids) for (const s of shared) own[id].delete(s) const out = {} for (const id of ids) out[id] = [...new Set([...(HAND_VOCAB[id] || []), ...own[id]])] return out @@ -120,8 +131,10 @@ function* strings(v) { } /** The text a series OWNS: its overrides and its poster furniture. Not its prose documentation. */ +// The poster header block too: that is where the country poster once printed the UPG-100 side box, and a scan +// of strings + networks alone could never see a series' framing copied into another's poster text. function seriesText(entry) { - return [...strings(entry.strings || {}), ...strings(entry.networks || [])] + return [...strings(entry.strings || {}), ...strings(entry.networks || []), ...strings(entry.poster || {})] } function checkDeclarations(series) { @@ -208,6 +221,9 @@ function selfTest() { ['upg100 loses its funnel', (s) => { s.upg100.funnel = false }], ['a series loses a networks block', (s) => { s.country.networks.pop() }], ['dataDir points nowhere', (s) => { s.country.dataDir = 'no/such/folder' }], + ['indiaState keeps the country side box', (s) => { s.indiaState.poster.sideBoxHeading = 'What is a Country Prayer Map?' }], + ['country keeps the state network line', (s) => { s.country.networks[0][1] = s.indiaState.networks[0][1] }], + ['indiaState keeps PRAY FOR THIS COUNTRY', (s) => { s.indiaState.strings.map_page_kicker = 'PRAY FOR THIS COUNTRY' }], ] let pass = 0 for (const [name, mutate] of cases) { @@ -224,6 +240,19 @@ function selfTest() { fails.length = 0 if (pass !== cases.length) fail(`self-test: caught ${pass}/${cases.length} known-bad inputs`) else ok(`self-test: caught all ${cases.length} known-bad inputs`) + // Shared wording is nobody's fingerprint, in EITHER order: three series where the first and last share a + // sentence must neither flag it nor lose their own phrases. + const SHARED = 'A sentence two series both print, word for word.' + const tri = { + a: { strings: { k: 'Only series a says this phrase.' }, networks: [['h', SHARED]] }, + b: { strings: { k: 'Only series b says this phrase.' }, networks: [] }, + c: { strings: { k: 'Only series c says this phrase.' }, networks: [['h', SHARED]] }, + } + const v = deriveVocab(tri) + const leaked = Object.entries(v).filter(([, p]) => p.includes(SHARED)).map(([id]) => id) + const lost = ['a', 'b', 'c'].filter((id) => !v[id].includes(`Only series ${id} says this phrase.`)) + if (leaked.length || lost.length) fail(`self-test: shared wording kept as vocabulary of [${leaked}] / own phrases lost for [${lost}]`) + else ok('self-test: shared wording is dropped from every series, whatever their order') } // ── run ────────────────────────────────────────────────────────────────────── diff --git a/library/packet/countrySheet.js b/library/packet/countrySheet.js index ea4dbbc87..dacab7b5f 100644 --- a/library/packet/countrySheet.js +++ b/library/packet/countrySheet.js @@ -58,8 +58,9 @@ * keeps the renderer's own default rather than a fabricated position. * * A null sheet is a tier too: India (sheet null, too many groups for any paper size) is its own - * 1-of-1 series. Skipping it printed "map 1 of 0" on India's packet page 1 from the Regions tab - * while the wall's own copy printed "map 1 of 1" -- the two paths disagreeing again. + * 1-of-1 series. Skipping it gave India's sheet "map 1 of 0" on the Regions path and "map 1 of 1" on the + * wall's old copy. Neither was visible then (the map-only packet page prints no series line, and India's + * poster is gated), but any India sheet with a header would have shown it. * Every null-sheet country shares that ONE oversized series: a second would make India "map 1 of 2". * check:country-sheet-one-path fails when that happens, so it is decided, not drifted into. */ diff --git a/library/scripts/posters/series.json b/library/scripts/posters/series.json index d7a903aba..c309f765e 100644 --- a/library/scripts/posters/series.json +++ b/library/scripts/posters/series.json @@ -143,5 +143,49 @@ "emit-countries.mjs), so the SHAPE was replaced: not 'split the set' but 'ask which one,", "then give them that poster'. The numbers are gone from the headings for the same reason." ] + }, + "indiaState": { + "label": "India states and union territories — one poster for each of the 34 with people groups listed", + "dataDir": "plugin-designs/my-upg-100-list/data", + "countryMode": true, + "funnel": false, + "poster": { + "kicker": "Unreached People Groups", + "titleQualifier": "Pray for", + "seriesName": "India State Maps", + "sideBoxHeading": "What is a State Prayer Map?", + "sideBoxBody": "This map is one of a {approxLists}-map set called the {seriesName} — one sheet for each state and union territory of India, naming the unreached people groups who live there so a reader can pray for them by name." + }, + "_poster_why": [ + "A state poster is a country poster one level down: the Country Posters wall prints one for each of India's", + "states and union territories that has people groups listed (34 of 36; data/people-groups/india-states.json).", + "The country series' text cannot be reused: it would call Gujarāt's poster one of the ~170 'Country Maps',", + "one sheet for each country. The term is 'state and union territory', never 'province'. Approved as the", + "default wording 2026-09-30 and shown as editable; a change is a strings-only follow-up." + ], + "strings": { + "map_page_kicker": "PRAY FOR THIS STATE", + "map_page_sub": "A prayer list of {count} unreached people groups who live here", + "points_intro": "One hour with God, five minutes each. Pray these twelve for the peoples of this state.", + "christian_lede": "This is the same grid — one human life in weeks. Now each box is a share of the people in this state. The shaded boxes are the evangelical share of their population; the rest are waiting." + }, + "_strings_why": [ + "The country series' four keys with 'country' read as 'state'. Used by the Node generator; the browser", + "packet reads the localized packet-strings.json, which is series-invariant." + ], + "networks": [ + [ + "1 · Disciples.", + "Ask a disciple which state God has put on their heart, then give them that state's poster to print and prepare." + ], + [ + "2 · Churches.", + "List the churches you know and ask each the same question, so every church has a poster to pray over together." + ], + [ + "3 · Indigenous leaders.", + "Ask which leaders you know who live in one of these states, and give them their own state's map to pray for it specifically." + ] + ] } }