658 lines
20 KiB
JavaScript
658 lines
20 KiB
JavaScript
import { createServer } from 'vite';
|
|
|
|
const storage = new Map();
|
|
let rejectStorageWrites = false;
|
|
globalThis.window = {
|
|
localStorage: {
|
|
getItem(key) {
|
|
return storage.has(key) ? storage.get(key) : null;
|
|
},
|
|
setItem(key, value) {
|
|
if (rejectStorageWrites) {
|
|
throw new Error('Simulated campaign storage write failure.');
|
|
}
|
|
storage.set(key, String(value));
|
|
},
|
|
removeItem(key) {
|
|
storage.delete(key);
|
|
},
|
|
clear() {
|
|
storage.clear();
|
|
}
|
|
}
|
|
};
|
|
|
|
const server = await createServer({
|
|
logLevel: 'error',
|
|
server: { middlewareMode: true, hmr: false },
|
|
appType: 'custom'
|
|
});
|
|
|
|
try {
|
|
const dataModule = await server.ssrLoadModule(
|
|
'/src/game/data/secondBattleReliefExploration.ts'
|
|
);
|
|
const memoryModule = await server.ssrLoadModule(
|
|
'/src/game/data/secondBattleReliefMemory.ts'
|
|
);
|
|
const actionModule = await server.ssrLoadModule(
|
|
'/src/game/state/secondBattleReliefActions.ts'
|
|
);
|
|
const campaignModule = await server.ssrLoadModule(
|
|
'/src/game/state/campaignState.ts'
|
|
);
|
|
const { battleScenarios } = await server.ssrLoadModule(
|
|
'/src/game/data/battles.ts'
|
|
);
|
|
const { usableCatalog } = await server.ssrLoadModule(
|
|
'/src/game/data/battleUsables.ts'
|
|
);
|
|
const explorationAssets = await server.ssrLoadModule(
|
|
'/src/game/data/explorationCharacterAssets.ts'
|
|
);
|
|
const portraitAssets = await server.ssrLoadModule(
|
|
'/src/game/data/portraitAssets.ts'
|
|
);
|
|
const firstScoutModule = await server.ssrLoadModule(
|
|
'/src/game/data/firstPursuitScoutMemory.ts'
|
|
);
|
|
|
|
verifyCanonicalDefinition(
|
|
dataModule,
|
|
battleScenarios,
|
|
usableCatalog,
|
|
explorationAssets,
|
|
portraitAssets
|
|
);
|
|
verifyBattleResultAndScoutReview(
|
|
dataModule,
|
|
memoryModule,
|
|
firstScoutModule
|
|
);
|
|
verifyTargetBattleMemories(dataModule, memoryModule);
|
|
verifyGuardedPersistentProgress(
|
|
dataModule,
|
|
memoryModule,
|
|
actionModule,
|
|
campaignModule,
|
|
battleScenarios
|
|
);
|
|
|
|
console.log(
|
|
'Second-battle relief exploration verification passed (canonical route data, ordered resumable objectives, first-scout result review, guarded rewards, and third-battle memory isolation).'
|
|
);
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
|
|
function verifyCanonicalDefinition(
|
|
dataModule,
|
|
battleScenarios,
|
|
usableCatalog,
|
|
explorationAssets,
|
|
portraitAssets
|
|
) {
|
|
const definition =
|
|
dataModule.secondBattleReliefExplorationDefinition;
|
|
const source =
|
|
battleScenarios[dataModule.secondBattleReliefSourceBattleId];
|
|
const target =
|
|
battleScenarios[dataModule.secondBattleReliefTargetBattleId];
|
|
assert(
|
|
definition.id === dataModule.secondBattleReliefVisitId &&
|
|
definition.sourceBattleId === source.id &&
|
|
definition.targetBattleId === target.id,
|
|
'The relief route must stay scoped from the second victory to the third battle.'
|
|
);
|
|
assert(
|
|
source.objectives.some(
|
|
(objective) =>
|
|
objective.id ===
|
|
dataModule.secondBattleReliefSourceObjectiveIds.village
|
|
) &&
|
|
source.objectives.some(
|
|
(objective) =>
|
|
objective.id ===
|
|
dataModule.secondBattleReliefSourceObjectiveIds.quick
|
|
),
|
|
'The relief memory must reference the authored village and quick-victory objectives.'
|
|
);
|
|
|
|
const actorIds = new Set();
|
|
definition.actors.forEach((actor) => {
|
|
assert(!actorIds.has(actor.id), `Duplicate relief actor id ${actor.id}.`);
|
|
actorIds.add(actor.id);
|
|
assert(
|
|
pointInside(actor, definition.movementBounds),
|
|
`Actor ${actor.id} must stay inside the FHD exploration movement bounds.`
|
|
);
|
|
assert(
|
|
explorationAssets.hasExplorationCharacterAsset(actor.textureKey),
|
|
`Actor ${actor.id} must use an existing exploration character sheet.`
|
|
);
|
|
assert(
|
|
typeof actor.portraitKey === 'string' &&
|
|
portraitAssets.portraitAssetEntriesForKey(actor.portraitKey)
|
|
.length > 0,
|
|
`Actor ${actor.id} must use an existing face portrait in dialogue.`
|
|
);
|
|
assert(
|
|
actor.dialogue.length >= 2 && actor.repeatDialogue.length >= 1,
|
|
`Actor ${actor.id} must have first-time and repeat dialogue.`
|
|
);
|
|
});
|
|
assert(
|
|
pointInside(definition.player, definition.movementBounds) &&
|
|
pointInside(definition.exit, definition.movementBounds),
|
|
'The player start and camp exit must stay inside the movement bounds.'
|
|
);
|
|
|
|
const objectiveIds = new Set();
|
|
definition.objectives.forEach((objective, index) => {
|
|
assert(
|
|
!objectiveIds.has(objective.id),
|
|
`Duplicate relief objective id ${objective.id}.`
|
|
);
|
|
assert(
|
|
actorIds.has(objective.targetActorId),
|
|
`Objective ${objective.id} must point to a canonical actor.`
|
|
);
|
|
objective.prerequisiteObjectiveIds.forEach((prerequisiteId) => {
|
|
assert(
|
|
objectiveIds.has(prerequisiteId),
|
|
`Objective ${objective.id} prerequisite ${prerequisiteId} must appear earlier in the route.`
|
|
);
|
|
});
|
|
assert(
|
|
objective.id ===
|
|
dataModule.secondBattleReliefObjectiveIds[index],
|
|
`Objective ${objective.id} must retain the authored route order.`
|
|
);
|
|
objectiveIds.add(objective.id);
|
|
});
|
|
assert(
|
|
JSON.stringify(
|
|
dataModule.secondBattleReliefPrerequisiteObjectiveIds
|
|
) ===
|
|
JSON.stringify(
|
|
dataModule.secondBattleReliefObjectiveIds.slice(0, -1)
|
|
),
|
|
'Every field interaction before the final Jian Yong choice must be a persisted prerequisite.'
|
|
);
|
|
|
|
assert(
|
|
JSON.stringify(definition.choices.map(({ id }) => id)) ===
|
|
JSON.stringify(dataModule.secondBattleReliefChoiceIds),
|
|
'The two relief choices must remain canonical and ordered.'
|
|
);
|
|
definition.choices.forEach((choice) => {
|
|
const effect = choice.thirdBattleEffect;
|
|
assert(
|
|
choice.bondExp > 0 &&
|
|
choice.itemRewards.length === 1 &&
|
|
/^.+\s+\d+$/.test(choice.itemRewards[0]),
|
|
`Choice ${choice.id} must provide valid bond and item feedback.`
|
|
);
|
|
assert(
|
|
target.objectives.some(
|
|
(objective) => objective.id === effect.focusObjectiveId
|
|
),
|
|
`Choice ${choice.id} must focus an objective in the third battle.`
|
|
);
|
|
assert(
|
|
target.units.some(
|
|
(unit) =>
|
|
unit.id === effect.recommendedUnitId &&
|
|
unit.faction === 'ally'
|
|
),
|
|
`Choice ${choice.id} must recommend an allied third-battle unit.`
|
|
);
|
|
if (effect.kind === 'village-supply-line') {
|
|
assert(
|
|
usableCatalog[effect.carriedUsableId]?.command === 'item',
|
|
'The village supply payoff must reference a real battle item.'
|
|
);
|
|
} else {
|
|
assert(
|
|
target.units.some(
|
|
(unit) =>
|
|
unit.id === effect.trackedEnemyUnitId &&
|
|
unit.faction === 'enemy'
|
|
),
|
|
'The ferry intelligence payoff must track the authored third-battle leader.'
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
function verifyBattleResultAndScoutReview(
|
|
dataModule,
|
|
memoryModule,
|
|
firstScoutModule
|
|
) {
|
|
const cases = [
|
|
{
|
|
choiceId: firstScoutModule.firstPursuitScoutChoiceIds[0],
|
|
villageAchieved: true,
|
|
quickAchieved: true,
|
|
expectedFocus: 'river',
|
|
expectedOutcome: 'confirmed'
|
|
},
|
|
{
|
|
choiceId: firstScoutModule.firstPursuitScoutChoiceIds[0],
|
|
villageAchieved: true,
|
|
quickAchieved: false,
|
|
expectedFocus: 'river',
|
|
expectedOutcome: 'missed'
|
|
},
|
|
{
|
|
choiceId: firstScoutModule.firstPursuitScoutChoiceIds[1],
|
|
villageAchieved: true,
|
|
quickAchieved: false,
|
|
expectedFocus: 'village',
|
|
expectedOutcome: 'confirmed'
|
|
},
|
|
{
|
|
choiceId: firstScoutModule.firstPursuitScoutChoiceIds[1],
|
|
villageAchieved: false,
|
|
quickAchieved: true,
|
|
expectedFocus: 'village',
|
|
expectedOutcome: 'missed'
|
|
}
|
|
];
|
|
|
|
cases.forEach((testCase) => {
|
|
const campaign = literalCampaign(dataModule, {
|
|
villageAchieved: testCase.villageAchieved,
|
|
quickAchieved: testCase.quickAchieved,
|
|
firstScoutVisitId: firstScoutModule.firstPursuitScoutVisitId,
|
|
firstScoutChoiceId: testCase.choiceId
|
|
});
|
|
const context =
|
|
memoryModule.resolveSecondBattleReliefContext(campaign);
|
|
assert(
|
|
context?.scoutReview.choiceId === testCase.choiceId &&
|
|
context.scoutReview.focus === testCase.expectedFocus &&
|
|
context.scoutReview.outcome === testCase.expectedOutcome,
|
|
`The first scout choice must be reviewed against its matching second-battle result: ${JSON.stringify({
|
|
testCase,
|
|
context
|
|
})}`
|
|
);
|
|
});
|
|
|
|
const unrecorded =
|
|
memoryModule.resolveSecondBattleReliefContext(
|
|
literalCampaign(dataModule, {
|
|
villageAchieved: false,
|
|
quickAchieved: false
|
|
})
|
|
);
|
|
assert(
|
|
unrecorded?.scoutReview.outcome === 'unrecorded',
|
|
'Legacy saves without a first-scout marker must receive a safe unrecorded fallback.'
|
|
);
|
|
}
|
|
|
|
function verifyTargetBattleMemories(dataModule, memoryModule) {
|
|
dataModule.secondBattleReliefChoiceIds.forEach((choiceId) => {
|
|
const campaign = literalCampaign(dataModule, {
|
|
villageAchieved: choiceId ===
|
|
'restore-northern-village',
|
|
quickAchieved: choiceId === 'trace-ferry-messenger',
|
|
reliefChoiceId: choiceId
|
|
});
|
|
const memory = memoryModule.resolveSecondBattleReliefMemory({
|
|
campaign,
|
|
battleId: dataModule.secondBattleReliefTargetBattleId
|
|
});
|
|
assert(
|
|
memory?.choiceId === choiceId &&
|
|
memory.effect.readiness === 'reinforced' &&
|
|
memory.effect.sourceObjectiveAchieved === true &&
|
|
memory.openingLines.length === 3 &&
|
|
memory.openingLines[0] === memory.campSummaryLine,
|
|
`Choice ${choiceId} must resolve a reinforced third-battle memory.`
|
|
);
|
|
|
|
const recoveredCampaign = literalCampaign(dataModule, {
|
|
villageAchieved: false,
|
|
quickAchieved: false,
|
|
reliefChoiceId: choiceId
|
|
});
|
|
const recovered = memoryModule.resolveSecondBattleReliefMemory({
|
|
campaign: recoveredCampaign,
|
|
battleId: dataModule.secondBattleReliefTargetBattleId
|
|
});
|
|
assert(
|
|
recovered?.effect.readiness === 'recovered' &&
|
|
recovered.effect.sourceObjectiveAchieved === false,
|
|
`Choice ${choiceId} must turn a missed source objective into a clearly labelled recovery, not a false success.`
|
|
);
|
|
assert(
|
|
memoryModule.resolveSecondBattleReliefMemory({
|
|
campaign,
|
|
battleId: dataModule.secondBattleReliefSourceBattleId
|
|
}) === undefined,
|
|
`Choice ${choiceId} memory must not leak back into the source battle.`
|
|
);
|
|
});
|
|
|
|
const forged = literalCampaign(dataModule, {
|
|
villageAchieved: true,
|
|
quickAchieved: true,
|
|
reliefChoiceId: 'forged-choice'
|
|
});
|
|
assert(
|
|
memoryModule.resolveSecondBattleReliefMemory({
|
|
campaign: forged,
|
|
battleId: dataModule.secondBattleReliefTargetBattleId
|
|
}) === undefined,
|
|
'A forged relief choice must never produce a third-battle effect.'
|
|
);
|
|
}
|
|
|
|
function verifyGuardedPersistentProgress(
|
|
dataModule,
|
|
memoryModule,
|
|
actionModule,
|
|
campaignModule,
|
|
battleScenarios
|
|
) {
|
|
storage.clear();
|
|
campaignModule.resetCampaignState();
|
|
assert(
|
|
actionModule.recordSecondBattleReliefObjective(
|
|
dataModule.secondBattleReliefObjectiveIds[0]
|
|
).reason === 'invalid-campaign',
|
|
'Relief progress must reject completion before the second victory camp.'
|
|
);
|
|
|
|
prepareSecondVictory(
|
|
campaignModule,
|
|
battleScenarios,
|
|
dataModule,
|
|
{ villageAchieved: true, quickAchieved: false }
|
|
);
|
|
assert(
|
|
actionModule.recordSecondBattleReliefObjective(
|
|
dataModule.secondBattleReliefObjectiveIds[1]
|
|
).reason === 'prerequisites-incomplete',
|
|
'The ferry testimony must not be recordable before the village check.'
|
|
);
|
|
assert(
|
|
actionModule.completeSecondBattleReliefExploration(
|
|
dataModule.secondBattleReliefChoiceIds[0]
|
|
).reason === 'prerequisites-incomplete',
|
|
'The final choice must remain locked until every field objective is persisted.'
|
|
);
|
|
|
|
dataModule.secondBattleReliefPrerequisiteObjectiveIds.forEach(
|
|
(objectiveId, index) => {
|
|
const result =
|
|
actionModule.recordSecondBattleReliefObjective(objectiveId);
|
|
assert(
|
|
result.ok === true &&
|
|
result.completedObjectiveIds.length === index + 1,
|
|
`Objective ${objectiveId} must persist in authored order.`
|
|
);
|
|
}
|
|
);
|
|
const resumedProgress = actionModule.secondBattleReliefProgress();
|
|
assert(
|
|
resumedProgress.choiceReady === true &&
|
|
JSON.stringify(resumedProgress.completedObjectiveIds) ===
|
|
JSON.stringify(
|
|
dataModule.secondBattleReliefPrerequisiteObjectiveIds
|
|
),
|
|
'Reloadable campaign markers must reconstruct all completed field objectives.'
|
|
);
|
|
|
|
const choice =
|
|
dataModule.secondBattleReliefExplorationDefinition.choices[0];
|
|
const inventoryBefore =
|
|
campaignModule.getCampaignState().inventory;
|
|
const completed =
|
|
actionModule.completeSecondBattleReliefExploration(choice.id);
|
|
assert(
|
|
completed.ok === true &&
|
|
completed.memory.choiceId === choice.id &&
|
|
completed.memory.targetBattleId ===
|
|
dataModule.secondBattleReliefTargetBattleId &&
|
|
completed.campaign.completedCampVisits.includes(
|
|
dataModule.secondBattleReliefVisitId
|
|
) &&
|
|
completed.campaign.campVisitChoiceIds[
|
|
dataModule.secondBattleReliefVisitId
|
|
] === choice.id,
|
|
'A valid final choice must atomically save the route, reward, and third-battle memory.'
|
|
);
|
|
assertRewardApplied(choice, inventoryBefore, completed.campaign);
|
|
const completedProgress = actionModule.secondBattleReliefProgress();
|
|
assert(
|
|
completedProgress.completed === true &&
|
|
completedProgress.nextObjectiveId === null &&
|
|
completedProgress.choiceReady === false,
|
|
'A completed route must not keep advertising a stale final objective or another choice.'
|
|
);
|
|
assert(
|
|
actionModule.completeSecondBattleReliefExploration(choice.id)
|
|
.reason === 'already-completed',
|
|
'The completed route must reject duplicate rewards.'
|
|
);
|
|
|
|
prepareSecondVictory(
|
|
campaignModule,
|
|
battleScenarios,
|
|
dataModule,
|
|
{ villageAchieved: false, quickAchieved: true }
|
|
);
|
|
dataModule.secondBattleReliefPrerequisiteObjectiveIds.forEach(
|
|
(objectiveId) => {
|
|
const result =
|
|
actionModule.recordSecondBattleReliefObjective(objectiveId);
|
|
assert(result.ok === true, `Expected progress for ${objectiveId}.`);
|
|
}
|
|
);
|
|
const beforeFailedSave = progressSnapshot(
|
|
campaignModule.getCampaignState(),
|
|
dataModule
|
|
);
|
|
let failedSave;
|
|
rejectStorageWrites = true;
|
|
try {
|
|
failedSave = actionModule.completeSecondBattleReliefExploration(
|
|
dataModule.secondBattleReliefChoiceIds[1]
|
|
);
|
|
} finally {
|
|
rejectStorageWrites = false;
|
|
}
|
|
const afterFailedSave = progressSnapshot(
|
|
campaignModule.getCampaignState(),
|
|
dataModule
|
|
);
|
|
assert(
|
|
failedSave?.ok === false &&
|
|
failedSave.reason === 'save-unavailable' &&
|
|
JSON.stringify(afterFailedSave) ===
|
|
JSON.stringify(beforeFailedSave),
|
|
`A failed save must restore the route, rewards, and memory markers: ${JSON.stringify({
|
|
failedSave,
|
|
beforeFailedSave,
|
|
afterFailedSave
|
|
})}`
|
|
);
|
|
|
|
const finalCampaign = campaignModule.getCampaignState();
|
|
assert(
|
|
memoryModule.resolveSecondBattleReliefMemory({
|
|
campaign: finalCampaign,
|
|
battleId: dataModule.secondBattleReliefTargetBattleId
|
|
}) === undefined,
|
|
'A failed final write must not leave a ghost third-battle memory.'
|
|
);
|
|
}
|
|
|
|
function prepareSecondVictory(
|
|
campaignModule,
|
|
battleScenarios,
|
|
dataModule,
|
|
{ villageAchieved, quickAchieved }
|
|
) {
|
|
storage.clear();
|
|
campaignModule.resetCampaignState();
|
|
const scenario =
|
|
battleScenarios[dataModule.secondBattleReliefSourceBattleId];
|
|
campaignModule.setFirstBattleReport({
|
|
battleId: scenario.id,
|
|
battleTitle: scenario.title,
|
|
outcome: 'victory',
|
|
turnNumber: quickAchieved ? 18 : 27,
|
|
rewardGold: scenario.baseVictoryGold,
|
|
defeatedEnemies: scenario.units.filter(
|
|
(unit) => unit.faction === 'enemy'
|
|
).length,
|
|
totalEnemies: scenario.units.filter(
|
|
(unit) => unit.faction === 'enemy'
|
|
).length,
|
|
objectives: scenario.objectives.map((objective) => {
|
|
const achieved = objective.id ===
|
|
dataModule.secondBattleReliefSourceObjectiveIds.village
|
|
? villageAchieved
|
|
: objective.id ===
|
|
dataModule.secondBattleReliefSourceObjectiveIds.quick
|
|
? quickAchieved
|
|
: true;
|
|
return {
|
|
id: objective.id,
|
|
label: objective.label,
|
|
achieved,
|
|
status: achieved ? 'done' : 'failed',
|
|
detail: achieved ? '달성' : '미달',
|
|
rewardGold: objective.rewardGold
|
|
};
|
|
}),
|
|
units: scenario.units,
|
|
bonds: scenario.bonds.map((bond) => ({
|
|
...bond,
|
|
battleExp: 0
|
|
})),
|
|
itemRewards: [],
|
|
completedCampDialogues: [],
|
|
completedCampVisits: [],
|
|
createdAt: '2026-07-27T00:00:00.000Z'
|
|
});
|
|
const campaign = campaignModule.getCampaignState();
|
|
assert(
|
|
campaign.step === 'second-camp' &&
|
|
campaign.latestBattleId === scenario.id,
|
|
'The canonical second victory must route into second-camp.'
|
|
);
|
|
return campaign;
|
|
}
|
|
|
|
function literalCampaign(
|
|
dataModule,
|
|
{
|
|
villageAchieved,
|
|
quickAchieved,
|
|
reliefChoiceId,
|
|
firstScoutVisitId,
|
|
firstScoutChoiceId
|
|
}
|
|
) {
|
|
const completedCampVisits = [];
|
|
const campVisitChoiceIds = {};
|
|
if (reliefChoiceId) {
|
|
completedCampVisits.push(dataModule.secondBattleReliefVisitId);
|
|
campVisitChoiceIds[dataModule.secondBattleReliefVisitId] =
|
|
reliefChoiceId;
|
|
}
|
|
if (firstScoutVisitId && firstScoutChoiceId) {
|
|
completedCampVisits.push(firstScoutVisitId);
|
|
campVisitChoiceIds[firstScoutVisitId] = firstScoutChoiceId;
|
|
}
|
|
return {
|
|
battleHistory: {
|
|
[dataModule.secondBattleReliefSourceBattleId]: {
|
|
battleId: dataModule.secondBattleReliefSourceBattleId,
|
|
outcome: 'victory',
|
|
turnNumber: quickAchieved ? 18 : 27,
|
|
objectives: [
|
|
{
|
|
id: dataModule.secondBattleReliefSourceObjectiveIds.village,
|
|
achieved: villageAchieved,
|
|
status: villageAchieved ? 'done' : 'failed'
|
|
},
|
|
{
|
|
id: dataModule.secondBattleReliefSourceObjectiveIds.quick,
|
|
achieved: quickAchieved,
|
|
status: quickAchieved ? 'done' : 'failed'
|
|
}
|
|
]
|
|
}
|
|
},
|
|
completedCampVisits,
|
|
campVisitChoiceIds
|
|
};
|
|
}
|
|
|
|
function assertRewardApplied(choice, inventoryBefore, campaign) {
|
|
choice.itemRewards.forEach((reward) => {
|
|
const match = /^(.+?)\s+(\d+)$/.exec(reward);
|
|
assert(match, `Invalid reward label ${reward}.`);
|
|
const [, label, amountText] = match;
|
|
const amount = Number.parseInt(amountText, 10);
|
|
assert(
|
|
(campaign.inventory[label] ?? 0) ===
|
|
(inventoryBefore[label] ?? 0) + amount,
|
|
`Expected ${reward} to be granted exactly once.`
|
|
);
|
|
});
|
|
}
|
|
|
|
function progressSnapshot(campaign, dataModule) {
|
|
const relevantIds = campaign.completedCampVisits.filter(
|
|
(visitId) =>
|
|
visitId === dataModule.secondBattleReliefVisitId ||
|
|
visitId.startsWith(
|
|
`${dataModule.secondBattleReliefVisitId}:objective:`
|
|
)
|
|
);
|
|
return {
|
|
completedCampVisits: relevantIds,
|
|
reportCompletedCampVisits:
|
|
campaign.firstBattleReport?.completedCampVisits.filter(
|
|
(visitId) => relevantIds.includes(visitId)
|
|
) ?? [],
|
|
choiceId:
|
|
campaign.campVisitChoiceIds[
|
|
dataModule.secondBattleReliefVisitId
|
|
] ?? null,
|
|
inventory: { ...campaign.inventory },
|
|
bonds: campaign.bonds.map(
|
|
({ id, level, exp, battleExp }) => ({
|
|
id,
|
|
level,
|
|
exp,
|
|
battleExp
|
|
})
|
|
)
|
|
};
|
|
}
|
|
|
|
function pointInside(point, bounds) {
|
|
return (
|
|
point.x >= bounds.x &&
|
|
point.y >= bounds.y &&
|
|
point.x <= bounds.x + bounds.width &&
|
|
point.y <= bounds.y + bounds.height
|
|
);
|
|
}
|
|
|
|
function assert(condition, message) {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|