Career significator and house-lord rows now carry one presentation sentence. Values stay the same. A second three-repeat biography run still has one field conflict, so this is not accepted. Not pushed.
453 lines
18 KiB
TypeScript
453 lines
18 KiB
TypeScript
/**
|
|
* One judgment table per consult domain (TASK-consult-card-full-source-20261007).
|
|
*
|
|
* The Python facts module copies packet leaves. This file only lays those
|
|
* leaves into rows and columns. It does not round, translate, or decide that
|
|
* an affliction count means anything.
|
|
*/
|
|
|
|
type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
|
|
type JsonRecord = { [key: string]: Json };
|
|
|
|
export const JUDGMENT_TABLE_LABELS = {
|
|
rahuNote: "罗计不单独定功能吉凶",
|
|
precision: "出生时间精度不够,不参与",
|
|
relationLord: "主管",
|
|
relationOccupant: "落入",
|
|
relationAspect: "照",
|
|
relationConjunctLord: "与宫主同宫",
|
|
careerSignificatorReading: "这行只看力量与受冲,不按这颗星的本性写职业类型、做事方式或方向。",
|
|
} as const;
|
|
|
|
const CAREER_JAIMINI_ROLES = new Set(["AmK", "DK", "PK", "MK", "PiK"]);
|
|
|
|
/** Presentation only. Career significator rows keep their copied values. */
|
|
function careerSignificatorReading(domain: string, role: string | null): { reading: string } | Record<string, never> {
|
|
if (domain !== "career" || !role) return {};
|
|
const dasha = role.startsWith("Vimshottari");
|
|
const named = role === "natural" || role === "lord" || role === "Narayana MD lord" || CAREER_JAIMINI_ROLES.has(role);
|
|
if (!dasha && !named) return {};
|
|
return { reading: JUDGMENT_TABLE_LABELS.careerSignificatorReading };
|
|
}
|
|
|
|
const MALEFIC_BODIES = ["Saturn", "Mars", "Rahu", "Ketu"] as const;
|
|
const ASPECT_MALEFICS = ["Saturn", "Mars"] as const;
|
|
const DUSTHANA = new Set([6, 8, 12]);
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
}
|
|
|
|
function isJson(value: unknown): value is Json {
|
|
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
if (typeof value === "number") return Number.isFinite(value);
|
|
if (Array.isArray(value)) return value.every(isJson);
|
|
if (typeof value === "object") return Object.values(value as object).every(isJson);
|
|
return false;
|
|
}
|
|
|
|
function copyJson(value: unknown): Json | undefined {
|
|
if (!isJson(value)) return undefined;
|
|
return structuredClone(value);
|
|
}
|
|
|
|
function leaf(value: unknown): JsonRecord {
|
|
const row = record(value);
|
|
const out: JsonRecord = {};
|
|
if (typeof row.status === "string") out.status = row.status;
|
|
if (typeof row.path === "string") out.path = row.path;
|
|
if (typeof row.check === "string") out.check = row.check;
|
|
if (typeof row.note === "string") out.note = row.note;
|
|
if ("value" in row) {
|
|
const copied = copyJson(row.value);
|
|
if (copied !== undefined) out.value = copied;
|
|
else if (row.value === null) out.value = null;
|
|
}
|
|
if (typeof row.index === "number") out.index = row.index;
|
|
return out;
|
|
}
|
|
|
|
function leafValue(value: unknown): Json | undefined {
|
|
const row = leaf(value);
|
|
if (row.status !== "value" && row.status !== "withheld") return undefined;
|
|
return "value" in row ? row.value : undefined;
|
|
}
|
|
|
|
function textList(value: unknown): string[] {
|
|
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
}
|
|
|
|
function debilitated(status: unknown): boolean {
|
|
return typeof status === "string"
|
|
&& (status.includes("Debilitated") || status.includes("落陷"))
|
|
&& !status.includes("取消");
|
|
}
|
|
|
|
function houseCount(from: number, to: number): number {
|
|
return ((to - from + 12) % 12) + 1;
|
|
}
|
|
|
|
type Detail = { signal: number; via: string; value: Json };
|
|
|
|
function afflictionForPlanet(
|
|
planet: string,
|
|
planets: Record<string, unknown>,
|
|
houses: Record<string, unknown>,
|
|
role: string | undefined,
|
|
): JsonRecord {
|
|
const row = record(planets[planet]);
|
|
const details: Detail[] = [];
|
|
const unevaluated: string[] = [];
|
|
const coTenants = textList(leafValue(row.co_tenants));
|
|
const withMalefic = MALEFIC_BODIES.filter((name) => coTenants.includes(name));
|
|
if (withMalefic.length) details.push({ signal: 1, via: "co_tenants", value: withMalefic });
|
|
const aspectLeaf = leaf(row.aspected_by);
|
|
if (aspectLeaf.status !== "value") unevaluated.push("aspected_by");
|
|
else {
|
|
const by = ASPECT_MALEFICS.filter((name) => textList(aspectLeaf.value).includes(name));
|
|
if (by.length) details.push({ signal: 1, via: "aspected_by", value: by });
|
|
}
|
|
const house = leafValue(row.d1_house);
|
|
const isLord = role === "lord" || [...Object.values(houses)].some((item) => leafValue(record(item).lord) === planet);
|
|
if (isLord && typeof house === "number") {
|
|
const evidence: Json[] = [];
|
|
if (DUSTHANA.has(house)) evidence.push(house);
|
|
for (const number of Object.keys(houses)) {
|
|
const domainHouse = Number(number);
|
|
if (!Number.isInteger(domainHouse)) continue;
|
|
const count = houseCount(domainHouse, house);
|
|
if (DUSTHANA.has(count)) evidence.push([domainHouse, count]);
|
|
}
|
|
if (evidence.length) details.push({ signal: 2, via: "d1_house", value: evidence });
|
|
}
|
|
if (leafValue(row.functional_role) === "functional_malefics") {
|
|
details.push({ signal: 3, via: "functional_role", value: "functional_malefics" });
|
|
}
|
|
const dignity = leafValue(row.d1_dignity);
|
|
if (debilitated(dignity)) details.push({ signal: 4, via: "d1_dignity", value: dignity as Json });
|
|
if (leafValue(row.combust) === true) details.push({ signal: 4, via: "combust", value: true });
|
|
const varga = leaf(row.domain_varga_house);
|
|
if (varga.status === "withheld" || varga.status === "gap" || varga.status === undefined) {
|
|
unevaluated.push("domain_varga_house");
|
|
} else if (isLord && typeof varga.value === "number" && DUSTHANA.has(varga.value)) {
|
|
details.push({ signal: 5, via: "domain_varga_house", value: varga.value });
|
|
}
|
|
return {
|
|
count: details.length,
|
|
details: details as Json[],
|
|
unevaluated,
|
|
};
|
|
}
|
|
|
|
function afflictionForHouse(number: number, planets: Record<string, unknown>, houses: Record<string, unknown>): JsonRecord {
|
|
const occupants = textList(leafValue(record(houses[String(number)]).occupants));
|
|
const malefic = MALEFIC_BODIES.filter((name) => occupants.includes(name));
|
|
const details: Detail[] = malefic.length ? [{ signal: 1, via: "occupants", value: malefic }] : [];
|
|
const unevaluated = ["aspected_by"];
|
|
let vargaKnown = true;
|
|
for (const name of MALEFIC_BODIES) {
|
|
const varga = leaf(record(planets[name]).domain_varga_house);
|
|
if (varga.status !== "value") {
|
|
vargaKnown = false;
|
|
continue;
|
|
}
|
|
if (varga.value === number) details.push({ signal: 5, via: "domain_varga_house", value: [name, number] });
|
|
}
|
|
if (!vargaKnown) unevaluated.push("domain_varga_house");
|
|
return { count: details.length, details: details as Json[], unevaluated };
|
|
}
|
|
|
|
function numberList(value: unknown): number[] {
|
|
return Array.isArray(value) ? value.filter((item): item is number => typeof item === "number") : [];
|
|
}
|
|
|
|
function relationsFor(planet: string, planets: Record<string, unknown>, houses: Record<string, unknown>): Json[] {
|
|
const house = leafValue(record(planets[planet]).d1_house);
|
|
const cast = new Set(numberList(leafValue(record(planets[planet]).aspects_houses)));
|
|
const rows: Json[] = [];
|
|
for (const [number, item] of Object.entries(houses)) {
|
|
const domainHouse = Number(number);
|
|
const lord = leafValue(record(item).lord);
|
|
const labels: string[] = [];
|
|
if (lord === planet) labels.push(JUDGMENT_TABLE_LABELS.relationLord);
|
|
if (typeof house === "number" && house === domainHouse) labels.push(JUDGMENT_TABLE_LABELS.relationOccupant);
|
|
if (cast.has(domainHouse)) labels.push(JUDGMENT_TABLE_LABELS.relationAspect);
|
|
if (typeof lord === "string" && lord !== planet && typeof house === "number") {
|
|
const lordHouse = leafValue(record(planets[lord]).d1_house);
|
|
if (lordHouse === house) labels.push(JUDGMENT_TABLE_LABELS.relationConjunctLord);
|
|
}
|
|
rows.push({ house: domainHouse, labels });
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function aspectKnown(row: Record<string, unknown>): boolean {
|
|
return leaf(row.aspected_by).status === "value" || leaf(row.aspects_houses).status === "value";
|
|
}
|
|
|
|
function vargottama(planetRow: Record<string, unknown>): JsonRecord {
|
|
const d1 = leaf(planetRow.d1_sign);
|
|
const d9 = leaf(planetRow.d9_sign);
|
|
if (d1.status !== "value" || d9.status !== "value" || typeof d1.value !== "string" || typeof d9.value !== "string") {
|
|
return { status: "gap", check: "derived" };
|
|
}
|
|
return {
|
|
status: "derived",
|
|
value: d1.value === d9.value,
|
|
derived_from: [d1.path ?? "", d9.path ?? ""],
|
|
check: "derived",
|
|
};
|
|
}
|
|
|
|
function savOf(numbers: number[], houses: Record<string, unknown>): Json[] {
|
|
return numbers.map((number) => {
|
|
const house = record(houses[String(number)]);
|
|
return {
|
|
house: number,
|
|
sav_score: leaf(house.sav_score),
|
|
sav_sodhita: leaf(house.sav_sodhita),
|
|
};
|
|
});
|
|
}
|
|
|
|
function coTenantRoles(planet: string, planets: Record<string, unknown>): Json[] {
|
|
const names = textList(leafValue(record(planets[planet]).co_tenants));
|
|
return names.map((name) => ({
|
|
planet: name,
|
|
functional_role: leaf(record(planets[name]).functional_role),
|
|
}));
|
|
}
|
|
|
|
function planetColumns(
|
|
planet: string,
|
|
planets: Record<string, unknown>,
|
|
houses: Record<string, unknown>,
|
|
role: string | undefined,
|
|
): JsonRecord {
|
|
const row = record(planets[planet]);
|
|
if (row.present === false || Object.keys(row).length === 0) {
|
|
return { planet, status: "gap" };
|
|
}
|
|
const domainHouses = Object.keys(houses).map(Number).filter((number) => Number.isInteger(number));
|
|
return {
|
|
planet,
|
|
d1_sign: leaf(row.d1_sign),
|
|
d1_house: leaf(row.d1_house),
|
|
d1_dignity: leaf(row.d1_dignity),
|
|
functional_role: leaf(row.functional_role),
|
|
rasi_lord: leaf(row.rasi_lord),
|
|
co_tenants: leaf(row.co_tenants),
|
|
co_tenant_roles: coTenantRoles(planet, planets),
|
|
shadbala_rupas: leaf(row.shadbala_rupas),
|
|
shadbala_min: leaf(row.shadbala_min),
|
|
bav: leaf(row.bav),
|
|
sav: savOf(domainHouses, houses),
|
|
affliction: afflictionForPlanet(planet, planets, houses, role),
|
|
d9_sign: leaf(row.d9_sign),
|
|
d9_dignity: leaf(row.d9_dignity),
|
|
vargottama: vargottama(row),
|
|
domain_varga_house: leaf(row.domain_varga_house),
|
|
aspected_by: leaf(row.aspected_by),
|
|
aspects_houses: leaf(row.aspects_houses),
|
|
relations: relationsFor(planet, planets, houses),
|
|
relation_gaps: aspectKnown(row) ? [] : [JUDGMENT_TABLE_LABELS.relationAspect],
|
|
};
|
|
}
|
|
|
|
/** Assemble one domain table from copied facts. Missing leaves stay gaps. */
|
|
export function assembleJudgmentTable(facts: unknown): JsonRecord {
|
|
const body = record(facts);
|
|
const planets = record(body.planets);
|
|
const houses = record(body.houses);
|
|
const domain = typeof body.domain === "string" ? body.domain : "";
|
|
const rows: Json[] = [];
|
|
const factors = Array.isArray(body.factors) ? body.factors : [];
|
|
for (const factor of factors) {
|
|
const item = record(factor);
|
|
const id = typeof item.id === "string" ? item.id : "factor";
|
|
const citation = typeof item.citation === "string" ? item.citation : "";
|
|
if (item.kind === "house" && typeof item.house === "number") {
|
|
const house = record(houses[String(item.house)]);
|
|
rows.push({
|
|
id,
|
|
kind: "house",
|
|
citation,
|
|
house: item.house,
|
|
sign: leaf(house.sign),
|
|
lord: leaf(house.lord),
|
|
occupants: leaf(house.occupants),
|
|
sav: savOf([item.house], houses)[0] ?? null,
|
|
affliction: afflictionForHouse(item.house, planets, houses),
|
|
aspected_by: leaf(house.aspected_by),
|
|
relation_gaps: leaf(house.aspected_by).status === "value" ? [] : [JUDGMENT_TABLE_LABELS.relationAspect],
|
|
});
|
|
const lord = leafValue(house.lord);
|
|
if (typeof lord === "string") {
|
|
rows.push({
|
|
id: `${id}:lord`,
|
|
kind: "planet",
|
|
role: "lord",
|
|
citation,
|
|
...planetColumns(lord, planets, houses, "lord"),
|
|
...careerSignificatorReading(domain, "lord"),
|
|
});
|
|
} else {
|
|
rows.push({ id: `${id}:lord`, kind: "gap", citation, path: leaf(house.lord).path ?? null });
|
|
}
|
|
continue;
|
|
}
|
|
if (item.kind === "planet" && typeof item.planet === "string") {
|
|
const role = typeof item.role === "string" ? item.role : null;
|
|
rows.push({
|
|
id,
|
|
kind: "planet",
|
|
role,
|
|
citation,
|
|
...planetColumns(item.planet, planets, houses, role ?? undefined),
|
|
...careerSignificatorReading(domain, role),
|
|
});
|
|
continue;
|
|
}
|
|
if (item.kind === "sign") {
|
|
rows.push({
|
|
id,
|
|
kind: "sign",
|
|
citation,
|
|
sign: leaf(item.sign),
|
|
lord: leaf(item.lord),
|
|
affliction: { count: null, details: [], unevaluated: ["sign_row"] },
|
|
});
|
|
continue;
|
|
}
|
|
rows.push({
|
|
id,
|
|
kind: typeof item.kind === "string" ? item.kind : "gap",
|
|
citation,
|
|
...(typeof item.path === "string" ? { path: item.path } : {}),
|
|
});
|
|
}
|
|
return {
|
|
header_note: typeof body.header_note === "string" ? body.header_note : JUDGMENT_TABLE_LABELS.rahuNote,
|
|
domain,
|
|
domain_varga: typeof body.domain_varga === "string" ? body.domain_varga : "",
|
|
rows,
|
|
};
|
|
}
|
|
|
|
const MACHINE_KEYS = new Set(["path", "check", "derived_from", "member", "index"]);
|
|
|
|
function isLeafRecord(value: Record<string, unknown>): boolean {
|
|
return typeof value.status === "string" && ("path" in value || "check" in value || "value" in value || value.status === "gap" || value.status === "blocked");
|
|
}
|
|
|
|
function filledRelations(value: Json | undefined): Json[] {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.filter((item) => Array.isArray((item as JsonRecord | null)?.labels) && (item as JsonRecord).labels && ((item as JsonRecord).labels as Json[]).length > 0);
|
|
}
|
|
|
|
function housesWithSav(rows: Json[]): Set<number> {
|
|
const found = new Set<number>();
|
|
for (const item of rows) {
|
|
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
const sav = (item as JsonRecord).sav;
|
|
if ((item as JsonRecord).kind !== "house" || !sav || typeof sav !== "object" || Array.isArray(sav)) continue;
|
|
const house = (sav as JsonRecord).house;
|
|
if (typeof house === "number") found.add(house);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
function savAlreadyOnHouseRows(sav: Json | undefined, covered: Set<number>): boolean {
|
|
if (!Array.isArray(sav) || sav.length === 0) return false;
|
|
return sav.every((item) => {
|
|
if (!item || typeof item !== "object" || Array.isArray(item)) return false;
|
|
const house = (item as JsonRecord).house;
|
|
return typeof house === "number" && covered.has(house);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The server table repeats one planet's columns on every factor row.
|
|
* The model keeps that planet's values once. A later row keeps the role
|
|
* and the affliction count, which can change with the role. SAV stays on
|
|
* the house row when that row already carries it.
|
|
*/
|
|
function compactPlanetRows(rows: Json[]): Json[] {
|
|
const covered = housesWithSav(rows);
|
|
const seen = new Set<string>();
|
|
return rows.map((item) => {
|
|
if (!item || typeof item !== "object" || Array.isArray(item)) return item;
|
|
const row = { ...(item as JsonRecord) };
|
|
if (row.kind !== "planet" || typeof row.planet !== "string") return row;
|
|
const relations = filledRelations(row.relations as Json | undefined);
|
|
if (!seen.has(row.planet)) {
|
|
seen.add(row.planet);
|
|
if (savAlreadyOnHouseRows(row.sav as Json | undefined, covered)) delete row.sav;
|
|
if (relations.length) row.relations = relations;
|
|
else delete row.relations;
|
|
return row;
|
|
}
|
|
const repeat: JsonRecord = { id: row.id, kind: row.kind, planet: row.planet };
|
|
if (typeof row.role === "string") repeat.role = row.role;
|
|
if (typeof row.citation === "string") repeat.citation = row.citation;
|
|
if (typeof row.reading === "string") repeat.reading = row.reading;
|
|
if (row.affliction !== undefined) repeat.affliction = row.affliction;
|
|
return repeat;
|
|
});
|
|
}
|
|
|
|
/** Values for the model. Internal paths and the check field stay off this view. */
|
|
export function judgmentTableModelView(value: unknown): Json {
|
|
const slimmed = slimModel(value);
|
|
if (!slimmed || typeof slimmed !== "object" || Array.isArray(slimmed)) return slimmed;
|
|
const body = slimmed as JsonRecord;
|
|
if (!Array.isArray(body.rows)) return slimmed;
|
|
return { ...body, rows: compactPlanetRows(body.rows) };
|
|
}
|
|
|
|
export function participationModelView(value: unknown): Json {
|
|
if (!Array.isArray(value)) return [];
|
|
const rows: Json[] = [];
|
|
for (const item of value) {
|
|
const row = record(item);
|
|
const out: JsonRecord = {};
|
|
if (typeof row.id === "string") out.id = row.id;
|
|
if (row.status === "value" && "value" in row) {
|
|
const copied = copyJson(row.value);
|
|
out.value = copied === undefined ? null : copied;
|
|
if (typeof row.citation === "string") out.citation = row.citation;
|
|
} else if (row.status === "withheld" && typeof row.value === "string") {
|
|
out.value = row.value;
|
|
if (typeof row.citation === "string") out.citation = row.citation;
|
|
} else if (row.status === "gap" || row.status === "blocked") {
|
|
out.status = row.status;
|
|
} else {
|
|
continue;
|
|
}
|
|
rows.push(out);
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function slimModel(value: unknown): Json {
|
|
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
if (Array.isArray(value)) return value.map((item) => slimModel(item));
|
|
if (!value || typeof value !== "object") return null;
|
|
const row = value as Record<string, unknown>;
|
|
if (isLeafRecord(row)) {
|
|
if ((row.status === "value" || row.status === "derived") && "value" in row) {
|
|
const copied = copyJson(row.value);
|
|
return copied === undefined ? null : slimModel(copied);
|
|
}
|
|
if (row.status === "withheld") return typeof row.value === "string" ? row.value : "withheld";
|
|
if (row.status === "gap" || row.status === "blocked" || row.status === "value") return row.status;
|
|
}
|
|
const out: JsonRecord = {};
|
|
for (const [key, item] of Object.entries(row)) {
|
|
if (MACHINE_KEYS.has(key)) continue;
|
|
out[key] = slimModel(item);
|
|
}
|
|
return out;
|
|
}
|