Module:AlpazkigzSimulator

From MicrasWiki
Revision as of 20:54, 19 September 2026 by NewZimiaGov (talk | contribs) (Created page with "-- Module:AlpazkigzSimulator -- An illustrative, date-aware simulation of the mobile Alperkin city. -- Designed for MediaWiki Scribunto (Lua 5.1); no external packages or writes. -- CANON: festival dates and calendar conversion are sourced from -- Module:BassaridianCalendar; city customs from Alperkin/city articles. -- MODEL: exact annual assembly sites, daily indices, phase sub-boundaries -- and administrative scenarios are invented for gameplay, not historical data. l...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigationJump to search

Documentation for this module may be created at Module:AlpazkigzSimulator/doc

-- Module:AlpazkigzSimulator
-- An illustrative, date-aware simulation of the mobile Alperkin city.
-- Designed for MediaWiki Scribunto (Lua 5.1); no external packages or writes.
-- CANON: festival dates and calendar conversion are sourced from
-- Module:BassaridianCalendar; city customs from Alperkin/city articles.
-- MODEL: exact annual assembly sites, daily indices, phase sub-boundaries
-- and administrative scenarios are invented for gameplay, not historical data.

local p = {}
local MONTHS = { 'Atosiel', 'Thalassiel', 'Opsitheiel' }
local MONTH_NUM = { atosiel=1, thalassiel=2, opsitheiel=3, ['1']=1, ['2']=2, ['3']=3 }
local YEAR_LENGTH = 183

-- EDITABLE CANON REGISTER: When a specific annual location is established,
-- insert [54] = 'gloom' (etc.) rather than changing the rotation below.
-- Supported values: 'coastal', 'gloom', 'highlands'.
local RECORDED_ITINERARY = {
  -- [54] = 'gloom', -- EXAMPLE ONLY. Do not uncomment without confirming canon.
}

-- The year denotes the PSSC year of the next Cricket-Rise assembly.
-- A single site is held from that Cricket-Rise until the next Silence.
-- At the Silence, custodians move towards the following year's site.
local SITES = {
  coastal = {
    name='Coastal Woodlands assembly grounds',
    short='Coastal Woodlands', region='Northwestern Lake Morovia',
    anchor='Protected woodland clearings in the coastal Alperkin belt; precise clearing unrecorded',
    setting='Lakeward woodland edge, wet paths and dispersed forest-edge households',
    links={'Lake Morovia caravan transfer', 'Vaeringheim commercial agents', 'coastal woodland clan routes'},
    advantages='Easier lakeward transfer and shorter port-linked caravan connections.',
    sensitivities='Low-lying ground, runoff, access to freshwater and disturbance of protected groves.',
    port=8, route=5, water=-3, stewardship=-3, weather='Moist coastal air and possible saturated ground.'
  },
  gloom = {
    name='Gloom Forest assembly grounds',
    short='Gloom Forest of Perpetual Autumn', region='Central Alperkin',
    anchor='Recognised forest-edge gathering lands associated with the Gloom Forest; exact clearing unrecorded',
    setting='Autumnal canopy, ancestral approaches and strictly protected interior sanctuaries',
    links={'Erythros and Acheron forest-edge approaches', 'licensed forest caravan paths', 'lakeward transfer via fixed depots'},
    advantages='Proximity to ancestral gathering places and forest-derived craft traditions.',
    sensitivities='Sacred restricted areas, sensitive forest ecology, difficult visibility and controlled visitor access.',
    port=-4, route=-5, water=2, stewardship=-7, weather='Deep shade, damp forest passages and highly variable visibility.'
  },
  highlands = {
    name='Northern Highlands assembly grounds',
    short='Northern and Western Highlands', region='Northern Alperkin uplands',
    anchor='Recognised upland gathering terraces and customary approaches; precise terrace unrecorded',
    setting='Forest-to-highland transition, steep approaches and scattered mountain households',
    links={'northern highland pack routes', 'regional railway transfer through fixed settlements', 'forest-edge caravan passages'},
    advantages='A strong gathering point for upland clans, wool-workers and mountain couriers.',
    sensitivities='Slope stability, exposed weather, limited heavy-caravan access and protection of highland sacred routes.',
    port=-7, route=-8, water=-5, stewardship=1, weather='Exposed slopes, cooler nights and potentially disruptive upland conditions.'
  }
}
local SITE_ROTATION = { 'coastal', 'gloom', 'highlands' }

local FESTIVALS = {
  {doy=1, name='Cricket-Rise Festival', short='Cricket-Rise', description='The standardized opening of the Winter-to-Spring season and the Great Yurt reconstruction cycle.'},
  {doy=62, name='Cicada Song Festival', short='Cicada Song', description='Principal Alperkin gathering associated with the migration of the wider tribes.'},
  {doy=123, name='Cricket-Fall Festival', short='Cricket-Fall', description='Remembrance rites and the opening of the city\'s closing season.'},
  {doy=152, name='The Silence', short='The Silence', description='A solemn observance associated here with the city\'s relocation and dispersed civic life.'}
}

local POLICIES = {
  balanced={name='Balanced stewardship', note='Customary routine; no additional policy bias.', mod={}},
  sanctuary={name='Sanctuary and ceremonies', note='More ritual access and protected time, less market throughput.', mod={ritual=12, archives=5, trade=-8, market=-7, routes=-3}},
  trade={name='Caravan and market priority', note='Additional controlled trading hours increase commerce and infrastructure pressure.', mod={trade=13, market=14, supply=2, ritual=-4, stewardship=-8, fire=-5}},
  mobility={name='Migration readiness', note='Crews prioritise packing, route checks and vehicle staging over ordinary business.', mod={routes=14, archives=7, civic=3, market=-10, trade=-9, assembly=-3}},
  welfare={name='Households and public care', note='Extra staff are assigned to food, health, accessible routes and water distribution.', mod={health=13, water=10, supply=6, market=-5, trade=-3}},
  stewardship={name='Forest and sacred-land protection', note='Ground inspection and restricted access reduce ecological strain and commerce.', mod={stewardship=16, ritual=5, trade=-9, market=-9, routes=-3}},
  security={name='Perimeter and safe passage', note='Resources shift to emergency access, visitors and routes, limiting trade capacity.', mod={security=15, fire=8, routes=7, market=-9, ritual=-3}}
}

local DISTRICTS = {
  {name='Graji Canïfaha Precinct', kind='sanctuary',
   role='Tar-associated sanctuary, protected ceremonial ground and restricted religious custody.',
   office='Saint Mothers and temple custodians',
   tasks='Guard sanctified thresholds; inspect ritual coverings; coordinate authorised ceremonial access.'},
  {name='Court of the Heavenlet', kind='civic',
   role='Seat of the Heavenlet, reception of clan petitions and diplomatic audiences.',
   office='Heavenlet and clan chieftains',
   tasks='Receive petitions; preserve the civic yurt-story; record rulings and formal audiences.'},
  {name="Mothers' Enclosures", kind='sanctuary',
   role='Protected meeting courts, inter-clan mediation and custody of sacred records.',
   office='Saint Mothers',
   tasks='Mediate disputes; safeguard protected records; settle sanctuary-access questions.'},
  {name='Circle of the Clan Yurts', kind='household',
   role='Residential and kinship clusters arranged around customary clan gathering spaces.',
   office='Clan chieftains and household councils',
   tasks='Allocate household spaces; assist returning families; inspect paths and shared hearths.'},
  {name='The Yurt-Story Walk', kind='archives',
   role='Scribes, textile conservators and the recording and repair of portable family histories.',
   office='Scribes and hereditary custodians',
   tasks='Copy genealogies; protect memorial panels; prepare archives for safe transport.'},
  {name='Cricket Market', kind='trade',
   role='Food, textiles, tools and everyday household exchange; a larger fair at festivals.',
   office='Merchant stewards and clan market representatives',
   tasks='Arrange stalls; check food storage; record merchant deposits and return spaces.'},
  {name='Noctic Exchange', kind='trade',
   role='Culturally important, controlled trade in Noctic-Rabrev and ritual botanical goods.',
   office='Customary market stewards and relevant lawful inspectors',
   tasks='Keep restricted goods separate from food stalls; check provenance and safe handling.'},
  {name='Caravan Gateyards', kind='route',
   role='Receiving courts for people, freight, pack animals and outbound port-linked goods.',
   office='Gate stewards and transport coordinators',
   tasks='Reserve emergency routes; manage arrivals and departures; match shipments to depots.'},
  {name="The Weavers' Rows", kind='craft',
   role='Construction, repair and replacement of the Great Yurt\'s textile and structural components.',
   office='Weavers, rope-makers and construction crews',
   tasks='Check panels, lashings and frames; organise reusable materials and repair teams.'},
  {name='Outer Hearths and Service Grounds', kind='services',
   role='Water, kitchens, sanitation, household services and emergency staging inside or beyond the gates.',
   office='Service stewards and local response crews',
   tasks='Protect drinking water; clean collection points; inspect emergency access and storage.'}
}

local ROUTES = {
  {name='Lakeward exchange', destination='General Port of Lake Morovia',
   direction='Coastal woodland transfer yards → fixed depots → lake and port network',
   goods='Textiles, portable crafts, permitted botanicals and religious objects outbound; food, hardware and household supplies inbound.',
   note='The mobile city is not itself a deep-water port; freight uses permanent receiving and transfer points.'},
  {name='Forest-edge circuit', destination='Erythros and Acheron',
   direction='Recognised Gloom Forest perimeter corridors and established outside settlements',
   goods='Cloth, forest crafts, archives, household supplies and authorised ceremonial consignments.',
   note='Not all forest interior paths are public; sensitive and sacred areas remain restricted.'},
  {name='Northern highland circuit', destination='Northern and Western Highlands',
   direction='Approved upland clan paths and mountain transfer stations',
   goods='Wool, skins, repair stock, pack-animal services and family goods.',
   note='Steep routes limit bulky freight and may require rescheduling.'},
  {name='Southern Alperkin connection', destination='Odiferia and the Southern Odiferae Wetlands',
   direction='Registered southward caravans, lakeward markets and authorised inter-clan passages',
   goods='Textiles, prepared goods and permitted ceremonial supplies.',
   note='Odiferia remains a distinct settled Alperkin city with its own traditions and institutions.'},
  {name='National railway interface', destination='Trans-Morovian Express and regional fixed depots',
   direction='Road or caravan transfer between the current gathering ground and established rail facilities',
   goods='Passengers, construction materials, durable stores and forwarding consignments.',
   note='This does not imply a railway terminus moving with the Great Yurt.'}
}

local function clamp(x, min, max)
  if x < min then return min end
  if x > max then return max end
  return x
end
local function round(x) return math.floor(x + 0.5) end
local function tidy(x) return tostring(x or ''):match('^%s*(.-)%s*$') end
local function escape(x)
  local str=tostring(x or '')
  return (str:gsub('&','&amp;'):gsub('<','&lt;'):gsub('>','&gt;')
    :gsub('%[','&#91;'):gsub('%]','&#93;'):gsub('|','&#124;')
    :gsub('{','&#123;'):gsub('}','&#125;'))
end
local function bar(n)
  n=clamp(round(n),0,100)
  local blocks=math.floor(n/10+0.5)
  return string.rep('■',blocks)..string.rep('·',10-blocks)..' '..n..'/100'
end
local function dateFromDoy(doy, year)
  local m=math.floor((doy-1)/61)+1
  return {year=year, doy=doy, month=m, day=doy-(m-1)*61,
    monthName=MONTHS[m], label=(doy-(m-1)*61)..'/'..m..'/'..year..' PSSC',
    long=(doy-(m-1)*61)..' '..MONTHS[m]..', '..year..' PSSC'}
end
local function validInteger(x,min,max)
  local n=tonumber(x)
  if not n or n~=math.floor(n) or n<min or n>max then return nil end
  return n
end
local function resolveCurrent()
  -- Calendar module's first number is DAY OF YEAR, not day in the month.
  -- Example: '152, Opsitheiel (Stygian), 54 PSSC'.
  local ok, mod=pcall(require,'Module:BassaridianCalendar')
  if not ok or type(mod)~='table' or type(mod.getCurrentDateNoProverb)~='function' then
    return nil, 'Module:BassaridianCalendar is unavailable.'
  end
  local ran, output=pcall(mod.getCurrentDateNoProverb)
  if not ran or type(output)~='string' then
    return nil, 'The calendar module did not return a readable date.'
  end
  local doy, year=output:match('^%s*(%d+)%s*,.-,%s*(%-?%d+)%s+PSSC')
  doy=validInteger(doy,1,183)
  year=validInteger(year,0,9999)
  if not doy or not year then
    return nil, 'Could not parse the current PSSC date: '..output
  end
  return dateFromDoy(doy,year)
end
local function inputDate(args)
  local base,err=resolveCurrent()
  if not base then
    -- Explicit dates still work even when the live calendar module is offline.
    if not (args.date or args.year or args.doy or args.month or args.day) then return nil,err end
  end
  local year=validInteger(args.year or (base and base.year) or nil,0,9999)
  local doy
  if args.date and tidy(args.date)~='' then
    local d,m,y=tidy(args.date):match('^(%d+)%s*/%s*(%d+)%s*/%s*(%d+)%s*$')
    if not d then return nil,'Use date=day/month/year, for example date=30/3/54.' end
    d,m,y=validInteger(d,1,61),validInteger(m,1,3),validInteger(y,0,9999)
    if not d or not m or not y then return nil,'Invalid PSSC date: day must be 1–61, month 1–3.' end
    year=y
    doy=(m-1)*61+d
  elseif args.doy and tidy(args.doy)~='' then
    doy=validInteger(args.doy,1,YEAR_LENGTH)
    if not doy then return nil,'doy must be a whole number from 1 to 183.' end
  elseif (args.day and tidy(args.day)~='') or (args.month and tidy(args.month)~='') then
    local m=MONTH_NUM[tostring(args.month or (base and base.month) or ''):lower()]
    local d=validInteger(args.day or (base and base.day),1,61)
    if not m or not d then return nil,'Use month=1, 2, 3 (or month name) and day=1–61.' end
    doy=(m-1)*61+d
  else
    doy=base and base.doy
  end
  if not year or not doy then return nil,'A year and valid PSSC date are required.' end
  return dateFromDoy(doy,year)
end
local function siteForYear(year)
  local picked=RECORDED_ITINERARY[year]
  if picked and SITES[picked] then return picked,'Recorded itinerary configuration' end
  -- Stable three-year MODEL cycle; no claim that the real city followed it.
  return SITE_ROTATION[(year%3)+1],'Illustrative three-year rotation (not a canonical site record)'
end
local function currentLocation(date, args)
  local site, provenance=siteForYear(date.year)
  local destination, nextProvenance=siteForYear(date.year+1)
  local override=tidy(args.site or ''):lower()
  if override~='' then
    if not SITES[override] then
      return nil,'Invalid site. Choose coastal, gloom or highlands.'
    end
    site=override
    provenance='Reader-selected scenario (not canonical)'
  end
  local targetOverride=tidy(args.nextsite or ''):lower()
  if targetOverride~='' then
    if not SITES[targetOverride] then
      return nil,'Invalid nextsite. Choose coastal, gloom or highlands.'
    end
    destination=targetOverride
    nextProvenance='Reader-selected scenario (not canonical)'
  end
  local doy=date.doy
  local loc={origin=site, destination=destination, originSource=provenance,
    destinationSource=nextProvenance, site=site, assembled=(doy>=1 and doy<=145),
    status='assembled', source='Site schedule is simulated; exact historical coordinates are not provided.'}
  if doy>=146 and doy<=151 then
    loc.assembled=false
    loc.status='partly dismantled'
  elseif doy>=152 and doy<=158 then
    loc.assembled=false
    loc.status='on the migration route'
    loc.site=nil
  elseif doy>=159 then
    loc.assembled=false
    loc.status='dispersed winter gathering'
    loc.site=destination
  end
  if not loc.site then
    loc.title='In transit from '..SITES[site].short..' to '..SITES[destination].short
    loc.description='The Great Yurt has no fixed active site; custodians, clans and stores travel among recognised waypoints. No precise road or coordinates are asserted.'
  elseif doy>=159 then
    loc.title=SITES[destination].name..' (winter receiving ground)'
    loc.description='The next assembly site is receiving households and civic custodians, but the complete Great Yurt has not yet been reconstructed.'
  else
    loc.title=SITES[site].name
    loc.description=SITES[site].anchor
  end
  return loc
end
local function phaseFor(date)
  local d=date.doy
  if d<=6 then return 'rebuilding','The Great Yurt is being raised. Advance parties, families and the principal offices return.' end
  if d<=12 then return 'opening','Households settle, public services are inspected and the markets begin opening.' end
  if d<=61 then return 'inhabited','The mobile city is fully assembled and everyday civic life continues.' end
  if d<=71 then return 'cicada-gathering','Cicada Song draws wider tribal attendance, ceremonies, trading and inter-clan meetings.' end
  if d<=122 then return 'inhabited','The city is assembled; its households, courts, shrines and markets operate.' end
  if d<=136 then return 'cricket-fall','The city holds autumnal remembrance rites and begins its closing preparations.' end
  if d<=145 then return 'closing','Commercial accounts and petitions are being settled before the Great Yurt is packed.' end
  if d<=151 then return 'dismantling','The canopy, market stalls and residential structures are packed in stages.' end
  if d<=158 then return 'relocation','The Silence accompanies movement of households, sacred custody and civic archives.' end
  return 'winter-camp','Dispersed households and custodians occupy the receiving landscape; the full city awaits Cricket-Rise.'
end
local function festivalToday(date)
  for _,event in ipairs(FESTIVALS) do
    if event.doy==date.doy then return event end
  end
  return nil
end
local function nextFestival(date)
  for _,event in ipairs(FESTIVALS) do
    if event.doy>date.doy then return event,date.year,event.doy-date.doy end
  end
  return FESTIVALS[1],date.year+1,YEAR_LENGTH-date.doy+1
end
local function noise(y,d,s)
  local q=math.sin((y+31)*12.9898+(d+1)*78.233+(s+3)*37.719)*43758.5453
  return (q-math.floor(q))*2-1
end
local function metrics(date, phase, loc, policy)
  local load={rebuilding=37, opening=64, inhabited=76, ['cicada-gathering']=96,
    ['cricket-fall']=87, closing=68, dismantling=42, relocation=23, ['winter-camp']=29}
  local market={rebuilding=19, opening=54, inhabited=78, ['cicada-gathering']=95,
    ['cricket-fall']=76, closing=45, dismantling=12, relocation=7, ['winter-camp']=24}
  local assembled={rebuilding=round((date.doy/7)*100),opening=100,inhabited=100,
    ['cicada-gathering']=100,['cricket-fall']=100,closing=95,
    dismantling=round((152-date.doy)/7*100),relocation=0,['winter-camp']=0}
  local ctx=SITES[loc.site or loc.origin]
  local shifts=POLICIES[policy].mod
  local mods={attendance=0, assembly=0, market=0, trade=0, routes=ctx.route,
    water=ctx.water, stewardship=ctx.stewardship, fire=0, archives=0,
    health=0, ritual=0, supply=0, security=0, civic=0}
  for k,v in pairs(shifts) do mods[k]=(mods[k] or 0)+v end
  local m={}
  local base={
    attendance=load[phase], assembly=assembled[phase], market=market[phase],
    trade=market[phase]+ctx.port-5, routes=74, water=73,
    stewardship=82, fire=76, archives=82, health=75, ritual=74,
    supply=76, security=75, civic=77
  }
  if phase=='rebuilding' or phase=='dismantling' then
    base.routes=67; base.fire=62; base.supply=70; base.civic=63; base.archives=70
  elseif phase=='relocation' then
    base.routes=58; base.water=66; base.fire=67; base.archives=69; base.civic=66
  elseif phase=='winter-camp' then
    base.routes=64; base.water=69; base.fire=72; base.archives=84
  elseif phase=='cicada-gathering' then
    base.water=62; base.fire=66; base.stewardship=70; base.supply=68; base.health=70
  end
  local order={'attendance','assembly','market','trade','routes','water','stewardship','fire','archives','health','ritual','supply','security','civic'}
  for i,key in ipairs(order) do
    local jitter=round(5*noise(date.year,date.doy,i))
    if key=='assembly' then jitter=0 end
    m[key]=clamp(round(base[key]+jitter+(mods[key] or 0)),0,100)
  end
  -- This is a modelled attendance *index*, not an invented census.
  return m
end
local function concerns(m,phase,loc)
  local out={}
  if m.water<70 then out[#out+1]='Water distribution requires inspection before additional households or visitors are admitted.' end
  if m.fire<70 then out[#out+1]='Keep emergency corridors unobstructed and check textile, hearth and ventilation arrangements.' end
  if m.stewardship<73 then out[#out+1]='Inspect ground disturbance and restrict traffic near protected woodland and sacred approaches.' end
  if m.routes<65 then out[#out+1]='Review caravan timing, animal rest points and access for emergency personnel.' end
  if m.archives<75 then out[#out+1]='Transfer priority to the yurt-story panels and portable civic records.' end
  if phase=='relocation' then out[#out+1]='The assembled city\'s address is suspended; use custodians and registered transfer grounds.' end
  if phase=='winter-camp' then out[#out+1]='The receiving ground is not a fully assembled urban settlement; keep public functions distributed.' end
  if loc.origin==loc.destination then out[#out+1]='Origin and destination coincide in this scenario: relocation is between recognised grounds within the same landscape, not necessarily the same clearing.' end
  if #out==0 then out[1]='Routine civic, sacred-land and public-health inspections remain in effect.' end
  return out
end
local function districtStatus(phase, kind)
  if phase=='relocation' then
    if kind=='archives' or kind=='civic' or kind=='sanctuary' then return 'Travelling custody' end
    if kind=='services' or kind=='route' then return 'Mobile support' end
    return 'Packed / dispersed'
  end
  if phase=='winter-camp' then
    if kind=='archives' or kind=='civic' or kind=='sanctuary' then return 'Distributed winter office' end
    if kind=='services' or kind=='route' then return 'Receiving-ground service' end
    return 'No permanent city precinct'
  end
  if phase=='dismantling' then
    if kind=='archives' or kind=='sanctuary' or kind=='civic' then return 'Packing under custody' end
    if kind=='trade' then return 'Settling and closing' end
    return 'Being dismantled'
  end
  if phase=='rebuilding' then
    if kind=='services' or kind=='route' or kind=='craft' then return 'Priority construction / receiving' end
    if kind=='sanctuary' then return 'Ceremonial re-establishment' end
    return 'Opening progressively'
  end
  if phase=='closing' then
    if kind=='trade' then return 'Final market / accounts' end
    if kind=='archives' or kind=='craft' then return 'Packing preparations' end
    return 'Open; closing preparations'
  end
  if kind=='sanctuary' then return 'Active; authorised access only' end
  if phase=='cicada-gathering' then return 'Active; festival demand' end
  if phase=='cricket-fall' then return 'Active; autumn rites' end
  return 'Active'
end
local function dailyEvent(date, phase, loc, m)
  local festival=festivalToday(date)
  if festival then return festival.name,festival.description end
  local scripts={
    rebuilding={
      'Receiving-ground survey','Textile and lashings inspection','Returning households',
      'Sacred archive transfer','Water and hearth certification','Gate and market opening'},
    opening={'Household allotment review','Opening of the Cricket Market','Clan registration','Public sanitation checks'},
    inhabited={'Clan petition day','Yurt-story preservation','Caravan receiving','Perimeter and water inspection','Weavers\' repair day'},
    ['cicada-gathering']={'Inter-clan audience','Cicada fair arrangements','Festival shelter inspection','Visitor access review'},
    ['cricket-fall']={'Remembrance gathering','Autumn stores audit','Cemetery and tar-land access review'},
    closing={'Final merchant settlement','Clan departure scheduling','Packing of non-essential goods','Winter route review'},
    dismantling={'Canopy panel inventory','Archive packing and sealing','Emergency route clearance','Caravan load inspection'},
    relocation={'Protected caravan passage','Waystation inspection','Transfer of the civic yurt-story','Silence observances'},
    ['winter-camp']={'Household winter correspondence','Receiving-ground stewardship','Repair of portable structures','Planning the next Cricket-Rise'}
  }
  local arr=scripts[phase]
  local event=arr[((date.doy+date.year)%#arr)+1]
  local detail='Illustrative daily civic activity at '..loc.title..'.'
  if m.water<66 then detail=detail..' Water and sanitation are flagged for attention.' end
  return event,detail
end
local function buildState(args)
  args=args or {}
  local date,err=inputDate(args)
  if not date then return nil,err end
  local policy=tidy(args.policy or 'balanced'):lower()
  if policy=='' then policy='balanced' end
  if not POLICIES[policy] then return nil,'Unknown policy. Choose balanced, sanctuary, trade, mobility, welfare, stewardship or security.' end
  local loc,lerr=currentLocation(date,args)
  if not loc then return nil,lerr end
  local phase,description=phaseFor(date)
  local m=metrics(date,phase,loc,policy)
  local event,nextEventYear,daysLeft=nextFestival(date)
  local dailyTitle,dailyDescription=dailyEvent(date,phase,loc,m)
  return {
    date=date, phase=phase, phaseDescription=description,
    location=loc, metrics=m, policy=policy,
    today=festivalToday(date), dailyTitle=dailyTitle, dailyDescription=dailyDescription,
    nextFestival=event, nextFestivalYear=nextEventYear, daysUntilNext=daysLeft,
    concerns=concerns(m,phase,loc)
  }
end

local function row(k,v) return '|-\n! style="text-align:left;vertical-align:top" | '..k..'\n| '..v..'\n' end
local function heading(text) return '\n== '..text..' ==\n' end
local function renderTable(title,rows)
  return '{| class="wikitable" style="width:100%;"\n|+ '..title..'\n'..table.concat(rows)..'|}\n'
end
local function renderSmall(state)
  local d,l=state.date,state.location
  local rows={
    row('PSSC date',d.long),
    row('Status',escape(state.phaseDescription)),
    row('Current location',escape(l.title)),
    row('Assembly',l.assembled and 'Great Yurt assembled or in reconstruction' or 'Great Yurt not fully assembled'),
    row('Next festival',state.nextFestival.name..' — '..dateFromDoy(state.nextFestival.doy,state.nextFestivalYear).label..' ('..state.daysUntilNext..' days)'),
    row('Location certainty',escape(l.originSource)..'; '..escape(l.source))
  }
  return renderTable('Alpazkigz — '..d.label,rows)
end
local function renderHistory(state)
  local d=state.date
  local lines={}
  lines[#lines+1]="The city is a seasonal institution, not a fixed point on a map. In this '''illustrative itinerary''', year "..d.year.." assembles at '''"..SITES[state.location.origin].short.."''', while the next Cricket-Rise is planned for '''"..SITES[state.location.destination].short.."'''."
  lines[#lines+1]="'''On this date:''' "..state.phaseDescription
  lines[#lines+1]="'''Daily dispatch:''' "..escape(state.dailyTitle)..' — '..escape(state.dailyDescription)
  return table.concat(lines,'\n\n')..'\n'
end
local function renderMetrics(state)
  local m=state.metrics
  local displays={
    {'Attendance index','attendance','Relative concentration of households and visitors, not headcount.'},
    {'Great Yurt completion','assembly','Progress of assembly; a modelled progress measure.'},
    {'Market activity','market','Activity of ordinary and festival markets.'},
    {'Port-linked trade','trade','Ability to dispatch and receive goods through fixed depots.'},
    {'Route accessibility','routes','Readiness of caravans, receiving grounds and emergency roads.'},
    {'Water and sanitation','water','Readiness of drinking water and waste systems.'},
    {'Sacred-land stewardship','stewardship','Capacity to protect sensitive gathering lands.'},
    {'Fire and structural readiness','fire','Readiness of fire response, egress and shelter inspections.'},
    {'Archives and continuity','archives','Care of the portable yurt-story and civic records.'},
    {'Public health and care','health','Capacity for routine public health and household support.'},
    {'Ceremonial provision','ritual','Readiness of ritual spaces and customary observances.'},
    {'Household supply','supply','Availability of routine stores and distribution services.'},
    {'Safe passage','security','Visitor screening and safety on customary routes.'},
    {'Civic continuity','civic','Ability to hear petitions and maintain authorities when dispersed.'}
  }
  local rows={}
  for _,x in ipairs(displays) do
    rows[#rows+1]=row(x[1],bar(m[x[2]])..' — '..x[3])
  end
  return renderTable('Illustrative operational indicators (0–100; not census or measured statistics)',rows)
end
local function renderDistricts(state)
  local rows={}
  for _,d in ipairs(DISTRICTS) do
    rows[#rows+1]="|-\n| '''"..d.name.."'''\n| "..districtStatus(state.phase,d.kind)..'\n| '..d.role..'\n| '..d.office..'\n| '..d.tasks..'\n'
  end
  return '{| class="wikitable sortable" style="width:100%;"\n! Precinct !! Status on this date !! Role !! Stewardship !! Today\n'..table.concat(rows)..'|}\n'
end
local function renderGovernance(state)
  local phase=state.phase
  local mode='The Heavenlet receives petitions at the Court; clan chieftains coordinate households; Saint Mothers preside over customary and religious matters.'
  if phase=='relocation' or phase=='winter-camp' then
    mode='The Heavenlet, Saint Mothers and clan chieftains continue their functions through travelling or dispersed custodians; the absence of an assembled court does not dissolve their offices.'
  elseif phase=='dismantling' or phase=='closing' then
    mode='Priority is placed on finishing petitions, recording decisions, protecting sacred archives and arranging urgent hearings during travel.'
  elseif phase=='cicada-gathering' then
    mode='Expanded inter-clan audiences and consultations accompany the Cicada Song gathering; visitors do not acquire access to restricted sanctuaries.'
  end
  local rows={
    row('Heavenlet',mode),
    row('Saint Mothers','Customary mediation, religious stewardship and protected ceremonial thresholds. No computed score replaces their decisions.'),
    row('Clan chieftains','Household representation, allotment and coordination between migrating or assembled clans.'),
    row('Constitutional setting','Customary institutions and cultural migration routes operate within the national constitutional framework, including protected sacred land and applicable national law.'),
    row('Visitor and Nonkin protections','The simulation assumes no exemption from public safety, lawful access and the protection of residents or visitors; historic abuses are not presented as permissible present-day policy.'),
    row('Civic service index',bar(state.metrics.civic))
  }
  return renderTable('Government, customary authority and continuity',rows)
end
local function renderSacred(state)
  local d=state.date
  local season,plant
  if d.doy<=61 then season='Winter-to-Spring'; plant='Green growth is associated with the opening season.'
  elseif d.doy<=122 then season='Spring-to-Summer'; plant='The season is associated with the cicada song and growing Alperkin movement.'
  elseif d.doy<=151 then season='Summer-to-Fall'; plant='Autumn remembrance and the closing of the assembled city dominate public observance.'
  else season='Fall-to-Winter'; plant='The Silence marks contemplation and the dispersed condition of the city.' end
  local today=state.today and (state.today.name..' — '..state.today.description) or 'No principal standardized Alperkin festival falls on this date.'
  return renderTable('Sacred calendar and religious life',{
    row('Liturgical season',season),
    row('Today',today),
    row('Seasonal environment',plant),
    row('Graji Canïfaha','The sanctuary of Tar, whose ritual access is restricted by Alperkin custom. Physical status: '..districtStatus(state.phase,'sanctuary')..'.'),
    row('Yurt-stories','Portable family and civic histories remain part of the city even when its buildings are in transit.'),
    row('Calendar caveat','The official module assigns fixed festival days. Traditional Alperkin accounts also link observances to insect song, the solstices and lunar events; no astronomical ephemeris is computed here.'),
    row('Ceremonial provision',bar(state.metrics.ritual))
  })
end
local function renderEconomy(state)
  local loc=state.location
  local ctx=SITES[loc.site or loc.origin]
  local m=state.metrics
  local note='Trade uses the city’s portable markets and registered external depots.'
  if state.phase=='relocation' or state.phase=='winter-camp' then
    note='The main city market is dispersed; trade and correspondence pass through registered travelling agents and fixed transfer depots.'
  elseif state.phase=='closing' or state.phase=='dismantling' then
    note='Merchants settle obligations, dispatch remaining stock and preserve claims to spaces in the next assembled market.'
  end
  return renderTable('Commerce and the General Port connection',{
    row('Commercial phase',note),
    row('Current landscape advantage',ctx.advantages),
    row('Principal outbound goods','Portable textiles and craftwork, household products, permitted botanicals and authorised religious objects.'),
    row('Principal inbound goods','Food staples, hardware, medical supplies, shelter materials and equipment not readily produced by travelling households.'),
    row('National trade interface','[[General Port of Lake Morovia]] and the Alpazkigz Regional Investor; this module does not claim to reproduce their live financial ledgers.'),
    row('Ordinary market',bar(m.market)),row('Port-linked trade',bar(m.trade)),row('Household supply',bar(m.supply)),
    row('Data status','All displayed indices are scenario outputs, not recorded Poli totals, prices, tonnage, population or trade statistics.')
  })
end
local function renderRoutes(state)
  local loc=state.location
  local routeIntro
  if state.phase=='relocation' then
    routeIntro='Custodians and households are modelled as moving between '..SITES[loc.origin].short..' and '..SITES[loc.destination].short..'. A precise track is not asserted.'
  elseif state.phase=='winter-camp' then
    routeIntro='Households have reached the receiving landscape at '..SITES[loc.destination].short..', but the Great Yurt remains unassembled.'
  else
    routeIntro='The gathering landscape is '..SITES[loc.origin].short..'; approaches vary with the assembly ground and protected route permissions.'
  end
  local out={routeIntro,'\n{| class="wikitable" style="width:100%;"','! Route !! Destination / interface !! What moves !! Limitations'}
  for _,r in ipairs(ROUTES) do
    out[#out+1]="|-\n| '''"..r.name.."'''\n| "..r.destination..'<br />'..r.direction..'\n| '..r.goods..'\n| '..r.note
  end
  out[#out+1]='|}'
  out[#out+1]="\n'''Route readiness".."''' — "..bar(state.metrics.routes)..'\n'
  return table.concat(out,'\n')
end
local function renderEnvironment(state)
  local site=SITES[state.location.site or state.location.origin]
  return renderTable('Environmental and civic-service conditions',{
    row('Assembly landscape',site.setting),
    row('Landscape considerations',site.weather),
    row('Protection priorities',site.sensitivities),
    row('Water and sanitation',bar(state.metrics.water)),
    row('Fire readiness',bar(state.metrics.fire)),
    row('Land stewardship',bar(state.metrics.stewardship)),
    row('Health and household care',bar(state.metrics.health)),
    row('Operational caveat','These are illustrative readiness indices, not real-time weather, measured health outcomes, or an environmental-impact survey.')
  })
end
local function renderAdvisories(state)
  local out={"* '''"..POLICIES[state.policy].name.."''': "..POLICIES[state.policy].note}
  for _,warning in ipairs(state.concerns) do out[#out+1]='* '..warning end
  return table.concat(out,'\n')..'\n'
end
local function renderItinerary(state)
  local rows={}
  for offset=-1,2 do
    local year=state.date.year+offset
    if year>=0 then
      local key,source=siteForYear(year)
      if year==state.date.year and state.location.originSource:find('Reader',1,true) then
        key=state.location.origin; source=state.location.originSource
      elseif year==state.date.year+1 and state.location.destinationSource:find('Reader',1,true) then
        key=state.location.destination; source=state.location.destinationSource
      end
      rows[#rows+1]='|-\n| '..year..' PSSC\n| '..SITES[key].short..'\n| '..escape(source)..'\n| Next assembly on 1/1/'..year..' PSSC; route preparations at the preceding Silence.\n'
    end
  end
  local out={'{| class="wikitable" style="width:100%;"','! Assembly year !! Selected landscape !! Evidentiary status !! Annual transition',table.concat(rows),'|}'}
  out[#out+1]="\n'''Important".."''': The default repeating itinerary is a playable model, not proof of the city’s position in a historical year. Put documented dates into RECORDED_ITINERARY or use site= / nextsite= for hypothetical scenarios."
  return table.concat(out,'\n')..'\n'
end
local function renderCalendar(state)
  local checkpoints={1,2,7,13,54,61,62,63,72,113,122,123,124,137,146,151,152,153,158,159,173,183}
  local rows={}
  for _,doy in ipairs(checkpoints) do
    local date=dateFromDoy(doy,state.date.year)
    local phase,description=phaseFor(date)
    local festival=festivalToday(date)
    local label=festival and festival.name or description
    local loc=currentLocation(date,{site=state.location.originSource:find('Reader',1,true) and state.location.origin or nil,
      nextsite=state.location.destinationSource:find('Reader',1,true) and state.location.destination or nil})
    local active=(doy==state.date.doy) and "'''CURRENT SELECTED DAY''' — " or ''
    rows[#rows+1]='|-\n| '..active..date.label..'\n| '..escape(loc.title)..'\n| '..escape(phase)..'\n| '..escape(label)..'\n'
  end
  return '{| class="wikitable sortable" style="width:100%;"\n! Date !! Modeled location !! Phase !! Holiday or civic activity\n'..table.concat(rows)..'|}\n'
end
local function renderDispatches(state,n)
  n=validInteger(n or 14,1,60) or 14
  local rows={}
  local current=state.date
  local siteArgs={site=state.location.originSource:find('Reader',1,true) and state.location.origin or nil,
    nextsite=state.location.destinationSource:find('Reader',1,true) and state.location.destination or nil,policy=state.policy}
  for offset=0,n-1 do
    local doy=current.doy+offset
    local year=current.year
    if doy>183 then doy=doy-183; year=year+1 end
    local d=dateFromDoy(doy,year)
    local locArgs={site=(year==current.year) and siteArgs.site or ((year==current.year+1) and siteArgs.nextsite or nil),
      nextsite=(year==current.year) and siteArgs.nextsite or nil}
    local loc=currentLocation(d,locArgs)
    local phase=phaseFor(d)
    local m=metrics(d,phase,loc,state.policy)
    local title,description=dailyEvent(d,phase,loc,m)
    rows[#rows+1]='|-\n| '..d.label..'\n| '..escape(loc.title)..'\n| '..escape(title)..'\n| '..escape(description)..'\n'
  end
  return '{| class="wikitable sortable" style="width:100%;"\n! Date !! Location !! Civic dispatch !! Summary\n'..table.concat(rows)..'|}\n'
end
local function renderMap(state)
  local loc=state.location
  local rows={}
  for _,key in ipairs(SITE_ROTATION) do
    local here=(loc.site==key and '● Current gathering landscape' or
      (state.phase=='relocation' and loc.origin==key and '◌ Departure region' or
      (state.phase=='relocation' and loc.destination==key and '➜ Destination region' or
      (loc.destination==key and '○ Next assembly landscape' or '—'))))
    rows[#rows+1]='|-\n| '..SITES[key].short..'\n| '..here..'\n| '..SITES[key].anchor..'\n'
  end
  return '{| class="wikitable" style="width:100%;"\n! Region !! Status on selected date !! Where within the region\n'..table.concat(rows)..'|}\n'..
    "''Schematic locator only: no fictitious street address, GPS coordinates or exact annual campsite are implied.''\n"
end
local function argumentTable(frame)
  local args={}
  if type(frame)=='table' then
    if type(frame.getParent)=='function' then
      local ok,parent=pcall(frame.getParent,frame)
      if ok and parent and parent.args then
        for k,v in pairs(parent.args) do if tidy(v)~='' then args[k]=v end end
      end
    end
    if frame.args then
      for k,v in pairs(frame.args) do if tidy(v)~='' then args[k]=v end end
    else
      for k,v in pairs(frame) do if type(k)=='string' and tidy(v)~='' then args[k]=v end end
    end
  end
  return args
end
local function withState(frame,render)
  local args=argumentTable(frame)
  local state,err=buildState(args)
  if not state then return '<strong class="error">AlpazkigzSimulator: '..escape(err)..'</strong>' end
  return render(state,args)
end

-- Public #invoke endpoints --------------------------------------------------
function p.dashboard(frame)
  return withState(frame,function(state,args)
    local out={
      "'''ALPAZKIGZ / SEASONAL CIVIC SIMULATION'''",
      "\n''A repeatable, fictional scenario informed by Alperkin lore. Live location and indices are not canonically recorded observations.''\n",
      renderSmall(state),
      renderHistory(state),
      heading('Gathering landscape and planned itinerary'),renderMap(state),renderItinerary(state),
      heading('Civic activity and operating indicators'),renderMetrics(state),
      heading('Customary government and law'),renderGovernance(state),
      heading('Sacred calendar and ceremonial life'),renderSacred(state),
      heading('Markets and commerce'),renderEconomy(state),
      heading('District-by-district civic operations'),renderDistricts(state),
      heading('Caravan routes, rail interfaces and travel'),renderRoutes(state),
      heading('Ecology, public health and emergency preparedness'),renderEnvironment(state),
      heading('Daily advisories'),renderAdvisories(state),
      heading('Upcoming PSSC milestones'),renderCalendar(state)
    }
    if tidy(args.chronicle or '')~='' then
      out[#out+1]=heading('Forward daily dispatches')
      out[#out+1]=renderDispatches(state,args.chronicle)
    end
    out[#out+1]="\n''Calendar source: [[Module:BassaridianCalendar]]; lore: [[Alperkin]], [[List of cities in Bassaridia Vaeringheim#The Alpazkigz|The Alpazkigz]] and [[General Port of Lake Morovia]].''"
    return table.concat(out,'\n')
  end)
end
function p.location(frame) return withState(frame,function(state) return renderSmall(state) end) end
function p.calendar(frame) return withState(frame,function(state) return renderCalendar(state) end) end
function p.districts(frame) return withState(frame,function(state) return renderDistricts(state) end) end
function p.economy(frame) return withState(frame,function(state) return renderEconomy(state) end) end
function p.governance(frame) return withState(frame,function(state) return renderGovernance(state) end) end
function p.sacred(frame) return withState(frame,function(state) return renderSacred(state) end) end
function p.routes(frame) return withState(frame,function(state) return renderRoutes(state) end) end
function p.environment(frame) return withState(frame,function(state) return renderEnvironment(state) end) end
function p.map(frame) return withState(frame,function(state) return renderMap(state) end) end
function p.itinerary(frame) return withState(frame,function(state) return renderItinerary(state) end) end
function p.chronicle(frame)
  return withState(frame,function(state,args) return renderDispatches(state,args.days or args.chronicle or 14) end)
end
function p.field(frame)
  return withState(frame,function(state,args)
    local key=tidy(args.field or args[1] or ''):lower()
    local fields={
      date=state.date.label, location=state.location.title,
      phase=state.phase, status=state.phaseDescription,
      assembly=state.location.assembled and 'Assembled / under reconstruction' or 'Not fully assembled',
      origin=SITES[state.location.origin].short,
      destination=SITES[state.location.destination].short,
      nextfestival=state.nextFestival.name,
      daysuntilfestival=tostring(state.daysUntilNext),
      attendance=tostring(state.metrics.attendance),
      market=tostring(state.metrics.market), trade=tostring(state.metrics.trade),
      water=tostring(state.metrics.water), routes=tostring(state.metrics.routes),
      stewardship=tostring(state.metrics.stewardship),
      source=state.location.originSource
    }
    if fields[key]==nil then return '<strong class="error">Unknown field: '..escape(key)..'</strong>' end
    return escape(fields[key])
  end)
end

-- Unrendered helper for testing or coordinated modules; not an #invoke endpoint.
-- Changes here do not store persistent state between page views.
p._state=buildState
p._dateFromDoy=dateFromDoy
p._currentLocation=currentLocation
p._phaseFor=phaseFor
p._festivalToday=festivalToday
return p