y
This commit is contained in:
parent
61718e2981
commit
f657b6a4a4
94 changed files with 3970 additions and 1241 deletions
|
|
@ -113,17 +113,35 @@ const expectedIds = [
|
|||
"park_ground_changed", "ground_change_4_in_1_second",
|
||||
"ant_nest_without_tarinai", "sticky_bomb_15_passes",
|
||||
"daily_play_7_days", "wire_shock_7_tarinai", "information_industry",
|
||||
"strength_in_numbers", "elite_few", "zunchi_overflow", "comfortable_beds",
|
||||
"stone_pillow", "across_seasons", "favorite_one", "statistician",
|
||||
"well_informed", "lively_making", "memento_mori",
|
||||
];
|
||||
|
||||
(async function run() {
|
||||
let h = createHarness();
|
||||
let storage;
|
||||
assert(h.api.definitions.length === 64, "definition count must be 64");
|
||||
assert(h.api.definitions.length === 75, "definition count must be 75");
|
||||
{
|
||||
const spellHarness = createHarness();
|
||||
spellHarness.api.unlock("statistician");
|
||||
const spell = spellHarness.api.exportSpellState();
|
||||
assert(spell[0] === 8, "current achievement spell version must be 8");
|
||||
assert(spell[3] === 128, "achievement 72 is not stored in the extended mask");
|
||||
spellHarness.api.unlock("memento_mori");
|
||||
const extendedSpell = spellHarness.api.exportSpellState();
|
||||
assert((extendedSpell[3] & 0x0400) !== 0, "achievement 75 is not stored in the second extended mask byte");
|
||||
const oldSpellHarness = createHarness();
|
||||
const imported = oldSpellHarness.api.importSpellState([7, 1, 0, 0, 100, [0], []]);
|
||||
assert(!imported.included && !oldSpellHarness.api.isUnlocked("first_birth"), "obsolete achievement spell version was accepted");
|
||||
}
|
||||
assert(JSON.stringify(h.api.definitions.map(d => d.id)) === JSON.stringify(expectedIds), "definition IDs mismatch");
|
||||
const categorizedIds = h.api.categories.flatMap(category => category.ids);
|
||||
assert(categorizedIds.length === expectedIds.length, "achievement category count mismatch");
|
||||
assert(new Set(categorizedIds).size === expectedIds.length, "achievement category contains duplicates");
|
||||
assert(expectedIds.every(id => categorizedIds.includes(id)), "achievement category misses an ID");
|
||||
assert(h.api.categories.length === 6, "achievement categories must be reorganized into six groups");
|
||||
assert(JSON.stringify(h.api.categories.map(category => category.title)) === JSON.stringify(["生態", "繁殖・人口", "実験", "建築", "操作", "その他"]), "achievement category titles/order mismatch");
|
||||
h.api.open();
|
||||
const titles = Object.fromEntries(h.api.definitions.map(def => [def.id, def.title]));
|
||||
assert(titles.natural_zunchi_slave === "ずんちどれい", "slave title mismatch");
|
||||
|
|
@ -179,7 +197,7 @@ const expectedIds = [
|
|||
assert(descriptions.sniper_333_shots.includes("333発"), "sniper description mismatch");
|
||||
assert(descriptions.megalopolis.includes("10個") && descriptions.megalopolis.includes("15個") && descriptions.megalopolis.includes("100体"), "megalopolis description mismatch");
|
||||
assert(descriptions.fertility_seeker_721_love_births.includes("へこ餅") && descriptions.fertility_seeker_721_love_births.includes("721回"), "fertility description mismatch");
|
||||
assert(descriptions.idle_observer_5_minutes === "5分間画面を見るだけ", "observer description mismatch");
|
||||
assert(descriptions.idle_observer_5_minutes === "3ゲーム日、操作せず画面を見るだけ", "observer description mismatch");
|
||||
assert(descriptions.safe_colony_25_5_minutes.includes("寿命以外で"), "safe-colony lifespan exclusion description mismatch");
|
||||
assert(descriptions.pause_spam_4_in_1_second.includes("何もない部分"), "empty-click description mismatch");
|
||||
assert(descriptions.low_fps_single_digit === "fpsを1桁にする。", "low-fps description mismatch");
|
||||
|
|
@ -200,20 +218,26 @@ const expectedIds = [
|
|||
const eternalHistoryCard = cards.find(node => node.dataset.achievementId === "eternal_history_generation_10");
|
||||
const lowFpsCard = cards.find(node => node.dataset.achievementId === "low_fps_single_digit");
|
||||
const sleepCard = cards.find(node => node.dataset.achievementId === "continuous_play_24_hours");
|
||||
const livelyCard = cards.find(node => node.dataset.achievementId === "lively_making");
|
||||
const hiddenCard = cards.find(node => node.dataset.achievementId === "self_zunchi_death");
|
||||
assert(firstBirthCard.children[1].children[0].children[1].textContent === descriptions.first_birth, "disclosed locked condition is not shown in the UI");
|
||||
for (const card of [disclosedSlaveCard, disclosedKingCard, disclosedRevolutionCard, disclosedLaxativeCard, eternalHistoryCard, lowFpsCard, sleepCard]) {
|
||||
for (const card of [disclosedSlaveCard, disclosedKingCard, disclosedRevolutionCard, disclosedLaxativeCard, eternalHistoryCard, lowFpsCard, sleepCard, livelyCard]) {
|
||||
assert(card.children[1].children[0].children[1].textContent !== "???", "requested pre-unlock condition is hidden");
|
||||
}
|
||||
assert(hiddenCard.children[1].children[0].children[1].textContent === "???", "undisclosed locked condition is not hidden as question marks");
|
||||
const groups = h.elements.get("achievementList").children;
|
||||
assert(groups.length === 4, "achievement UI must contain exactly four category groups");
|
||||
assert(JSON.stringify(groups.map(group => group.dataset.achievementGroup)) === JSON.stringify(["ecology", "experiment", "construction", "operation"]), "achievement category order mismatch");
|
||||
assert(groups.length === 6, "achievement UI must contain exactly six category groups");
|
||||
assert(JSON.stringify(groups.map(group => group.dataset.achievementGroup)) === JSON.stringify(["ecology", "population", "experiment", "construction", "operation", "other"]), "achievement category order mismatch");
|
||||
assert(groups.every(group => group.tagName === "DETAILS"), "achievement categories are not collapsible details");
|
||||
const categoryTitles = h.api.categories.map(category => category.title);
|
||||
assert(JSON.stringify(categoryTitles) === JSON.stringify(["生態", "実験", "建築", "その他"]), "achievement category titles mismatch");
|
||||
assert(JSON.stringify(categoryTitles) === JSON.stringify(["生態", "繁殖・人口", "実験", "建築", "操作", "その他"]), "achievement category titles mismatch");
|
||||
const operationIds = h.api.categories.find(category => category.id === "operation").ids;
|
||||
assert(JSON.stringify(operationIds.slice(-2)) === JSON.stringify(["continuous_play_1_hour", "true_tarinai_observer"]), "observation achievements are not at the bottom of その他");
|
||||
const otherIds = h.api.categories.find(category => category.id === "other").ids;
|
||||
const populationIds = h.api.categories.find(category => category.id === "population").ids;
|
||||
assert(otherIds.at(-1) === "true_tarinai_observer", "completionist is not at the bottom of その他");
|
||||
assert(otherIds.includes("across_seasons"), "across_seasons is not in その他");
|
||||
assert(operationIds.includes("statistician") && operationIds.includes("well_informed"), "requested 操作 achievements are missing");
|
||||
assert(populationIds.includes("lively_making") && populationIds.includes("memento_mori"), "population achievements are missing from 繁殖・人口");
|
||||
h.api.unlock("natural_zunchi_slave");
|
||||
const rerenderedGroups = h.elements.get("achievementList").children;
|
||||
const ecologyCards = walk(rerenderedGroups[0]).filter(node => node.dataset?.achievementId);
|
||||
|
|
@ -262,16 +286,21 @@ const expectedIds = [
|
|||
assert(unlocked(h, "laxative_starvation"), "laxative starvation did not unlock");
|
||||
|
||||
h = createHarness();
|
||||
const slaveWorld = {};
|
||||
[1, 2, 4, 5, 6].forEach(generation => h.api.recordNaturalZunchiSlave({ world: slaveWorld, tarinai: { generation } }));
|
||||
assert(!unlocked(h, "natural_zunchi_slave_5_generations"), "slave run ignored generation gap");
|
||||
h.api.recordNaturalZunchiSlave({ world: slaveWorld, tarinai: { generation: 3 } });
|
||||
assert(unlocked(h, "natural_zunchi_slave_5_generations"), "slave five-generation run did not unlock");
|
||||
const slaveWorld = { tarinai: [] };
|
||||
let parent = { id: "s1", parents: [], generation: 1 }; slaveWorld.tarinai.push(parent); h.api.recordNaturalZunchiSlave({ world: slaveWorld, tarinai: parent });
|
||||
for (let i = 2; i <= 5; i += 1) { const child = { id: `s${i}`, parents: [parent.id], generation: i }; slaveWorld.tarinai.push(child); h.api.recordNaturalZunchiSlave({ world: slaveWorld, tarinai: child }); parent = child; }
|
||||
assert(unlocked(h, "natural_zunchi_slave_5_generations"), "direct slave lineage did not unlock");
|
||||
|
||||
h = createHarness();
|
||||
const kingWorld = {};
|
||||
[4, 5, 6].forEach(generation => h.api.recordNaturalTarinaiKing({ world: kingWorld, tarinai: { generation } }));
|
||||
assert(unlocked(h, "natural_tarinai_king_3_generations"), "king three-generation run did not unlock");
|
||||
const kingWorld = { tarinai: [] };
|
||||
let kingParent = { id: "k1", parents: [], generation: 4 }; kingWorld.tarinai.push(kingParent); h.api.recordNaturalTarinaiKing({ world: kingWorld, tarinai: kingParent });
|
||||
for (let i = 2; i <= 3; i += 1) { const child = { id: `k${i}`, parents: [kingParent.id], generation: 3 + i }; kingWorld.tarinai.push(child); h.api.recordNaturalTarinaiKing({ world: kingWorld, tarinai: child }); kingParent = child; }
|
||||
assert(unlocked(h, "natural_tarinai_king_3_generations"), "direct king lineage did not unlock");
|
||||
|
||||
h = createHarness();
|
||||
const unrelatedWorld = { tarinai: [] };
|
||||
for (let i = 1; i <= 5; i += 1) { const t = { id: `u${i}`, parents: [], generation: i }; unrelatedWorld.tarinai.push(t); h.api.recordNaturalZunchiSlave({ world: unrelatedWorld, tarinai: t }); }
|
||||
assert(!unlocked(h, "natural_zunchi_slave_5_generations"), "unrelated generations incorrectly counted as a lineage");
|
||||
|
||||
h = createHarness();
|
||||
const feedWorldA = { time: 0, achievementDirectFeedCount: 0 };
|
||||
|
|
@ -390,7 +419,7 @@ const expectedIds = [
|
|||
assert(unlocked(h, "true_tarinai_observer"), "completionist did not unlock with every other achievement");
|
||||
|
||||
h = createHarness();
|
||||
const idleWorld = { time: 300, tarinai: [], ants: [], achievementLastInterventionAt: 0, achievementNoDeathStartAt: -1 };
|
||||
const idleWorld = { time: 360, tarinai: [], ants: [], achievementLastInterventionAt: 0, achievementNoDeathStartAt: -1 };
|
||||
h.api.evaluateWorld(idleWorld, { id: "relaxed" });
|
||||
assert(unlocked(h, "idle_observer_5_minutes"), "idle observer did not unlock");
|
||||
|
||||
|
|
@ -444,12 +473,12 @@ const expectedIds = [
|
|||
|
||||
storage = new Map();
|
||||
h = createHarness(storage);
|
||||
for (let i = 0; i < 65; i += 1) h.api.recordAntKilled({ world: {} });
|
||||
for (let i = 0; i < 65; i += 1) h.api.recordAntKilled({ world: {}, playerCaused: true });
|
||||
assert(!unlocked(h, "ants_killed_100"), "ant extermination unlocked before 100");
|
||||
h = createHarness(storage);
|
||||
for (let i = 0; i < 34; i += 1) h.api.recordAntKilled({ world: {} });
|
||||
for (let i = 0; i < 34; i += 1) h.api.recordAntKilled({ world: {}, playerCaused: true });
|
||||
assert(!unlocked(h, "ants_killed_100"), "ant extermination unlocked at 99");
|
||||
h.api.recordAntKilled({ world: {} });
|
||||
h.api.recordAntKilled({ world: {}, playerCaused: true });
|
||||
assert(unlocked(h, "ants_killed_100"), "ant extermination did not persist to 100");
|
||||
|
||||
|
||||
|
|
@ -668,7 +697,17 @@ const expectedIds = [
|
|||
h.api.evaluateCleanFreak(cleanWorld);
|
||||
assert(unlocked(h, "clean_freak_robot_only"), "clean freak did not unlock with all settings and robot-only field");
|
||||
|
||||
console.log("[OK] all 64 achievement definitions, categorized UI, inline disclosures, and recovery synchronization passed");
|
||||
|
||||
h = createHarness();
|
||||
const livelyWorldA = { time: 0, tarinai: [] };
|
||||
for (let i = 0; i < 60; i += 1) h.api.recordManualTarinaiAdded({ world: livelyWorldA });
|
||||
h.api.resetWorldProgress(livelyWorldA);
|
||||
const livelyWorldB = { time: 0, tarinai: [] };
|
||||
for (let i = 0; i < 39; i += 1) h.api.recordManualTarinaiAdded({ world: livelyWorldB });
|
||||
assert(!unlocked(h, "lively_making"), "lively making unlocked before 100 additions across resets");
|
||||
h.api.recordManualTarinaiAdded({ world: livelyWorldB });
|
||||
assert(unlocked(h, "lively_making"), "lively making did not persist across field reset");
|
||||
console.log("[OK] all 75 achievement definitions, categorized UI, inline disclosures, and recovery synchronization passed");
|
||||
})().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
|
|
|
|||
|
|
@ -17,14 +17,14 @@ css = read('css/components.css')
|
|||
|
||||
ids_js = re.findall(r'id: "([a-z0-9_]+)"', ach.split('const DEFINITIONS',1)[1].split(']);',1)[0])
|
||||
ids_php = re.findall(r"'([a-z0-9_]+)'", api.split('const ACHIEVEMENT_IDS',1)[1].split('];',1)[0])
|
||||
require(len(ids_js) == 64, 'JS achievement count is not 64')
|
||||
require(len(ids_js) == 75, 'JS achievement count is not 75')
|
||||
require(ids_js == ids_php, 'JS/PHP achievement IDs are not synchronized')
|
||||
require('signal_automation_first' not in ids_js and 'link_craftsman' not in ids_js, 'retired achievements remain defined')
|
||||
require('mechanized_industry' in ids_js, 'mechanized industry is missing')
|
||||
require('placed_objects_100' in ids_js and 'ants_killed_100' in ids_js, '100-count achievements are missing')
|
||||
require('great_mother_1000_births' in ids_js and 'fight_pair_danger_kill' in ids_js, 'previous new achievements are missing')
|
||||
require(all(item in ids_js for item in ['secret_collection_9_slots','pause_spam_4_in_1_second','continuous_play_1_hour','below_absolute_zero_item']), 'previous achievement IDs are missing')
|
||||
require(all(item in ids_js for item in ['eternal_history_generation_10','king_full_satisfaction','slave_zero_satisfaction','town_doctor_50','poke_plushie_fling','chaos_seeker_666_fights','clean_freak_robot_only','true_tarinai_observer','sniper_333_shots','megalopolis','fertility_seeker_721_love_births','low_fps_single_digit','continuous_play_24_hours','sandbox_five_toilets','park_ground_changed','ground_change_4_in_1_second','ant_nest_without_tarinai','sticky_bomb_15_passes','daily_play_7_days','wire_shock_7_tarinai']), 'latest achievement IDs are missing')
|
||||
require(all(item in ids_js for item in ['eternal_history_generation_10','king_full_satisfaction','slave_zero_satisfaction','town_doctor_50','poke_plushie_fling','chaos_seeker_666_fights','clean_freak_robot_only','true_tarinai_observer','sniper_333_shots','megalopolis','fertility_seeker_721_love_births','low_fps_single_digit','continuous_play_24_hours','sandbox_five_toilets','park_ground_changed','ground_change_4_in_1_second','ant_nest_without_tarinai','sticky_bomb_15_passes','daily_play_7_days','wire_shock_7_tarinai','well_informed','lively_making','memento_mori']), 'latest achievement IDs are missing')
|
||||
require(all(item in ids_js for item in ['undo_mass_revival','robot_cleaner_100','held_30_seconds','minimalist_happy','overprotective','self_sufficient','unplanned_city_30','sauna_cold_plunge','rain_shelter_all','medicine_ledger_all','mercury_lifespan','enemy_enemy_friend','revolution','fuel_to_fire','undo_20','redo_20']), 'playstyle achievement IDs are missing')
|
||||
|
||||
checks = {
|
||||
|
|
@ -72,7 +72,7 @@ checks = {
|
|||
'low fps heartbeat': 'recordLowFps' in ach and '__tarinaiFps' in ach,
|
||||
'toilet-sand field count': 'evaluateSandbox' in ach and 'item.type === "toilet"' in ach,
|
||||
'ground-change hooks': 'recordGroundChange' in ach and 'recordGroundChange?.' in read('js/world_view.js'),
|
||||
'idle observer timer': 'now - lastInterventionAt >= 300' in ach,
|
||||
'idle observer timer': 'observerTarget' in ach and 'OBSERVER_GAME_DAYS = 3' in ach,
|
||||
'server legacy AND migration': "unset($unlocks['signal_automation_first'], $unlocks['link_craftsman'])" in api and "mechanized_industry" in api,
|
||||
'automatic aggregate recovery sync': 'localUnlockSnapshot' in ach and 'action: "sync"' in ach and "['session', 'unlock', 'sync', 'reset', 'summary']" in api and "$action === 'sync'" in api,
|
||||
'save-slot collection hook': 'recordSaveSlotsFilled' in ach and 'recordSaveSlotsFilled?.' in read('js/save_storage.js'),
|
||||
|
|
@ -81,7 +81,7 @@ checks = {
|
|||
'continuous play survives tab switches': 'if (document.hidden) continuousPlayStartedAt' not in ach and 'continuousPlayStartedAt = Date.now();\n continuousPlayLastHeartbeatAt' not in ach.split('document.addEventListener("visibilitychange"',1)[-1],
|
||||
'ant observation evaluator': 'evaluateAntNestWithoutTarinai' in ach and 'item.type === "ant_nest"' in ach,
|
||||
'sticky bomb relay hook and persistence': 'recordStickyBombPass' in ach and 'recordStickyBombPass' in read('js/item_dynamic_tool_system.js') and 'stickyBombPassCount' in read('js/snapshot_system.js') and '[0, -1, 0], 3' in read('js/save_codec.js'),
|
||||
'current-only achievement spell': 'const SPELL_STATE_VERSION = 5;' in ach and 'Number(payload[0]) !== SPELL_STATE_VERSION' in ach and 'schema !== BINARY_SCHEMA_VERSION' in read('js/save_codec.js'),
|
||||
'current-only achievement and save format': 'const SPELL_STATE_VERSION = 8;' in ach and 'PREVIOUS_SPELL_STATE_VERSION' not in ach and 'version !== SPELL_STATE_VERSION' in ach and 'schema !== BINARY_SCHEMA_VERSION' in read('js/save_codec.js') and 'schema !== 52' not in read('js/save_codec.js'),
|
||||
'progress text weight is stable': 'font-weight: 500;' in read('css/components.css').split('.achievement-entry-progress',1)[1].split('}',1)[0],
|
||||
'sauna description matches evaluator': r'15\u79d2\u4ee5\u5185\u306b\u5bd2\u3059\u304e\u308b' in ach,
|
||||
'enemy streak resets on dangerous damage': 'world.achievementEnemyAntKills = 0;' in ach,
|
||||
|
|
@ -107,7 +107,7 @@ checks = {
|
|||
'managed paradise starts day six': 'day >= 6' in ach and 'now - happyStart >= dayLength' in ach,
|
||||
'compact server state v3': 'function compact_state' in api and 'return [STATE_SCHEMA_VERSION, $baseTime, $versions, $players, $unlocks];' in api and 'compact_player_id' in api,
|
||||
'spell omits completed counters': 'state.unlocked.sniper_333_shots ? 0' in ach and 'state.unlocked.fertility_seeker_721_love_births ? 0' in ach and 'state.unlocked.chaos_seeker_666_fights ? 0' in ach,
|
||||
'old generic fight progress is not migrated': 'progressSource.fightCount' not in ach and 'progressVersion === SPELL_STATE_VERSION' in ach,
|
||||
'obsolete spell versions are rejected': 'const SPELL_STATE_VERSION = 8;' in ach and 'LEGACY_SPELL_STATE_VERSION' not in ach and 'PREVIOUS_SPELL_STATE_VERSION' not in ach,
|
||||
'stale completionist server record is revocable': 'completionistEligible' in ach and "unset($state['unlocks']['true_tarinai_observer'][$playerId])" in api,
|
||||
'server corruption is non-destructive': 'JSON_THROW_ON_ERROR' in api and "throw new RuntimeException('storage_corrupt')" in api and 'atomic_write_state' in api,
|
||||
'server writes use a separate lock': "LOCK_FILE_NAME = 'state.lock'" in api and 'flock($lockHandle, LOCK_EX)' in api and '@rename($tempPath, $dataPath)' in api,
|
||||
|
|
@ -122,7 +122,6 @@ social = read('js/world_family_social.js') + read('js/tarinai_social_action_runt
|
|||
require('!!a.isZunchiSlave !== !!b.isZunchiSlave' in social or '!!t.isZunchiSlave === !!other.isZunchiSlave' in social, 'slave mating rule not found')
|
||||
require('recordNaturalGeneration(world, tarinai, "achievementNaturalSlaveGenerations", 5)' in ach, 'slave achievement still uses impossible direct-line logic')
|
||||
require('\\u52dd\\u738730%\\u4ee5\\u4e0b' in read('js/world_combat_effects.js'), 'slave fight-record text does not state the 30% threshold')
|
||||
require('legacyLinkTypes' in ach, 'legacy link-craftsman partial migration is missing')
|
||||
|
||||
print('[OK] achievement integration audit passed')
|
||||
for name in checks:
|
||||
|
|
|
|||
47
scripts/achievement_save_v6_audit.js
Normal file
47
scripts/achievement_save_v6_audit.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use strict";
|
||||
|
||||
global.window = global;
|
||||
global.TarinaiSnapshot = { version: 40 };
|
||||
global.TarinaiSaveSchema = {
|
||||
BINARY_SCHEMA_VERSION: 53,
|
||||
FIELD_IDS: ["garden"],
|
||||
itemTypeValue(_index, fallback = "") { return fallback; },
|
||||
};
|
||||
|
||||
require("../js/save_codec.js");
|
||||
|
||||
// first_birth + statistician + memento_mori. v8 also stores persistent lively-making progress.
|
||||
// Achievements 65-75 remain in the extended mask value at spell[3].
|
||||
const progress = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 29, 9999, 99];
|
||||
const spell = [8, 1, 0, 1152, 100, [0, 1, 2], progress];
|
||||
const snapshot = {
|
||||
v: 40,
|
||||
a: "tj1",
|
||||
m: [1, 0, "garden", 0],
|
||||
w: [0, 0, 0, 0, 0, -9990, 1, 0, "w0000000000000000", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
t: [],
|
||||
i: [],
|
||||
g: {
|
||||
w: [[], [], 0, 0, 0, null, -1, null, 0, -1, -1],
|
||||
t: [],
|
||||
i: [],
|
||||
s: spell,
|
||||
},
|
||||
};
|
||||
|
||||
const bytes = global.TarinaiSaveCodec.encodeBinarySnapshot(snapshot);
|
||||
const decoded = global.TarinaiSaveCodec.decodeBinarySnapshot(bytes);
|
||||
const restored = decoded?.g?.s;
|
||||
if (!Array.isArray(restored) || restored[0] !== 8) throw new Error("achievement spell v8 was not restored");
|
||||
if (restored[3] !== 1152) throw new Error("extended achievement mask bytes were lost");
|
||||
if (restored[6]?.[14] !== 29) throw new Error("statistician progress was lost");
|
||||
if (restored[6]?.[15] !== 9999) throw new Error("memento mori progress was lost");
|
||||
if (restored[6]?.[16] !== 99) throw new Error("lively making progress was lost");
|
||||
|
||||
const oldSchemaBytes = Uint8Array.from(bytes);
|
||||
oldSchemaBytes[0] = (oldSchemaBytes[0] & 0x80) | 52;
|
||||
let rejectedOldSchema = false;
|
||||
try { global.TarinaiSaveCodec.decodeBinarySnapshot(oldSchemaBytes); } catch (_) { rejectedOldSchema = true; }
|
||||
if (!rejectedOldSchema) throw new Error("obsolete binary schema was accepted");
|
||||
|
||||
console.log("[OK] current-only schema 53, achievement v8 mask, and persistent progress binary round-trip passed");
|
||||
|
|
@ -28,8 +28,8 @@ def achievement_ids() -> list[str]:
|
|||
if not match:
|
||||
raise AssertionError("ACHIEVEMENT_IDS was not found")
|
||||
values = re.findall(r"'([^']+)'", match.group(1))
|
||||
if len(values) != 64 or len(values) != len(set(values)):
|
||||
raise AssertionError(f"expected 64 unique achievement IDs, got {len(values)}")
|
||||
if len(values) != 75 or len(values) != len(set(values)):
|
||||
raise AssertionError(f"expected 75 unique achievement IDs, got {len(values)}")
|
||||
return values
|
||||
|
||||
|
||||
|
|
@ -282,7 +282,7 @@ def run_concurrency_case(server: PhpServer, state_path: Path) -> None:
|
|||
rng = random.Random(52_001)
|
||||
requests: list[tuple[str, dict[str, int]]] = []
|
||||
now_ms = int(time.time() * 1000)
|
||||
for index in range(72):
|
||||
for index in range(75):
|
||||
player_id = str(uuid.UUID(int=rng.getrandbits(128), version=4))
|
||||
selected = rng.sample(ACHIEVEMENTS, rng.randint(1, 18))
|
||||
unlocks = {achievement_id: now_ms - rng.randint(0, 1_000_000) for achievement_id in selected}
|
||||
|
|
@ -332,7 +332,7 @@ def main() -> None:
|
|||
server.close()
|
||||
print(
|
||||
"[OK] 32 randomized v2/verbose migrations, exact semantic round-trips, corruption preservation, "
|
||||
f"completionist filtering, GET mutation rejection, and 72 concurrent syncs passed; "
|
||||
f"completionist filtering, GET mutation rejection, and 75 concurrent syncs passed; "
|
||||
f"aggregate JSON shrank {original}→{compact} bytes ({compact / original:.1%})"
|
||||
)
|
||||
|
||||
|
|
|
|||
44
scripts/colony_crisis_regression_audit.js
Normal file
44
scripts/colony_crisis_regression_audit.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use strict";
|
||||
|
||||
global.window = global;
|
||||
global.CONFIG = { dayLength: 120 };
|
||||
global.TarinaiAchievements = { evaluateWorld() {} };
|
||||
require("../js/colony_situation_system.js");
|
||||
|
||||
function actor({ energy = 80, hunger = 20, health = 0 } = {}) {
|
||||
return { dead: false, energy, hunger, stress: 10, needs: { health, safety: 0 }, state: "idle" };
|
||||
}
|
||||
function world(actors, deaths = 0, previousPopulation = null) {
|
||||
return {
|
||||
time: 100,
|
||||
tarinai: actors,
|
||||
recentDeathTimes: Array.from({ length: deaths }, (_, i) => 99 - i * 0.1),
|
||||
lastColonyMoodPopulation: previousPopulation == null ? actors.length : previousPopulation,
|
||||
colonyMoodDefinition(id) { return { id, label: id, effects: { personality: {} } }; },
|
||||
config: { dayLength: 120 },
|
||||
};
|
||||
}
|
||||
function evaluate(w) { return global.TarinaiColonySituationSystem.evaluate(w, true).id; }
|
||||
function assert(ok, message) { if (!ok) throw new Error(message); }
|
||||
|
||||
// Ten recent deaths alone at 100 population must no longer trigger crisis without systemic weakness.
|
||||
let w = world(Array.from({ length: 100 }, () => actor()), 10);
|
||||
assert(evaluate(w) !== "crisis", "recent deaths alone still trigger crisis too easily");
|
||||
|
||||
// Systemic weakness alone below collapse severity must not trigger crisis.
|
||||
w = world(Array.from({ length: 100 }, (_, i) => actor(i < 50 ? { energy: 24, hunger: 90, health: 70 } : {})), 0);
|
||||
assert(evaluate(w) !== "crisis", "moderate systemic weakness alone still triggers crisis");
|
||||
|
||||
// Mortality plus systemic weakness should trigger crisis.
|
||||
w = world(Array.from({ length: 100 }, (_, i) => actor(i < 50 ? { energy: 24, hunger: 90, health: 70 } : {})), 10);
|
||||
assert(evaluate(w) === "crisis", "compound mortality/systemic stress does not trigger crisis");
|
||||
|
||||
// Extreme collapse can trigger even without recent deaths.
|
||||
w = world(Array.from({ length: 100 }, (_, i) => actor(i < 70 ? { energy: 10, hunger: 98, health: 80 } : {})), 0);
|
||||
assert(evaluate(w) === "crisis", "extreme colony collapse does not trigger crisis");
|
||||
|
||||
// Population growth still suppresses crisis.
|
||||
w = world(Array.from({ length: 100 }, (_, i) => actor(i < 70 ? { energy: 10, hunger: 98, health: 80 } : {})), 20, 90);
|
||||
assert(evaluate(w) !== "crisis", "crisis ignored population-growth exclusion");
|
||||
|
||||
console.log("[OK] stricter compound crisis classification passed");
|
||||
15
scripts/effect_runtime_regression_audit.js
Normal file
15
scripts/effect_runtime_regression_audit.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"use strict";
|
||||
const fs = require("fs");
|
||||
function ok(cond, msg) { if (!cond) throw new Error(msg); console.log(`[OK] ${msg}`); }
|
||||
const sim = fs.readFileSync("js/simulation_effects_system.js", "utf8");
|
||||
const combat = fs.readFileSync("js/world_combat_effects.js", "utf8");
|
||||
const all = fs.readdirSync("js").filter(x => x.endsWith(".js")).map(x => fs.readFileSync(`js/${x}`, "utf8")).join("\n");
|
||||
ok(!/\.map\([\s\S]{0,300}\.sort\(/.test(sim), "effect pressure compaction no longer map/sorts all effects");
|
||||
ok(sim.includes("for (let priority = 1; priority <= 3"), "effect pressure drops by priority tiers");
|
||||
ok(combat.includes('new Set(["ring", "fight", "flame", "fall"])'), "effect pool is limited to simple high-frequency types");
|
||||
ok(combat.includes("offscreenCulled"), "low-importance offscreen spawns are tracked and culled");
|
||||
ok(sim.includes('if (effect.type === "ring")') && sim.includes("lightweight"), "rings bypass the budgeted physics update path");
|
||||
ok(sim.includes("spawnRequested") && sim.includes("poolEligible") && sim.includes("pooled"), "effect runtime diagnostics expose spawn and pool counters");
|
||||
const direct = [...all.matchAll(/effects\.push\(new Effect\(/g)].length;
|
||||
ok(direct === 0, "normal Effect construction is routed through spawnEffect");
|
||||
console.log("[OK] effect runtime regression audit passed");
|
||||
46
scripts/history_binary_regression_audit.js
Normal file
46
scripts/history_binary_regression_audit.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"use strict";
|
||||
|
||||
const path = require("path");
|
||||
global.window = global;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
global.TarinaiSaveCodec = {
|
||||
encodeBinarySnapshot(snapshot) {
|
||||
return encoder.encode(JSON.stringify(snapshot));
|
||||
},
|
||||
decodeBinarySnapshot(bytes) {
|
||||
return JSON.parse(decoder.decode(bytes));
|
||||
},
|
||||
};
|
||||
global.TarinaiSnapshot = {
|
||||
createSnapshot(world) {
|
||||
return { v: 1, a: "tj1", value: world.value, time: world.time };
|
||||
},
|
||||
};
|
||||
global.TarinaiRestoreCoordinator = {
|
||||
restoreSnapshot(snapshot, world) {
|
||||
world.value = snapshot.value;
|
||||
world.time = snapshot.time;
|
||||
},
|
||||
};
|
||||
|
||||
require(path.join(__dirname, "..", "js", "history_system.js"));
|
||||
|
||||
const world = { value: 1, time: 10 };
|
||||
if (!global.TarinaiHistory.capture(world, "first")) throw new Error("initial history capture failed");
|
||||
if (!(world._undoStack[0].bytes instanceof Uint8Array)) throw new Error("history entry is not binary");
|
||||
if ("snapshot" in world._undoStack[0] || "patch" in world._undoStack[0]) throw new Error("legacy object history payload remains");
|
||||
if (global.TarinaiHistory.capture(world, "duplicate")) throw new Error("duplicate snapshot was not suppressed");
|
||||
|
||||
world.value = 2;
|
||||
world.time = 20;
|
||||
if (!global.TarinaiHistory.capture(world, "second")) throw new Error("second history capture failed");
|
||||
world.value = 3;
|
||||
world.time = 30;
|
||||
let result = global.TarinaiHistory.undo(world);
|
||||
if (!result.ok || world.value !== 2 || world.time !== 20) throw new Error("undo did not restore the binary snapshot");
|
||||
result = global.TarinaiHistory.redo(world);
|
||||
if (!result.ok || world.value !== 3 || world.time !== 30) throw new Error("redo did not restore the captured current state");
|
||||
|
||||
console.log("[OK] binary undo/redo history, duplicate suppression, and synchronous restore passed");
|
||||
21
scripts/non_tarinai_performance_regression_audit.js
Normal file
21
scripts/non_tarinai_performance_regression_audit.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"use strict";
|
||||
const fs = require("fs");
|
||||
const viewSrc = fs.readFileSync("js/world_view.js", "utf8");
|
||||
const maintenanceSrc = fs.readFileSync("js/simulation_maintenance_system.js", "utf8");
|
||||
const spatialSrc = fs.readFileSync("js/world_spatial_budget.js", "utf8");
|
||||
const mainSrc = fs.readFileSync("js/main.js", "utf8");
|
||||
const statsSrc = fs.readFileSync("js/ui_charts.js", "utf8");
|
||||
const decaySrc = fs.readFileSync("js/item_lifecycle_decay_system.js", "utf8");
|
||||
function ok(cond, msg) { if (!cond) { console.error(`[FAIL] ${msg}`); process.exitCode = 1; } else console.log(`[OK] ${msg}`); }
|
||||
ok(!viewSrc.includes('terrain-periodic'), "terrain cache is not globally invalidated on a timer");
|
||||
ok(viewSrc.includes('return `${worldRef.fieldType || "garden"}:${worldRef.groundType || "soil"}`'), "terrain signature excludes object-count churn");
|
||||
ok(maintenanceSrc.includes('_nextStructureDependencyCheckAt'), "structure dependency safety sweeps are throttled");
|
||||
ok(maintenanceSrc.includes('_nextItemCompactAt') && maintenanceSrc.includes('_nextAntCompactAt'), "item and ant compactions are staggered");
|
||||
ok(!maintenanceSrc.includes('countsInterval = 2.75'), "item bucket rebuilds are no longer forced by a periodic timer");
|
||||
ok(spatialSrc.includes('if (terrainChanged) this.markTerrainDirty?.("compact-terrain-items")'), "item compaction only invalidates terrain when cached terrain items were removed");
|
||||
ok(!spatialSrc.includes('else this.markTerrainDirty?.(reason);'), "adding ordinary objects no longer invalidates the whole terrain cache");
|
||||
ok(mainSrc.includes('requestIdleCallback') && mainSrc.includes('scheduleStatsRefresh'), "periodic stats refresh is deferred outside the simulation frame");
|
||||
ok(statsSrc.includes('world.tarinaiCounts?.()') && statsSrc.includes('collectDynamicColonyStats'), "stats use differential population counters and isolate dynamic aggregation");
|
||||
ok(statsSrc.includes('colonyStatsUiVisible') && mainSrc.includes('!colonyStatsUiVisible()'), "hidden colony UI skips scheduled stats work");
|
||||
ok(decaySrc.includes('"splat-fade"'), "splat fading invalidates only its local terrain chunk");
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
58
scripts/pathfinding_regression_audit.js
Normal file
58
scripts/pathfinding_regression_audit.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"use strict";
|
||||
|
||||
const path = require("path");
|
||||
global.window = global;
|
||||
global.CONFIG = { worldPadding: 28 };
|
||||
global.clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
||||
class World {}
|
||||
global.World = World;
|
||||
require(path.join(__dirname, "..", "js", "world_pathfinding_system.js"));
|
||||
|
||||
function makeWorld({ directBlocked = false } = {}) {
|
||||
const w = new World();
|
||||
w.w = 1400;
|
||||
w.h = 1000;
|
||||
w.time = 10;
|
||||
w.routingObstacleVersion = 3;
|
||||
w.pointChecks = 0;
|
||||
w.edgeChecks = 0;
|
||||
w.pointBlockedByObstacle = () => { w.pointChecks++; return false; };
|
||||
w.pathBlockedByFence = () => { w.edgeChecks++; return directBlocked; };
|
||||
return w;
|
||||
}
|
||||
|
||||
function waypointFor(id) {
|
||||
const w = makeWorld();
|
||||
const actor = { id, x: 120, y: 120, radius: 20 };
|
||||
const target = { id: "goal", x: 1120, y: 780 };
|
||||
const first = w.findGridPathWaypoint(actor, target, { gridStep: 58, state: "seek_food" });
|
||||
if (!first) throw new Error(`no path for ${id}`);
|
||||
const pointAfterFirst = w.pointChecks;
|
||||
const edgeAfterFirst = w.edgeChecks;
|
||||
const again = w.findGridPathWaypoint(actor, target, { gridStep: 58, state: "seek_food" });
|
||||
if (!again || first.x !== again.x || first.y !== again.y) throw new Error("same actor route cache is not stable");
|
||||
if (w.pointChecks !== pointAfterFirst || w.edgeChecks !== edgeAfterFirst) throw new Error("throttled actor cache still performs geometry checks every call");
|
||||
if (actor._routeCache?.spatialVersion !== 3 || actor._routeCache?.mode !== "grid") throw new Error("unified route cache was not stored");
|
||||
if (actor._gridPathWaypoint || actor._pathWaypoint) throw new Error("legacy route caches still exist");
|
||||
w.routingObstacleVersion = 4;
|
||||
w.findGridPathWaypoint(actor, target, { gridStep: 58, state: "seek_food" });
|
||||
if (actor._routeCache?.spatialVersion !== 4) throw new Error("cache did not invalidate after obstacle version change");
|
||||
if (w.pointChecks > 18 * 18 * 3) throw new Error(`excessive point checks: ${w.pointChecks}`);
|
||||
if (w.edgeChecks > 18 * 18 * 8 * 3) throw new Error(`excessive edge checks: ${w.edgeChecks}`);
|
||||
return `${first.x.toFixed(3)},${first.y.toFixed(3)}`;
|
||||
}
|
||||
|
||||
const routes = new Set(Array.from({ length: 64 }, (_, i) => waypointFor(`tarinai-${i}`)));
|
||||
if (routes.size < 2) throw new Error("ID-fixed bias did not diversify independently computed routes");
|
||||
|
||||
const directWorld = makeWorld();
|
||||
const directTarget = { id: "direct-goal", x: 600, y: 400 };
|
||||
const directActor = { id: "direct-a", x: 100, y: 100, radius: 20 };
|
||||
directWorld.findTarinaiPathWaypoint(directActor, directTarget, { state: "seek_food" });
|
||||
const directChecks = directWorld.edgeChecks;
|
||||
for (let i = 0; i < 100; i++) directWorld.findTarinaiPathWaypoint(directActor, directTarget, { state: "seek_food" });
|
||||
if (directWorld.edgeChecks !== directChecks) throw new Error("direct-route throttle did not suppress repeated fence checks");
|
||||
|
||||
const w = makeWorld();
|
||||
w.markSpatialDirty = World.prototype.markSpatialDirty;
|
||||
console.log(`[OK] throttled actor-local route cache, obstacle invalidation, stable ID bias, and route diversity passed (${routes.size} first-waypoint variants)`);
|
||||
58
scripts/performance_5to7_regression_audit.js
Normal file
58
scripts/performance_5to7_regression_audit.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const read = rel => fs.readFileSync(path.join(ROOT, rel), "utf8");
|
||||
|
||||
const creatureSource = read("js/simulation_creature_system.js");
|
||||
assert(creatureSource.includes("_creatureScheduledWorkCursor"), "rotating scheduled-work cursor is missing");
|
||||
assert(creatureSource.includes("creatureCount >= 80"), "high-population guard for rotating AI work is missing");
|
||||
assert(creatureSource.includes("perfProfile.aiBudget"), "AI budget is not used as the low-overhead rotation step");
|
||||
assert(!creatureSource.includes("sortScheduledAi"), "AI spreading introduced an unnecessary sort queue");
|
||||
console.log("[OK] high-cost creature work uses a rotating, queue-free budget order");
|
||||
|
||||
const renderSource = read("js/render.js");
|
||||
assert(renderSource.includes("creatureSortRect = visibleWorldRect(world, 72)"), "narrow creature depth-sort rectangle is missing");
|
||||
assert(renderSource.includes("nearbyRectInto(worldRef.spatial.tarinaiCells, creatureSortRect"), "Tarinai sort candidates are not limited before sorting");
|
||||
assert(renderSource.includes("nearbyRectInto(worldRef.spatial.antCells, creatureSortRect"), "ant sort candidates are not limited before sorting");
|
||||
assert(renderSource.includes("collectVisibleRenderStack(world, visibleRect, creatureSortRect)"), "render stack does not receive the narrowed creature sort rectangle");
|
||||
console.log("[OK] creature depth sorting is limited to near-viewport candidates before list insertion");
|
||||
|
||||
const relationSource = read("js/tarinai_identity_social.js");
|
||||
assert(relationSource.includes("lazyDropDeadRelationPeer"), "lazy dead relationship deletion helper is missing");
|
||||
assert(relationSource.includes("pruneDeadRelationshipRefs(deadIds)"), "dead-ID batch relationship cleanup is missing");
|
||||
assert(!/entry\.ownCount\s*=\s*Math\.max\(entry\.ownCount,\s*Object\.keys\(this\.relationships\)\.length\)/.test(relationSource), "relation insertion still enumerates all relationship keys");
|
||||
console.log("[OK] dead relationship references are lazily removed without per-insert full key enumeration");
|
||||
|
||||
class World {}
|
||||
global.World = World;
|
||||
global.TarinaiHistory = { trimMemory() { return 0; } };
|
||||
require(path.join(ROOT, "js/world_spatial_budget.js"));
|
||||
|
||||
const world = new World();
|
||||
world.time = 100;
|
||||
const aliveA = { id: "A", dead: false, relationships: { D1: { affinity: 1 }, C: { affinity: 2 } }, relationCache: { stale: true } };
|
||||
const aliveC = { id: "C", dead: false, relationships: { A: { affinity: 2 }, X: { affinity: 9 } }, relationCache: { stale: true } };
|
||||
world.tarinai = [aliveA, aliveC];
|
||||
world.liveTarinai = new Map([["A", { target: aliveA }], ["C", { target: aliveC }]]);
|
||||
world._deadTarinaiIdsPendingCleanup = new Set(["D1"]);
|
||||
world.tarinaiCollisionMemo = new Map();
|
||||
world.relationNotices = {};
|
||||
world.fightPairCooldowns = {};
|
||||
world.resolvedFightIds = {};
|
||||
world.queueTarinaiRuntimeCachePrune("audit", { resetRelationPeerCaches: true });
|
||||
assert(world._tarinaiRuntimePruneJob, "threshold cleanup job was not queued");
|
||||
world.processTarinaiRuntimeCachePruneStep(1);
|
||||
assert(world._tarinaiRuntimePruneJob, "threshold cleanup was not split across frames");
|
||||
while (world._tarinaiRuntimePruneJob) world.processTarinaiRuntimeCachePruneStep(1);
|
||||
assert(!Object.prototype.hasOwnProperty.call(aliveA.relationships, "D1"), "known dead relationship was not removed");
|
||||
assert(Object.prototype.hasOwnProperty.call(aliveA.relationships, "C"), "live relationship was incorrectly removed");
|
||||
assert(Object.prototype.hasOwnProperty.call(aliveC.relationships, "X"), "unrelated stale reference was scanned/removed by targeted cleanup");
|
||||
assert.strictEqual(world._deadTarinaiIdsPendingCleanup.size, 0, "processed dead-ID set was not released after chunked cleanup");
|
||||
console.log("[OK] threshold cleanup is split across frames and removes only queued dead IDs");
|
||||
|
||||
const deathSource = read("js/tarinai_social_move_life.js");
|
||||
assert(deathSource.includes("noteTarinaiDeathForRuntimeCleanup?.(this.id)"), "death hook does not queue the dead Tarinai ID");
|
||||
console.log("Performance 5-7 regression audit passed.");
|
||||
28
scripts/performance_architecture_regression_audit.js
Normal file
28
scripts/performance_architecture_regression_audit.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const read = rel => fs.readFileSync(path.join(root, rel), "utf8");
|
||||
const ok = (value, message) => { if (!value) throw new Error(message); console.log(`[OK] ${message}`); };
|
||||
|
||||
const budget = read("js/world_spatial_budget.js");
|
||||
const family = read("js/world_family_social.js");
|
||||
const life = read("js/tarinai_social_move_life.js");
|
||||
const achievements = read("js/achievements.js");
|
||||
const charts = read("js/ui_charts.js");
|
||||
const main = read("js/main.js");
|
||||
const render = read("js/render.js");
|
||||
|
||||
ok(budget.includes("rebuildTarinaiCountCache") && budget.includes("syncTarinaiCountEntry") && budget.includes("tarinaiCounts()"), "population counters use mutation-maintained differential cache");
|
||||
ok(family.includes("syncTarinaiCountEntry?.(t)") && life.includes("syncTarinaiCountEntry?.(this)"), "birth/add and death paths update differential population counters");
|
||||
ok(budget.includes('noteItemInactive(item, reason = "item-inactive", notifyAchievements = true)') && budget.includes('noteItemInactive?.(it, "compact-items", false)'), "bulk item compaction suppresses per-item achievement checks");
|
||||
ok(budget.includes('evaluateEvent?.(this, "items", { delta: 0, reason: "compact-items" })'), "bulk item compaction emits one aggregate achievement event");
|
||||
ok(achievements.includes("function evaluateEvent(worldRef, trigger") && achievements.includes('evaluateEvent(worldRef, "timer"'), "achievement evaluation has event-driven trigger entry point");
|
||||
ok(!achievements.includes("const needsHabitatItemScan"), "playstyle habitat achievements no longer rescan every item on periodic evaluation");
|
||||
ok(achievements.includes("worldRef?.itemCounts") && achievements.includes("playstyleItemCount"), "item-dependent achievements use differential item counts in normal runtime");
|
||||
ok(charts.includes("function colonyStatsUiVisible()") && main.includes("!colonyStatsUiVisible()"), "hidden colony UI prevents scheduled stats aggregation");
|
||||
ok(charts.includes("world.tarinaiCounts?.()") && charts.includes("collectDynamicColonyStats"), "visible stats separate hot counters from dynamic full-population aggregation");
|
||||
ok(render.includes("nearbyRectInto(worldRef.spatial.tarinaiCells") && render.includes("isPointVisibleInRect") && render.includes("!isEntityVisibleInRect(entity, visibleRect)"), "render stack pre-culls entities before list insertion");
|
||||
ok(!render.includes("isEntityVisibleInRect({ ...it, x: pose.x, y: pose.y }"), "carried-item visibility culling avoids temporary object allocation");
|
||||
|
||||
console.log("[OK] performance architecture: differential counters, event triggers, hidden UI gating, and pre-render culling verified");
|
||||
77
scripts/performance_cleanup_regression_audit.js
Normal file
77
scripts/performance_cleanup_regression_audit.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const vm = require("vm");
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const read = rel => fs.readFileSync(path.join(ROOT, rel), "utf8");
|
||||
|
||||
const render = read("js/render.js");
|
||||
assert(!render.includes("insertionSortNearPrevious"), "custom insertion sort still exists");
|
||||
assert(!render.includes("_renderPrevDynamicLayeredIds") && !render.includes("_renderBackSortMap"), "previous-order render bookkeeping still exists");
|
||||
assert(render.includes("dynamicBack.sort(compareBackItems)") && render.includes("dynamicLayered.sort(compareRenderEntries)"), "native render sort is not active");
|
||||
console.log("[OK] previous-order Map and custom insertion sort are removed");
|
||||
|
||||
const pathfinding = read("js/world_pathfinding_system.js");
|
||||
assert(!pathfinding.includes("_sharedRouteCache") && !pathfinding.includes("trySharedRoute") && !pathfinding.includes("storeSharedRoute"), "shared route cache still exists");
|
||||
assert(pathfinding.includes("actor._routeCache"), "actor-local route cache was removed accidentally");
|
||||
console.log("[OK] shared route cache is removed while actor-local throttling remains");
|
||||
|
||||
const profiler = read("js/perf_profiler.js");
|
||||
const budget = read("js/world_spatial_budget.js");
|
||||
assert(profiler.includes("diagnosticsEnabled"), "diagnostic-mode gate is missing");
|
||||
assert(budget.includes("diagnostics ?") && budget.includes("this.workStats = diagnostics ?"), "production detailed diagnostic allocation is still unconditional");
|
||||
assert(profiler.includes("transitionHoldSeconds") && profiler.includes("pendingProfileSeconds"), "performance-profile hysteresis hold timers are missing");
|
||||
console.log("[OK] production diagnostics are gated and auto profile switching uses hysteresis");
|
||||
|
||||
const environment = read("js/world_environment.js");
|
||||
assert(environment.includes("`${this.routingObstacleVersion || 0}:${qx},${qy},${qr},${cacheLimit},${maxRects}`"), "obstacle cache is not keyed by routingObstacleVersion only");
|
||||
assert(!environment.includes("`${this.spatialVersion || 0}:${this.spatialDirtyMarksTotal || 0}:${qx}"), "obstacle cache still invalidates on generic spatial movement");
|
||||
console.log("[OK] obstacle cache invalidation is isolated from generic spatial movement");
|
||||
|
||||
const simCore = read("js/sim_core.js");
|
||||
const start = simCore.indexOf("class SpatialGrid {");
|
||||
const end = simCore.indexOf("\nfunction drawHeartShape", start);
|
||||
assert(start >= 0 && end > start, "SpatialGrid source could not be isolated");
|
||||
const context = { console, Map, Set, Math, Number, Array, globalThis: {} };
|
||||
vm.createContext(context);
|
||||
vm.runInContext(`${simCore.slice(start, end)}\nglobalThis.SpatialGrid = SpatialGrid;`, context);
|
||||
const SpatialGrid = context.globalThis.SpatialGrid;
|
||||
const grid = new SpatialGrid(100);
|
||||
const a = { id: "a", x: 10, y: 10, dead: false };
|
||||
const b = { id: "b", x: 40, y: 40, dead: false };
|
||||
grid.rebuildTarinai([a, b]);
|
||||
const firstKey = a._spatialTarinaiCellKey;
|
||||
a.x = 50;
|
||||
assert.strictEqual(grid.syncTarinai([a, b]), 0, "same-cell movement should not rewrite the grid");
|
||||
a.x = 150;
|
||||
assert.strictEqual(grid.syncTarinai([a, b]), 1, "cell crossing should update exactly one entity");
|
||||
assert.notStrictEqual(a._spatialTarinaiCellKey, firstKey, "cell key did not change after crossing");
|
||||
b.dead = true;
|
||||
assert(grid.syncTarinai([a, b]) >= 1, "dead entity was not removed incrementally");
|
||||
assert(!grid._indexedTarinai.has(b), "dead entity remains in the Tarinai spatial index");
|
||||
console.log("[OK] Tarinai spatial grid updates only cell crossings/removals instead of full rebuilds");
|
||||
|
||||
class World {}
|
||||
global.World = World;
|
||||
global.TarinaiHistory = { trimMemory() { return 0; } };
|
||||
require(path.join(ROOT, "js", "world_spatial_budget.js"));
|
||||
const world = new World();
|
||||
world.time = 100;
|
||||
world.tarinai = Array.from({ length: 120 }, (_, i) => ({ id: `T${i}`, dead: false, relationships: { D: { affinity: 1 } } }));
|
||||
world.liveTarinai = new Map(world.tarinai.map(t => [t.id, { target: t }]));
|
||||
world._deadTarinaiIdsPendingCleanup = new Set(["D"]);
|
||||
world.tarinaiCollisionMemo = new Map();
|
||||
world.relationNotices = {};
|
||||
world.fightPairCooldowns = {};
|
||||
world.resolvedFightIds = {};
|
||||
assert(world.queueTarinaiRuntimeCachePrune("audit", { resetRelationPeerCaches: true }), "cleanup job was not queued");
|
||||
world.processTarinaiRuntimeCachePruneStep(24);
|
||||
assert(world._tarinaiRuntimePruneJob, "cleanup finished in one frame instead of being chunked");
|
||||
assert.strictEqual(world._tarinaiRuntimePruneJob.cursor, 24, "cleanup chunk size was not respected");
|
||||
while (world._tarinaiRuntimePruneJob) world.processTarinaiRuntimeCachePruneStep(24);
|
||||
assert(world.tarinai.every(t => !Object.prototype.hasOwnProperty.call(t.relationships, "D")), "chunked cleanup left dead references behind");
|
||||
console.log("[OK] death-reference cleanup runs in bounded per-frame chunks");
|
||||
|
||||
console.log("Performance cleanup regression audit passed.");
|
||||
|
|
@ -35,7 +35,7 @@ function findCard(h,id){const stack=[h.elements.get("achievementList")];while(st
|
|||
|
||||
(function run(){
|
||||
let h=harness();
|
||||
assert(h.api.definitions.length===64,"must have 64 achievements");
|
||||
assert(h.api.definitions.length===75,"must have 75 achievements");
|
||||
h.api.open();
|
||||
const title=Object.fromEntries(h.api.definitions.map(d=>[d.id,d.title]));
|
||||
assert(title.medicine_ledger_all==="おくすり手帳全埋め","medicine title");
|
||||
|
|
@ -45,6 +45,7 @@ function findCard(h,id){const stack=[h.elements.get("achievementList")];while(st
|
|||
assert(description("mercury_lifespan").includes("水銀を使用したたりないが天寿を全うする。"),"mercury condition disclosed in UI");
|
||||
assert(findCard(h,"enemy_enemy_friend").children[1].children.some(node=>node.textContent.includes("現在の達成率 0.0%")),"enemy progress shown in UI");
|
||||
assert(description("overprotective")==="???","other new condition hidden");
|
||||
assert(description("elite_few").includes("25体以下の状態を5日間維持"),"elite few condition must be visible before unlock");
|
||||
|
||||
// One Undo only: multiple smaller Undo operations never aggregate.
|
||||
h.api.recordHistoryAction("undo",{deadRestored:9}); h.api.recordHistoryAction("undo",{deadRestored:1});
|
||||
|
|
@ -83,7 +84,7 @@ function findCard(h,id){const stack=[h.elements.get("achievementList")];while(st
|
|||
// Overprotective requires both thresholds on the same individual.
|
||||
h=harness(); const cared={dead:false}; const cw={time:0};
|
||||
for(let i=0;i<9;i++)h.api.recordDirectFeed({world:cw,tarinai:cared,type:"food"});
|
||||
for(let i=0;i<3;i++)h.api.recordDirectCare({world:cw,tarinai:cared,type:"first_aid",treatment:true});
|
||||
for(let i=0;i<3;i++)h.api.recordDirectCare({world:cw,tarinai:cared,type:"first_aid",beneficialRecovery:true});
|
||||
assert(off(h,"overprotective"),"overprotective before 10 feeds"); h.api.recordDirectFeed({world:cw,tarinai:cared,type:"food"}); assert(on(h,"overprotective"),"overprotective thresholds");
|
||||
|
||||
// Self-sufficient: 20+, no direct feed, no duplicator, 10 game minutes.
|
||||
|
|
@ -99,6 +100,7 @@ function findCard(h,id){const stack=[h.elements.get("achievementList")];while(st
|
|||
assert(off(h,"unplanned_city_30"),"unplanned at 29");let item={};h.api.recordPlayerPlacement({world:qw,item});qw.time=30.001;h.api.recordPlayerDeletion({world:qw,item});assert(off(h,"unplanned_city_30"),"late deletion counted");
|
||||
item={_achievementPlayerPlaced:true,_achievementPlacedAt:null};qw.time=0;h.api.recordPlayerDeletion({world:qw,item});assert(off(h,"unplanned_city_30"),"missing placement timestamp counted");
|
||||
item={};qw.time=0;h.api.recordPlayerPlacement({world:qw,item});qw.time=30;h.api.recordPlayerDeletion({world:qw,item});assert(on(h,"unplanned_city_30"),"unplanned at 30");
|
||||
h=harness(); qw={time:0}; for(let i=0;i<30;i++){const x={};h.api.recordPlayerPlacement({world:qw,item:x});h.api.recordPlayerDeletion({world:qw,item:x,operationId:"same-area-op"});} assert(off(h,"unplanned_city_30"),"area deletion counted each removed item instead of one operation");
|
||||
|
||||
// Sauna -> cold within 15 seconds; cold alone must never unlock.
|
||||
h=harness(); const st={dead:false,x:0,y:0}; let temp=0; const tw={time:0,tarinai:[st],ants:[],items:[],feltTemperatureFor(){return temp;},temperatureStatusFor(v){return {direction:v>25?"hot":v<10?"cold":"comfort",comfortable:v>=10&&v<=25};}};
|
||||
|
|
@ -174,9 +176,29 @@ function findCard(h,id){const stack=[h.elements.get("achievementList")];while(st
|
|||
h=harness();const antWorld={tarinai:[],ants:[],items:[{type:"ant_nest",dead:false}]};h.api.evaluateWorld(antWorld,{});assert(on(h,"ant_nest_without_tarinai"),"ant observation condition");
|
||||
h=harness();const bomb={type:"sticky_bomb",dead:false};for(let i=0;i<14;i++)h.api.recordStickyBombPass(bomb,{});assert(off(h,"sticky_bomb_15_passes"),"sticky relay early");h.api.recordStickyBombPass(bomb,{});assert(on(h,"sticky_bomb_15_passes"),"sticky relay at 15");
|
||||
|
||||
|
||||
|
||||
// New playstyle-survey achievements.
|
||||
h=harness(); let popWorld={time:0,day:1,tarinai:alive(99),ants:[],items:[]}; h.api.evaluateWorld(popWorld,{}); assert(off(h,"strength_in_numbers"),"strength in numbers unlocked before 100"); popWorld.tarinai.push({id:"t99",dead:false,x:99,y:0}); h.api.evaluateWorld(popWorld,{}); assert(on(h,"strength_in_numbers"),"strength in numbers did not unlock at 100");
|
||||
|
||||
h=harness(); let eliteWorld={time:0,day:1,tarinai:alive(25),ants:[],items:[],config:{dayLength:120}}; h.api.evaluateWorld(eliteWorld,{}); eliteWorld.time=599.99; h.api.evaluateWorld(eliteWorld,{}); assert(off(h,"elite_few"),"elite few unlocked before five days"); eliteWorld.time=600; h.api.evaluateWorld(eliteWorld,{}); assert(on(h,"elite_few"),"elite few did not unlock at five days");
|
||||
h=harness(); eliteWorld={time:0,day:1,tarinai:alive(25),ants:[],items:[],config:{dayLength:120}}; h.api.evaluateWorld(eliteWorld,{}); eliteWorld.time=300; eliteWorld.tarinai.push({dead:false}); h.api.evaluateWorld(eliteWorld,{}); eliteWorld.tarinai.pop(); h.api.evaluateWorld(eliteWorld,{}); eliteWorld.time=899.99; h.api.evaluateWorld(eliteWorld,{}); assert(off(h,"elite_few"),"elite few timer survived population overflow"); eliteWorld.time=900; h.api.evaluateWorld(eliteWorld,{}); assert(on(h,"elite_few"),"elite few did not restart after overflow");
|
||||
|
||||
h=harness(); let zWorld={time:0,tarinai:alive(20),ants:[],items:Array.from({length:20},()=>({type:"zunchi",dead:false}))}; h.api.evaluateWorld(zWorld,{}); assert(off(h,"zunchi_overflow"),"zunchi overflow unlocked at equal count"); zWorld.items.push({type:"zunchi",dead:false}); h.api.evaluateWorld(zWorld,{}); assert(on(h,"zunchi_overflow"),"zunchi overflow did not unlock above population");
|
||||
|
||||
h=harness(); let bedWorld={time:0,tarinai:alive(5),ants:[],items:[{type:"nest_box",dead:false}],nestBoxCapacity(){return 5;}}; h.api.evaluateWorld(bedWorld,{}); assert(on(h,"comfortable_beds"),"comfortable beds did not count nest capacity");
|
||||
h=harness(); bedWorld={time:0,tarinai:alive(30),ants:[],items:[]}; h.api.evaluateWorld(bedWorld,{}); assert(on(h,"stone_pillow"),"stone pillow did not unlock at 30 with zero bed capacity");
|
||||
|
||||
h=harness(); const seasonWorld={time:2400,elapsedDays:20,tarinai:alive(1),ants:[],items:[]}; h.api.evaluateWorld(seasonWorld,{}); assert(on(h,"across_seasons"),"across seasons did not unlock after full cycle");
|
||||
h=harness(); const fav={dead:false,favorite:true}; h.api.recordFavoriteTarinai({tarinai:fav}); assert(on(h,"favorite_one"),"favorite achievement did not unlock");
|
||||
|
||||
storage=new Map(); h=harness(storage); for(let i=0;i<15;i++) h.api.recordStatsButtonPress({control:"chart:population"}); h.api.resetWorldProgress({}); h=harness(storage); for(let i=0;i<14;i++) h.api.recordStatsButtonPress({control:"series:pop"}); assert(off(h,"statistician"),"statistician unlocked at 29 across reset"); h.api.recordStatsButtonPress({control:"chart:life"}); assert(on(h,"statistician"),"statistician did not persist across reset to 30");
|
||||
|
||||
storage=new Map(); h=harness(storage); for(let i=0;i<60;i++) h.api.recordManualTarinaiAdded({tool:"new"}); h.api.resetWorldProgress({}); h=harness(storage); for(let i=0;i<39;i++) h.api.recordManualTarinaiAdded({tool:"new"}); assert(off(h,"lively_making"),"lively making unlocked at 99 across reset"); h.api.recordManualTarinaiAdded({tool:"new"}); assert(on(h,"lively_making"),"lively making did not persist across reset to 100");
|
||||
|
||||
// Static integration guards for all production hook points.
|
||||
const checks={
|
||||
"js/command_dispatcher.js":["deadRestored","recordHistoryAction"],
|
||||
"js/command_dispatcher.js":["deadRestored","recordHistoryAction","recordFavoriteTarinai"],
|
||||
"js/robot_cleaner_system.js":["recordRobotClean"],
|
||||
"js/world_tool_actions.js":["recordPlayerDeletion","recordAntDamageSource"],
|
||||
"js/world_family_social.js":["resetWorldProgress","recordFightStarted"],
|
||||
|
|
@ -195,7 +217,16 @@ function findCard(h,id){const stack=[h.elements.get("achievementList")];while(st
|
|||
"js/item_dynamic_tool_system.js":["recordStickyBombPass", "reason === \"transfer\""],
|
||||
"js/snapshot_system.js":["stickyBombPassCount"],
|
||||
"js/save_codec.js":["sticky_bomb", "[0, -1, 0], 3"],
|
||||
"js/ui_bind.js":["recordStatsButtonPress","chart:","series:"],
|
||||
};
|
||||
for(const [file,needles] of Object.entries(checks)){const text=fs.readFileSync(path.join(root,file),"utf8");for(const needle of needles)assert(text.includes(needle),`${file} missing ${needle}`);}
|
||||
console.log("[OK] playstyle achievements: boundaries, reset scope, persistence, inline disclosures, and hook coverage passed");
|
||||
})();
|
||||
|
||||
{
|
||||
const achSrc = fs.readFileSync("js/achievements.js", "utf8");
|
||||
const ecologyBlock = achSrc.match(/id: "ecology"[\s\S]*?id: "population"/)?.[0] || "";
|
||||
const otherBlock = achSrc.match(/id: "other"[\s\S]*?const DEFINITIONS/)?.[0] || "";
|
||||
assert(!ecologyBlock.includes('"across_seasons"'), "across_seasons category remained ecology");
|
||||
assert(otherBlock.includes('"across_seasons"'), "across_seasons category is not その他");
|
||||
}
|
||||
|
|
|
|||
194
scripts/population_recovery_regression_audit.js
Normal file
194
scripts/population_recovery_regression_audit.js
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const read = rel => fs.readFileSync(path.join(ROOT, rel), "utf8");
|
||||
|
||||
// The removed load-settle feature may only be explicitly cleared for stale
|
||||
// runtime state; it must never be scheduled or checked as active behavior.
|
||||
const snapshotSource = read("js/snapshot_system.js");
|
||||
const settleSources = [
|
||||
"js/collision_response_system.js",
|
||||
"js/tarinai_social_move_life.js",
|
||||
"js/tarinai_update_step_movement.js",
|
||||
"js/world_environment.js",
|
||||
].map(read).join("\n");
|
||||
assert(!/_restoreSettleUntil\s*=/.test(snapshotSource), "load settle timer is still assigned");
|
||||
assert(!/_restoreSettleUntil/.test(settleSources), "load settle behavior is still checked outside snapshot cleanup");
|
||||
assert(snapshotSource.includes("delete worldRef._restoreSettleUntil"), "stale settle state is not cleared on restore");
|
||||
|
||||
// Colony population statistics must expose both special populations.
|
||||
const chartsSource = read("js/ui_charts.js");
|
||||
assert(chartsSource.includes('key: "zunchiSlaves"') && chartsSource.includes('label: "\\u305a\\u3093\\u3061\\u3069\\u308c\\u3044"'), "zunchi slave population series is missing");
|
||||
assert(chartsSource.includes('key: "tarinaiKings"') && chartsSource.includes('label: "\\u305f\\u308a\\u306a\\u3044\\u738b"'), "tarinai king population series is missing");
|
||||
assert(chartsSource.includes("world.tarinaiCounts?.()") && chartsSource.includes("counts.zunchiSlaves") && chartsSource.includes("counts.tarinaiKings"), "special population counters are not sourced from differential live counters");
|
||||
assert(chartsSource.indexOf('key: "deaths"') < chartsSource.indexOf('key: "zunchiSlaves"'), "zunchi slave button must appear after deaths");
|
||||
assert(chartsSource.indexOf('key: "zunchiSlaves"') < chartsSource.indexOf('key: "tarinaiKings"'), "tarinai king button must follow zunchi slave");
|
||||
|
||||
const maintenanceSource = read("js/simulation_maintenance_system.js");
|
||||
assert(!maintenanceSource.includes("periodic-runtime-cache-prune"), "periodic full runtime-cache prune is still enabled");
|
||||
|
||||
class World {}
|
||||
global.World = World;
|
||||
let historyTrimCalls = 0;
|
||||
global.TarinaiHistory = {
|
||||
trimMemory(world, options) {
|
||||
historyTrimCalls += 1;
|
||||
world._lastTrimOptions = options;
|
||||
return 0;
|
||||
},
|
||||
};
|
||||
|
||||
require(path.join(ROOT, "js/family_graph.js"));
|
||||
require(path.join(ROOT, "js/world_spatial_budget.js"));
|
||||
require(path.join(ROOT, "js/world_family_social.js"));
|
||||
|
||||
function makeWorld() {
|
||||
const world = new World();
|
||||
world.time = 100;
|
||||
world.tarinai = [];
|
||||
world.spatial = { tarinaiCells: new Map([["old", [1, 2, 3]]]) };
|
||||
world.markSpatialDirty = () => { world._spatialDirtyMarked = true; };
|
||||
world.markFamilyTreeDirty = () => {};
|
||||
world.normalizeFamily = () => false;
|
||||
return world;
|
||||
}
|
||||
|
||||
// Runtime cache pruning is death-triggered: small numbers of deaths must not
|
||||
// schedule a full survivor relationship scan, while the adaptive threshold does.
|
||||
{
|
||||
const world = makeWorld();
|
||||
world.liveTarinai = new Map(Array.from({ length: 100 }, (_, i) => [`T${i}`, { target: { id: `T${i}`, dead: false } }]));
|
||||
for (let i = 0; i < 19; i++) world.noteTarinaiDeathForRuntimeCleanup();
|
||||
assert.strictEqual(world.tarinaiRuntimePrunePending, false, "cleanup scheduled before the minimum 20-death threshold");
|
||||
world.noteTarinaiDeathForRuntimeCleanup();
|
||||
assert.strictEqual(world.tarinaiRuntimePrunePending, true, "cleanup was not scheduled at the death threshold");
|
||||
}
|
||||
|
||||
// A pending death-threshold cleanup executes once at compaction and then resets.
|
||||
{
|
||||
const world = makeWorld();
|
||||
const alive = Array.from({ length: 100 }, (_, i) => ({ id: `A${i}`, dead: false, relationships: {} }));
|
||||
const dead = Array.from({ length: 20 }, (_, i) => ({ id: `D${i}`, dead: true, relationships: {} }));
|
||||
world.tarinai = [...alive, ...dead];
|
||||
world.liveTarinai = new Map(alive.map(t => [t.id, { target: t }]));
|
||||
world.deadTarinaiSinceRuntimePrune = 20;
|
||||
world.tarinaiRuntimePrunePending = true;
|
||||
world._deadTarinaiIdsPendingCleanup = new Set(dead.map(t => t.id));
|
||||
world.compactTarinai();
|
||||
assert(world._tarinaiRuntimePruneJob, "pending runtime cleanup was not queued during compaction");
|
||||
assert.strictEqual(world.deadTarinaiSinceRuntimePrune, 0, "death cleanup counter was not reset when the job was queued");
|
||||
assert.strictEqual(world.tarinaiRuntimePrunePending, false, "death cleanup pending flag was not cleared when queued");
|
||||
let steps = 0;
|
||||
while (world._tarinaiRuntimePruneJob && steps++ < 10) world.processTarinaiRuntimeCachePruneStep(24);
|
||||
assert(!world._tarinaiRuntimePruneJob, "queued runtime cleanup did not finish in bounded chunks");
|
||||
}
|
||||
|
||||
// Runtime caches must stop retaining dead peers and expired peak-population keys.
|
||||
{
|
||||
const world = makeWorld();
|
||||
const aliveA = { id: "A", dead: false, relationships: { B: { affinity: 1 }, C: { affinity: 2 } }, relationCache: { stale: true } };
|
||||
const deadB = { id: "B", dead: true, relationships: {} };
|
||||
const aliveC = { id: "C", dead: false, relationships: { A: { affinity: 3 } } };
|
||||
world.tarinai = [aliveA, deadB, aliveC];
|
||||
world.liveTarinai = new Map([["A", { target: aliveA }], ["B", { target: deadB }], ["ghost", { target: null }]]);
|
||||
world.tarinaiCollisionMemo = new Map([["old", 90], ["recent", 99.5]]);
|
||||
world.relationNotices = { old: -50, recent: 50 };
|
||||
world.fightPairCooldowns = { expired: 99, future: 110 };
|
||||
world.resolvedFightIds = { stale: true };
|
||||
world._lastResolvedFightIdsResetAt = 0;
|
||||
world._deadTarinaiIdsPendingCleanup = new Set(["B"]);
|
||||
|
||||
world.pruneTarinaiRuntimeCaches("audit", { resetRelationPeerCaches: true });
|
||||
assert.deepStrictEqual(Object.keys(aliveA.relationships), ["C"], "dead relationship peer was retained");
|
||||
assert.strictEqual(aliveA.relationCache, null, "relationship hot cache was not invalidated");
|
||||
assert.deepStrictEqual([...world.liveTarinai.keys()], ["A"], "dead live-id cache entries were retained");
|
||||
assert.deepStrictEqual([...world.tarinaiCollisionMemo.keys()], ["recent"], "stale collision memo was retained");
|
||||
assert.deepStrictEqual(Object.keys(world.relationNotices), ["recent"], "stale relation notice was retained");
|
||||
assert.deepStrictEqual(Object.keys(world.fightPairCooldowns), ["future"], "expired fight cooldown was retained");
|
||||
assert.deepStrictEqual(world.resolvedFightIds, {}, "old resolved-fight keys were retained");
|
||||
}
|
||||
|
||||
// A large population collapse must recreate high-water scratch containers and
|
||||
// release large undo snapshots rather than keeping the peak footprint forever.
|
||||
{
|
||||
const world = makeWorld();
|
||||
const oldRenderScratch = new Array(400).fill(null);
|
||||
const oldRenderSeen = new Set(Array.from({ length: 400 }, (_, i) => `id-${i}`));
|
||||
world._renderVisibleTarinaiScratch = oldRenderScratch;
|
||||
world._renderVisibleSeen = oldRenderSeen;
|
||||
world.tarinaiCollisionMemo = new Map([["peak", 100]]);
|
||||
world.relationNotices = { peak: 100 };
|
||||
world.resolvedFightIds = { peak: true };
|
||||
world.fightPairCooldowns = { peak: 999 };
|
||||
world._solidObstacleRectQueryCache = new Map([["peak", []]]);
|
||||
world.spatialTarinaiScratch = new Array(400).fill(null);
|
||||
world.drawList = new Array(400).fill(null);
|
||||
world._tarinaiRuntimeHighWater = 400;
|
||||
world.tarinai = Array.from({ length: 400 }, (_, i) => ({ id: `T${i}`, dead: i >= 200, relationships: {} }));
|
||||
|
||||
const changed = world.compactTarinai();
|
||||
assert.strictEqual(changed, true, "population compaction did not run");
|
||||
assert(world._tarinaiRuntimePruneJob, "population collapse did not queue an aggressive cleanup job");
|
||||
let shrinkSteps = 0;
|
||||
while (world._tarinaiRuntimePruneJob && shrinkSteps++ < 20) world.processTarinaiRuntimeCachePruneStep(32);
|
||||
assert(!world._tarinaiRuntimePruneJob, "aggressive population cleanup did not complete in chunks");
|
||||
assert.strictEqual(world.tarinai.length, 200, "dead tarinai were not compacted");
|
||||
assert.strictEqual(world._tarinaiRuntimeHighWater, 200, "population high-water mark did not collapse");
|
||||
assert.notStrictEqual(world._renderVisibleTarinaiScratch, oldRenderScratch, "render scratch retained peak backing store");
|
||||
assert.notStrictEqual(world._renderVisibleSeen, oldRenderSeen, "render seen-set retained peak entries");
|
||||
assert.strictEqual(world.tarinaiCollisionMemo.size, 0, "collision peak cache survived aggressive shrink");
|
||||
assert(historyTrimCalls > 0, "history snapshots were not memory-trimmed after population collapse");
|
||||
assert.strictEqual(world._lastTrimOptions.targetBytes, 8 * 1024 * 1024, "population-collapse history target is incorrect");
|
||||
}
|
||||
|
||||
function node(id, generation, alive, parents = [], children = []) {
|
||||
return { id, generation, alive, parents: [...parents], children: [...children], hasPaired: true };
|
||||
}
|
||||
|
||||
// A dead generation is protected while an older generation above it is alive.
|
||||
{
|
||||
const world = makeWorld();
|
||||
world.family = {
|
||||
A: node("A", 1, true, [], ["B"]),
|
||||
B: node("B", 2, false, ["A"], ["C"]),
|
||||
C: node("C", 3, true, ["B"], []),
|
||||
};
|
||||
world.tarinai = [{ id: "A", dead: false, parents: [], children: ["B"] }, { id: "C", dead: false, parents: ["B"], children: [] }];
|
||||
assert.strictEqual(world.pruneExtinctFamilies({ normalize: false }), false, "dead generation was pruned despite a living older generation");
|
||||
assert.deepStrictEqual(Object.keys(world.family).sort(), ["A", "B", "C"]);
|
||||
}
|
||||
|
||||
// Once all older generations are extinct, extinct leading generations are removed.
|
||||
{
|
||||
const world = makeWorld();
|
||||
world.family = {
|
||||
A: node("A", 1, false, [], ["B"]),
|
||||
B: node("B", 2, false, ["A"], ["C"]),
|
||||
C: node("C", 3, true, ["B"], []),
|
||||
};
|
||||
world.tarinai = [{ id: "C", dead: false, parents: ["B"], children: [] }];
|
||||
assert.strictEqual(world.pruneExtinctFamilies({ normalize: false }), true, "extinct leading generations were not pruned");
|
||||
assert.deepStrictEqual(Object.keys(world.family), ["C"], "unexpected family generations remained after pruning");
|
||||
assert.deepStrictEqual(world.family.C.parents, [], "survivor retained a deleted parent link");
|
||||
assert.deepStrictEqual(world.tarinai[0].parents, [], "live tarinai retained a deleted parent link");
|
||||
}
|
||||
|
||||
// A fully extinct disconnected family component may be dropped completely.
|
||||
{
|
||||
const world = makeWorld();
|
||||
world.family = {
|
||||
A: node("A", 1, false, [], ["B"]),
|
||||
B: node("B", 2, false, ["A"], []),
|
||||
X: node("X", 1, true, [], ["Y"]),
|
||||
Y: node("Y", 2, true, ["X"], []),
|
||||
};
|
||||
world.tarinai = [{ id: "X", dead: false, parents: [], children: ["Y"] }, { id: "Y", dead: false, parents: ["X"], children: [] }];
|
||||
assert.strictEqual(world.pruneExtinctFamilies({ normalize: false }), true);
|
||||
assert.deepStrictEqual(Object.keys(world.family).sort(), ["X", "Y"], "fully extinct component was retained or living component was removed");
|
||||
}
|
||||
|
||||
console.log("Population recovery regression audit passed.");
|
||||
|
|
@ -370,8 +370,8 @@ if (!placementResult || placementResult.removed || placementItem.dead) throw new
|
|||
click_section = placement.find("const itemType = toolItemType(tool)")
|
||||
click_direct = placement.find("const directTarget =", click_section)
|
||||
duplicator_set = placement.find("this.directSetDuplicatorAt?.", click_section)
|
||||
if min(click_section, click_direct, duplicator_set) < 0 or click_direct > duplicator_set:
|
||||
fail("Tarinai direct feeding must take precedence over duplicator loading at the same point")
|
||||
if min(click_section, click_direct, duplicator_set) < 0 or duplicator_set > click_direct:
|
||||
fail("Duplicator loading must take precedence over Tarinai direct feeding at the same point")
|
||||
required_ui = ["directFeedTargetAt", 'source: "pinch"', "setGiveHover", "hoverGiveTarget"]
|
||||
if any(token not in ui for token in required_ui):
|
||||
fail("pinch direct-feeding input bridge is incomplete")
|
||||
|
|
@ -1389,6 +1389,7 @@ context.window = context;
|
|||
context.isServingFoodType = (type) => type === 'food';
|
||||
context.passiveFoodDecayInterval = () => 0.5;
|
||||
context.passiveFoodDecayRate = () => 0.1;
|
||||
context.updateServingFoodVisualSize = (item) => {{ item.r = (item.r || 12) - 0.1; return true; }};
|
||||
context.TarinaiItemRegistry = {{ food: {{ }} }};
|
||||
context.isPinType = (type) => type === 'pushpin';
|
||||
context.normalizeGrassStage = (item) => {{ item.normalized = true; }};
|
||||
|
|
@ -1401,11 +1402,13 @@ const events = [];
|
|||
const world = {{
|
||||
itemDropImpact(item) {{ events.push(['drop', item.type]); }},
|
||||
markTerrainDirty(reason) {{ events.push(['terrain', reason]); }},
|
||||
markItemBucketsDirty(reason) {{ events.push(['buckets', reason]); }},
|
||||
markSpatialDirty(reason) {{ events.push(['spatial', reason]); }},
|
||||
emit(type, payload) {{ events.push(['emit', type, payload.type]); }},
|
||||
}};
|
||||
const food = {{ type: 'food', dead: false, age: 0, dropTimer: 0.1, dropImpactDone: false, foodServingsRemaining: 1, amount: 1, passiveFoodDecayTimer: 0 }};
|
||||
if (!context.TarinaiItemLifecyclePipeline.updateOne(food, 0.6, world, {{ legacyUpdate() {{ throw new Error('legacy should not run'); }} }})) throw new Error('food update failed');
|
||||
if (food.age !== 0.6 || !food.dropImpactDone || !(food.foodServingsRemaining < 1) || !events.some(([kind, value]) => kind === 'terrain' && value === 'food-passive-decay')) throw new Error('frame/decay step did not run');
|
||||
if (food.age !== 0.6 || !food.dropImpactDone || !(food.foodServingsRemaining < 1) || !events.some(([kind, value]) => kind === 'buckets' && value === 'food-passive-resize') || !events.some(([kind, value]) => kind === 'spatial' && value === 'food-passive-resize')) throw new Error('frame/decay step did not run');
|
||||
context.CONFIG = {{ worldPadding: 30 }};
|
||||
context.clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
||||
context.distXY = (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by);
|
||||
|
|
@ -2755,7 +2758,7 @@ vm.createContext(collisionContext);
|
|||
vm.runInContext({collisions!r}, collisionContext, {{ filename: 'collision_response_system.js' }});
|
||||
const ball = {{ id: 'ball', type: 'ball', x: 9, y: 0, prevX: -9, prevY: 0, r: 10, vx: 100, vy: 0, spinVelocity: 0, dead: false }};
|
||||
const sleeper = {{ id: 'sleeper', name: 'sleeper', x: 20, y: 0, radius: 20, vx: 0, vy: 0, state: 'sleep', sleeping: true, dead: false, energy: 100 }};
|
||||
const collisionWorld = {{ time: 1, w: 500, h: 500, itemCounts: {{ ball: 1, balloon: 0 }}, items: [ball], effects: [], itemsOfType(type) {{ return type === 'ball' ? [ball] : []; }}, nearbyTarinai() {{ return [sleeper]; }}, isTarinaiHiddenInNestBox() {{ return false; }}, markSpatialDirty() {{}}, relationNotice() {{ return false; }}, applyImpulse() {{}}, applyImpactDamage() {{}} }};
|
||||
const collisionWorld = {{ time: 1, w: 500, h: 500, itemCounts: {{ ball: 1, balloon: 0 }}, items: [ball], effects: [], spawnEffect() {{ return null; }}, itemsOfType(type) {{ return type === 'ball' ? [ball] : []; }}, nearbyTarinai() {{ return [sleeper]; }}, isTarinaiHiddenInNestBox() {{ return false; }}, markSpatialDirty() {{}}, relationNotice() {{ return false; }}, applyImpulse() {{}}, applyImpactDamage() {{}} }};
|
||||
collisionContext.TarinaiCollisionResponseSystem.resolveBallInteractions(collisionWorld, 0.016);
|
||||
if (!(ball.vx < 0) || !(sleeper.vx > 0) || sleepMotionMarks < 1) throw new Error('sleeping Tarinai did not physically collide with ball');
|
||||
|
||||
|
|
|
|||
16
scripts/render_stack_regression_audit.js
Normal file
16
scripts/render_stack_regression_audit.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const render = fs.readFileSync(path.join(root, "js", "render.js"), "utf8");
|
||||
const spatial = fs.readFileSync(path.join(root, "js", "sim_core.js"), "utf8");
|
||||
const budget = fs.readFileSync(path.join(root, "js", "world_spatial_budget.js"), "utf8");
|
||||
function ok(cond, msg) { if (!cond) throw new Error(msg); console.log(`[OK] ${msg}`); }
|
||||
ok(render.includes("_renderStaticVisibleCache") && render.includes("renderStaticVersion"), "static visible render stacks are version-cached");
|
||||
ok(!render.includes("insertionSortNearPrevious") && render.includes("dynamicLayered.sort(compareRenderEntries)"), "dynamic sorting uses native sort without previous-order bookkeeping");
|
||||
ok(render.includes("mergeSortedRenderEntries") && render.includes("mergeSortedBackItems"), "static and dynamic sorted stacks are merged linearly");
|
||||
ok(spatial.includes("linkRenderCells") && spatial.includes("addLinkRenderItem") && spatial.includes("rebuildLinkRenderItems"), "links use dedicated AABB render spatial cells");
|
||||
ok(!render.includes("for (const type of LINK_RENDER_TYPES)"), "per-frame full link type fallback scan is removed");
|
||||
ok(render.includes("drawCarriedPlushieAtOwner") && !render.includes("syncCarriedPlushieToOwner"), "carried plushie rendering no longer synchronizes simulation coordinates");
|
||||
ok(render.includes("entity._renderCullRadius = value"), "eligible fixed cull radii are cached");
|
||||
ok(render.includes("it._renderSortY = renderLayerSortY(it)"), "back-layer sort keys are computed before sorting");
|
||||
ok(budget.includes("this.renderStaticVersion = (this.renderStaticVersion || 0) + 1"), "static spatial rebuilds invalidate the render cache");
|
||||
106
scripts/secondary_achievement_regression_audit.js
Normal file
106
scripts/secondary_achievement_regression_audit.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
const path = require("path");
|
||||
const root = path.join(__dirname, "..");
|
||||
const achievementSource = fs.readFileSync(path.join(root, "js", "achievements.js"), "utf8");
|
||||
|
||||
class ClassList { add() {} remove() {} contains() { return false; } toggle() { return false; } }
|
||||
class Element {
|
||||
constructor() { this.classList = new ClassList(); this.children = []; this.dataset = {}; this.style = { setProperty() {}, removeProperty() {} }; this.hidden = true; }
|
||||
append(...children) { this.children.push(...children); }
|
||||
replaceChildren(...children) { this.children = children; }
|
||||
addEventListener() {} focus() {} setAttribute() {}
|
||||
getBoundingClientRect() { return { left: 0, top: 0, width: 100, height: 30 }; }
|
||||
get offsetWidth() { return 100; }
|
||||
}
|
||||
function harness() {
|
||||
const storage = new Map();
|
||||
const ids = ["achievementsBtn","achievementButtonCount","achievementsDialog","achievementDialogCount","achievementsCloseBtn","achievementsCloseIconBtn","achievementRefreshBtn","achievementResetBtn","achievementUnlockAllBtn","achievementList","achievementSharedStatus","achievementToast","achievementToastTitle","pauseBtn"];
|
||||
const elements = new Map(ids.map(id => [id, new Element()]));
|
||||
let timer = 0;
|
||||
const c = {
|
||||
console, Date, Intl, Math, JSON, Object, Array, Map, Set, WeakMap, Promise, Number, String, Boolean, Error, AbortController, encodeURIComponent, URLSearchParams,
|
||||
location: { protocol: "file:", search: "" }, crypto: { randomUUID: () => "11111111-1111-4111-8111-111111111111" },
|
||||
localStorage: { getItem: k => storage.get(k) ?? null, setItem: (k, v) => storage.set(k, String(v)), removeItem: k => storage.delete(k) },
|
||||
document: { hidden: false, getElementById: id => elements.get(id) || null, createElement: () => new Element(), addEventListener() {} },
|
||||
TarinaiEvents: { emit() {} }, TarinaiGameDialogs: { confirm: async () => true }, showToast() {}, audio: { uiClick() {} },
|
||||
setTimeout() { return ++timer; }, clearTimeout() {}, setInterval() { return ++timer; }, clearInterval() {}, fetch: async () => { throw new Error("offline"); },
|
||||
TARINAI_VERSION: "39.16.77",
|
||||
};
|
||||
c.window = c; c.globalThis = c;
|
||||
vm.createContext(c); vm.runInContext(achievementSource, c, { filename: "achievements.js" });
|
||||
return { c, api: c.TarinaiAchievements };
|
||||
}
|
||||
function assert(value, message) { if (!value) throw new Error(message); }
|
||||
|
||||
(async function run() {
|
||||
// Sauna timing must start on entering the hot state, not refresh every hot frame.
|
||||
let h = harness();
|
||||
let temperature = 40;
|
||||
const t = { dead: false, x: 0, y: 0 };
|
||||
const world = {
|
||||
time: 0, tarinai: [t], ants: [], items: [],
|
||||
feltTemperatureFor() { return temperature; },
|
||||
temperatureStatusFor(v) { return { direction: v > 25 ? "hot" : (v < 10 ? "cold" : "comfort"), comfortable: v >= 10 && v <= 25 }; },
|
||||
};
|
||||
h.api.evaluateWorld(world, {});
|
||||
world.time = 20; h.api.evaluateWorld(world, {}); // Still hot: must not restart the timer.
|
||||
world.time = 20.1; temperature = 0; h.api.evaluateWorld(world, {});
|
||||
assert(!h.api.isUnlocked("sauna_cold_plunge"), "sauna timer refreshed while continuously hot");
|
||||
world.time = 30; temperature = 40; h.api.evaluateWorld(world, {}); // New hot entry.
|
||||
world.time = 40; temperature = 0; h.api.evaluateWorld(world, {});
|
||||
assert(h.api.isUnlocked("sauna_cold_plunge"), "sauna timer did not restart on a genuine new hot entry");
|
||||
|
||||
// Achievement reset must clear per-world/per-object hidden progress.
|
||||
h = harness();
|
||||
const tarinai = {
|
||||
dead: false,
|
||||
_achievementDirectFeedCount: 9,
|
||||
_achievementDirectTreatmentCount: 3,
|
||||
_achievementSaunaHotAt: 12,
|
||||
_achievementSaunaWasHot: true,
|
||||
_achievementHeldStartedAt: 1234,
|
||||
};
|
||||
const bomb = { type: "sticky_bomb", stickyBombPassCount: 14, _achievementPlayerPlaced: true, _achievementPlacedAt: 9 };
|
||||
const resetWorld = { time: 100, tarinai: [tarinai], items: [bomb] };
|
||||
h.c.world = resetWorld;
|
||||
await h.api.reset();
|
||||
assert(tarinai._achievementDirectFeedCount === 0 && tarinai._achievementDirectTreatmentCount === 0, "care progress survived achievement reset");
|
||||
assert(tarinai._achievementSaunaHotAt == null && tarinai._achievementHeldStartedAt == null, "timed per-tarinai progress survived achievement reset");
|
||||
assert(bomb.stickyBombPassCount === 0, "sticky-bomb relay progress survived achievement reset");
|
||||
assert(bomb._achievementPlayerPlaced === true && bomb._achievementPlacedAt == null, "reset should preserve placement identity but clear quick-delete timing");
|
||||
|
||||
// World-changing history/ground operations are interventions, so passive observation must restart.
|
||||
h = harness();
|
||||
const observerWorld = { time: 0, tarinai: [], ants: [], items: [], fieldType: "garden", groundType: "soil" };
|
||||
h.api.evaluateWorld(observerWorld, {});
|
||||
observerWorld.time = 359.9; h.api.evaluateWorld(observerWorld, {});
|
||||
assert(!h.api.isUnlocked("idle_observer_5_minutes"), "observer unlocked before three game days");
|
||||
h.api.recordHistoryAction("undo", { world: observerWorld, deadRestored: 0 });
|
||||
observerWorld.time = 360; h.api.evaluateWorld(observerWorld, {});
|
||||
assert(!h.api.isUnlocked("idle_observer_5_minutes"), "undo did not reset passive-observation progress");
|
||||
observerWorld.time = 500;
|
||||
h.api.recordGroundChange({ world: observerWorld, previous: "soil", next: "ice", now: 1000 });
|
||||
observerWorld.time = 859.9; h.api.evaluateWorld(observerWorld, {});
|
||||
assert(!h.api.isUnlocked("idle_observer_5_minutes"), "ground change did not reset passive-observation progress");
|
||||
observerWorld.time = 860; h.api.evaluateWorld(observerWorld, {});
|
||||
assert(h.api.isUnlocked("idle_observer_5_minutes"), "observer did not unlock after three uninterrupted game days");
|
||||
|
||||
// Minimalist needs player-placement identity even after Unplanned City is already unlocked.
|
||||
const snapshotSource = fs.readFileSync(path.join(root, "js", "snapshot_system.js"), "utf8");
|
||||
assert(snapshotSource.includes('const needPlayerPlacedMetadata = needQuickDelete || needMinimalist;'), "minimalist placement metadata is not retained independently of quick-delete progress");
|
||||
assert(snapshotSource.includes('needPlayerPlacedMetadata && rec?.item?._achievementPlayerPlaced ? 1 : 0'), "snapshot does not serialize minimalist placement identity");
|
||||
|
||||
// Lethal shocks must count before damage can mark the target dead.
|
||||
const signalSource = fs.readFileSync(path.join(root, "js", "signal_system.js"), "utf8");
|
||||
const shockStart = signalSource.indexOf("function shockTarget");
|
||||
const shockEnd = signalSource.indexOf("function damageFromWires", shockStart);
|
||||
const shockBody = signalSource.slice(shockStart, shockEnd);
|
||||
const recordAt = shockBody.indexOf("recordWireShock");
|
||||
const damageAt = shockBody.indexOf("target.damage?.");
|
||||
assert(recordAt >= 0 && damageAt >= 0 && recordAt < damageAt, "wire-shock achievement is still recorded after potentially lethal damage");
|
||||
|
||||
console.log("[OK] secondary achievement regressions: sauna onset, reset cleanup, observer interventions, minimalist save metadata, and lethal wire shocks passed");
|
||||
})();
|
||||
22
scripts/social_interaction_regression_audit.js
Normal file
22
scripts/social_interaction_regression_audit.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"use strict";
|
||||
const fs = require("fs");
|
||||
const moveSrc = fs.readFileSync("js/tarinai_social_move_life.js", "utf8");
|
||||
const needSrc = fs.readFileSync("js/tarinai_needs_items.js", "utf8");
|
||||
const actionSrc = fs.readFileSync("js/tarinai_action_definitions.js", "utf8");
|
||||
const colonySrc = fs.readFileSync("js/colony_situation_system.js", "utf8");
|
||||
function ok(cond, msg) { if (!cond) { console.error(`[FAIL] ${msg}`); process.exitCode = 1; } else console.log(`[OK] ${msg}`); }
|
||||
ok(!moveSrc.includes('this.socialCrowdSample = {'), "contact scan no longer records crowd density");
|
||||
ok(!needSrc.includes('const crowdSample = tarinai.socialCrowdSample'), "relation need no longer consumes crowd pressure");
|
||||
ok(!actionSrc.includes('createCrowdEscapeActionSpec(),'), "crowd retreat action is not registered");
|
||||
ok(!colonySrc.includes('nearestAverage('), "colony status no longer performs O(N^2) crowd distance scans");
|
||||
ok(!colonySrc.includes('add("overcrowded"'), "overcrowded colony status is no longer selected");
|
||||
ok(!moveSrc.includes('const candidateBudget = Math.max(20, scanLimit + 8)'), "contact candidates are no longer truncated by spatial bucket order");
|
||||
ok(!moveSrc.includes('if (inspected >= candidateBudget) break'), "contact scans do not stop before considering nearer later candidates");
|
||||
ok(moveSrc.includes('if (pos < scanLimit)'), "contact scan keeps a bounded nearest-candidate list");
|
||||
ok(moveSrc.includes('if (detailed.length > scanLimit) detailed.pop()'), "nearest-candidate list remains bounded under dense populations");
|
||||
ok(moveSrc.includes('const pairLeader = String(this.id) < String(o.id)'), "symmetric pair work has one deterministic leader");
|
||||
ok(moveSrc.includes('const d2 = dx * dx + dy * dy'), "distance-squared prefilter is used");
|
||||
ok(moveSrc.includes('const selfProfile = this.personalityProfile()'), "self personality profile is cached per scan");
|
||||
ok(!moveSrc.includes('open.sort('), "social interaction does not introduce full sorting");
|
||||
ok(moveSrc.includes('const combinedStartChance = 1 - (1 - oneDirectionChance) * (1 - oneDirectionChance)'), "single-pass pair processing preserves old start probability");
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
85
scripts/ui_grass_limits_regression_audit.py
Normal file
85
scripts/ui_grass_limits_regression_audit.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
def text(rel):
|
||||
return (ROOT / rel).read_text(encoding='utf-8')
|
||||
|
||||
def must(cond, msg):
|
||||
if not cond:
|
||||
raise AssertionError(msg)
|
||||
|
||||
index = text('index.html')
|
||||
ui_bind = text('js/ui_bind.js')
|
||||
ui_mouse = text('js/ui_input_mouse.js')
|
||||
ui_shared = text('js/ui_input_shared.js')
|
||||
view = text('js/world_view.js')
|
||||
data = text('js/data.js')
|
||||
temp = text('js/world_temperature_system.js')
|
||||
disease = text('js/tarinai_item_effects.js')
|
||||
spatial = text('js/sim_core.js')
|
||||
spatial_budget = text('js/world_spatial_budget.js')
|
||||
targeting = text('js/tarinai_item_targeting.js')
|
||||
ach = text('js/achievements.js')
|
||||
|
||||
# Tool mode survives ordinary controls, but blank UI space explicitly clears it.
|
||||
must('isInteractiveUiTarget' in ui_bind and 'clearSelectedToolSilently' in ui_bind,
|
||||
'blank-UI tool cancellation helpers are missing')
|
||||
must('!canvas?.contains?.(target) && !isInteractiveUiTarget(target)' in ui_bind,
|
||||
'blank UI space is not the exclusive document-click cancellation path')
|
||||
must('target.closest("button, a, input, select, textarea, label, summary' in ui_bind,
|
||||
'ordinary UI controls are not protected from tool cancellation')
|
||||
|
||||
# Limits: 300, next slot 301 = infinity, default infinity.
|
||||
must('COLONY_LIMIT_MAX = 300' in ui_bind, 'colony limit max is not 300')
|
||||
must('COLONY_LIMIT_INFINITY_SLOT = 301' in ui_bind, 'infinity slot is not 301')
|
||||
must(re.search(r'max="301"[^>]*value="301"', index) is not None, 'limit slider does not default to infinity slot')
|
||||
|
||||
# Right-drag context-menu suppression.
|
||||
must('suppressContextMenuUntil' in ui_mouse and 'contextmenu' in ui_mouse, 'right-pan context-menu suppression missing')
|
||||
must('suppressContextMenuUntil' in ui_shared, 'right-pan suppression timestamp is not set on pan end')
|
||||
|
||||
# Zoom.
|
||||
must(re.search(r'BASE_ZOOM_MAX\s*=\s*4\.5', view) is not None, 'zoom maximum is not 4.5')
|
||||
|
||||
# Disease probability reduced to one-third at the shared helper.
|
||||
must(re.search(r'Number\(base\)[^\n]*/\s*3', disease) is not None or '/ 3' in disease[disease.find('diseaseChance'):disease.find('diseaseChance')+240], 'diseaseChance is not reduced to one-third')
|
||||
|
||||
# Summer correction removed completely.
|
||||
must(re.search(r'summerTemperatureBoost\s*:\s*0\b', data) is not None, 'summerTemperatureBoost is not zero')
|
||||
body = re.search(r'(?:function\s+)?seasonTemperatureBias\s*\([^)]*\)\s*\{([\s\S]*?)\n\s*\}', temp)
|
||||
must(body is not None and re.search(r'\breturn\s+0\s*;', body.group(1)), 'seasonTemperatureBias does not return zero')
|
||||
|
||||
# Grass: dedicated spatial index and no global grass fallback re-add in food search.
|
||||
must('grassCells = new Map()' in spatial, 'dedicated grassCells index missing')
|
||||
must('nearbyGrass(' in spatial_budget and 'spatial.grassCells' in spatial_budget, 'nearbyGrass spatial query missing')
|
||||
must('type === "grass"' in targeting and 'nearbyFood' in targeting, 'grass global-bucket skip guard missing')
|
||||
# Ensure the hot-path global type bucket explicitly skips grass when nearbyFood exists.
|
||||
must(re.search(r'if\s*\(\s*type\s*===\s*["\']grass["\'][^)]*nearbyFood', targeting) is not None,
|
||||
'food targeting does not skip the global grass bucket')
|
||||
|
||||
# Achievements 73-75 and persistent Memento Mori progress.
|
||||
for aid in ('well_informed', 'lively_making', 'memento_mori'):
|
||||
must(aid in ach, f'achievement {aid} missing')
|
||||
must('memento_mori' in ach and 'PRE_UNLOCK_DESCRIPTION_IDS' in ach, 'Memento Mori pre-unlock condition visibility missing')
|
||||
must('totalDeathCount' in ach, 'persistent total death counter missing')
|
||||
|
||||
|
||||
# Current UI/achievement/colony changes.
|
||||
placement_preview = text('js/placement_preview_system.js')
|
||||
placement_log = text('js/world_placement_log.js')
|
||||
colony = text('js/colony_situation_system.js')
|
||||
components = text('css/components.css')
|
||||
|
||||
must('achievementToastCondition' in index and 'achievement-toast-condition' in components, 'achievement unlock toast condition line missing')
|
||||
must('if (dom.toastCondition) dom.toastCondition.textContent = definition.description || "";' in ach, 'achievement toast does not render the completion condition')
|
||||
must('lively_making' in ach and 'PRE_UNLOCK_DESCRIPTION_IDS' in ach and 'manualTarinaiAddedCount' in ach, 'Lively Making persistent/pre-unlock progress is missing')
|
||||
must(r'title: "\u7e41\u6b96\u30fb\u4eba\u53e3"' in ach and r'title: "\u64cd\u4f5c"' in ach and 'id: "other"' in ach, 'six-category achievement reorganization missing')
|
||||
must('num(rect.bottom) > (worldRef.h || 0)' in placement_preview, 'placement preview still reserves an artificial bottom padding')
|
||||
must('rect.bottom > this.h' in placement_log, 'confirmed placement still reserves an artificial bottom padding')
|
||||
must('Math.max(5, Math.ceil(m.n * 0.10))' in colony, 'crisis mortality threshold is still too loose')
|
||||
must('criticalRatio' in colony and 'crisisMortalitySignal && crisisSystemicStress' in colony and 'crisisCollapseSignal' in colony, 'crisis no longer requires compound severe conditions')
|
||||
must('!m.populationIncreasing' in colony, 'crisis population-growth exclusion was lost')
|
||||
|
||||
print('[OK] UI/limits/input/zoom/disease/grass/summer/achievement/placement/crisis regression audit passed')
|
||||
Loading…
Add table
Add a link
Reference in a new issue