Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions public/experience/check-solid.svg

This file was deleted.

5 changes: 0 additions & 5 deletions src/app/experience/simulation/page.tsx

This file was deleted.

4 changes: 0 additions & 4 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,6 @@
--color-primary: #ba7448; /* 채워진 강조 버튼 (활성 '시뮬레이션 하기' 등) */
--color-primary-hover: #a8663d; /* primary 버튼 hover */
--color-primary-active: #965a35; /* primary 버튼 눌림 */
--color-danger: #d96a5c; /* 시뮬레이션 실패 배지·테두리·강조 텍스트 */
--color-danger-bg: #ffefed; /* 시뮬레이션 실패 카드 배경 */
--color-success: #9dc88b; /* 시뮬레이션 성공 배지·테두리·강조 텍스트 */
--color-success-bg: rgba(233, 255, 238, 0.86); /* 시뮬레이션 성공 카드 배경 */
}

/* 카드 배경의 점 격자 (Figma Rectangle 5·7, 약 18px 간격). bg-card 와 함께 쓴다. */
Expand Down
1 change: 0 additions & 1 deletion src/features/simulation/api/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ export const simulationHandlers = [
normalizedCommandCount: countCommands(program.chain),
totalDistanceM: evaluation.totalDistanceM,
violations,
failedAtIndex: evaluation.failedAtIndex,
notes: passed ? ['실제 물리 결과와 차이가 있을 수 있어요.'] : [],
};

Expand Down
53 changes: 9 additions & 44 deletions src/features/simulation/api/simulateProgram.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ describe('simulateProgram (mock API)', () => {
expect(result.passed).toBe(true);
expect(result.totalDistanceM).toBe(2);
expect(result.violations).toEqual([]);
expect(result.failedAtIndex).toBeUndefined();
});

it("'종료' 로 끝나지 않으면 통과하지 못한다", async () => {
Expand Down Expand Up @@ -64,9 +63,6 @@ describe('simulateProgram (mock API)', () => {
expect(result.totalDistanceM).toBe(3);
expect(result.violations[0]?.code).toBe('exceeds-safe-zone');
expect(result.violations[0]?.message).toMatch(/안전 구역/);
// repeat(3){move 1m} 를 실제 실행 순서로 펼치면 [start, move, move, move, greet, end] —
// 4번째(세 번째 move)에서 누적 3m 로 2m 안전 구역을 처음 벗어난다.
expect(result.failedAtIndex).toBe(4);
});

it('반복 안 이동과 별도 이동 블록의 거리를 각각 더한다 (첫 값만 뽑지 않는다)', async () => {
Expand Down Expand Up @@ -98,50 +94,19 @@ describe('simulateProgram (mock API)', () => {
expect(result.passed).toBe(false);
expect(result.totalDistanceM).toBe(0);
expect(result.violations[0]?.code).toBe('invalid-values');
// count 가 무효해 실행 순서를 펼칠 수 없다 — 실패 위치는 알 수 없음
expect(result.failedAtIndex).toBeUndefined();
});

it("'앞으로' 이동도 '뒤로' 이동과 같은 총 이동 거리 계산에 포함된다", async () => {
it('알 수 없는(지원 종료된) 블록 kind 는 값 검증을 우회하지 못하고 거부된다 (inryeok-bot 리뷰)', async () => {
// 예: 지원 종료된 구버전 moveForward 요청 — 알 수 없는 kind 를 값 검증 없이 통과시키면
// 안전 구역 계산에서도 빠져 위반을 놓칠 수 있다.
const unknownNode = {
id: 'legacy-0',
kind: 'moveForward',
distanceM: 999,
} as unknown as SerializedBlockNode;
const result = await simulateProgram({
program: program({
chain: [
{ id: 'start-0', kind: 'start' },
{ id: 'move-1', kind: 'move', distanceM: 1 },
{ id: 'forward-0', kind: 'moveForward', distanceM: 1 },
{ id: 'end-0', kind: 'end' },
],
}),
});

expect(result.totalDistanceM).toBe(2);
});

it('회전(오른쪽·왼쪽)은 거리가 아니라 총 이동 거리 합산에 포함되지 않는다', async () => {
const result = await simulateProgram({
program: program({
chain: [
{ id: 'start-0', kind: 'start' },
{ id: 'move-1', kind: 'move', distanceM: 1 },
{ id: 'turn-0', kind: 'turnRight', degrees: 90 },
{ id: 'turn-1', kind: 'turnLeft', degrees: 90 },
{ id: 'end-0', kind: 'end' },
],
}),
});

expect(result.totalDistanceM).toBe(1);
expect(result.passed).toBe(true);
});

it('회전 각도가 허용 범위(1~90) 밖이면 서버가 거부한다', async () => {
const result = await simulateProgram({
program: program({
chain: [
{ id: 'start-0', kind: 'start' },
{ id: 'turn-0', kind: 'turnRight', degrees: 180 },
{ id: 'end-0', kind: 'end' },
],
chain: [{ id: 'start-0', kind: 'start' }, unknownNode, { id: 'end-0', kind: 'end' }],
}),
});

Expand Down
44 changes: 8 additions & 36 deletions src/features/simulation/model/safety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export const LIMITS = {
repeatCount: { min: 1, max: 20 },
moveDistance: { min: 1, max: 10 },
waitSeconds: { min: 1, max: 60 },
turnDegrees: { min: 1, max: 90 },
} as const;

const inRange = (raw: unknown, { min, max }: { min: number; max: number }) => {
Expand All @@ -23,17 +22,18 @@ const inRange = (raw: unknown, { min, max }: { min: number; max: number }) => {
function isValueValid(node: SerializedBlockNode): boolean {
switch (node.kind) {
case 'move':
case 'moveForward':
return inRange(node.distanceM, LIMITS.moveDistance);
case 'turnRight':
case 'turnLeft':
return inRange(node.degrees, LIMITS.turnDegrees);
case 'wait':
return inRange(node.seconds, LIMITS.waitSeconds);
case 'repeat':
return inRange(node.count, LIMITS.repeatCount);
case 'start':
case 'greet':
case 'end':
return true; // 파라미터가 없는 블록 — 값 검증 대상이 아니다
default:
return true;
// 알 수 없는(지원 종료됐거나 조작된) kind — 값 검증을 우회하지 못하게 명시적으로 거부한다.
return false;
}
}

Expand All @@ -49,49 +49,22 @@ function allValuesValid(nodes: SerializedBlockNode[]): boolean {
return invalidCount === 0;
}

/** 반복 중첩까지 포함한 총 이동 거리 (명세: "제한을 우회하는 중첩·합산 값도 계산").
* 회전(turnRight/turnLeft)은 거리가 아니라 각도라 합산에 포함하지 않는다. */
/** 반복 중첩까지 포함한 총 이동 거리 (명세: "제한을 우회하는 중첩·합산 값도 계산"). */
function totalDistance(nodes: SerializedBlockNode[]): number {
return sumOverBlockTree(nodes, {
valueOf: (node) => (node.kind === 'move' || node.kind === 'moveForward' ? node.distanceM : 0),
valueOf: (node) => (node.kind === 'move' ? node.distanceM : 0),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 HIGH · SECURITY

구버전 `moveForward` 요청이 이동 거리 0으로 승인됩니다

네트워크 요청은 런타임 스키마 검증 없이 타입 단언만 거치므로, 이전 클라이언트나 직접 작성한 요청은 여전히 moveForward를 보낼 수 있습니다. 이 변경은 해당 블록의 거리와 값 검증을 모두 제거했으며 default 분기는 유효한 값으로 처리합니다. 따라서 distanceM이 안전 구역을 크게 초과해도 시뮬레이션과 실행 요청이 통과할 수 있습니다.

알 수 없는 블록 종류를 거부하거나, 배포 호환 기간에는 기존 moveForward의 범위 및 거리 계산을 유지해야 합니다.

bodyOf,
repeatCountOf: (node) => (node.kind === 'repeat' ? node.count : 1),
});
}

/** repeat 를 실제 실행 횟수만큼 펼친다 (구조상 1개인 repeat 를 count 번 반복 실행한 순서). */
function unroll(nodes: SerializedBlockNode[]): SerializedBlockNode[] {
return nodes.flatMap((node) => {
if (node.kind !== 'repeat') return [node];
const body = unroll(node.body);
return Array.from({ length: node.count }, () => body).flat();
});
}

/**
* 안전 구역을 처음 벗어나는 실행 순서상 위치 (1-based, "N번째 블록에서 멈춤" 명세용).
* 값이 유효할 때만 부른다 — count 가 무효하면 펼칠 수 없다.
*/
function safeZoneBreachIndex(nodes: SerializedBlockNode[]): number | undefined {
let cumulative = 0;
const steps = unroll(nodes);
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
if (step.kind === 'move' || step.kind === 'moveForward') cumulative += step.distanceM;
if (cumulative > SAFE_ZONE_M) return i + 1;
}
return undefined;
}

export type ProgramEvaluation = {
structurallyValid: boolean;
valuesValid: boolean;
totalDistanceM: number;
withinSafeZone: boolean;
/** 시뮬레이션·실제 실행을 모두 진행할 수 있는 상태인지 */
runnable: boolean;
/** 안전 구역을 벗어난 실행 순서상 위치 (1-based) — 벗어나지 않았거나 값이 무효하면 없음. */
failedAtIndex?: number;
};

export function evaluateProgram(program: SerializedBlockProgram): ProgramEvaluation {
Expand All @@ -106,6 +79,5 @@ export function evaluateProgram(program: SerializedBlockProgram): ProgramEvaluat
totalDistanceM,
withinSafeZone,
runnable: structurallyValid && valuesValid && withinSafeZone,
failedAtIndex: valuesValid && !withinSafeZone ? safeZoneBreachIndex(program.chain) : undefined,
};
}
13 changes: 0 additions & 13 deletions src/features/simulation/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,6 @@ export type SerializedBlockNode =
| { id: string; kind: 'greet' }
| { id: string; kind: 'end' }
| { id: string; kind: 'move'; distanceM: number }
| { id: string; kind: 'moveForward'; distanceM: number }
| { id: string; kind: 'turnRight'; degrees: number }
| { id: string; kind: 'turnLeft'; degrees: number }
| { id: string; kind: 'stop' }
| { id: string; kind: 'sit' }
| { id: string; kind: 'standUp' }
| { id: string; kind: 'heart' }
| { id: string; kind: 'dance' }
| { id: string; kind: 'rollOver' }
| { id: string; kind: 'attack' }
| { id: string; kind: 'wait'; seconds: number }
| { id: string; kind: 'repeat'; count: number; body: SerializedBlockNode[] };

Expand Down Expand Up @@ -53,9 +43,6 @@ export type SimulationResult = {
totalDistanceM: number;
/** 안전 제한 위반 목록 (명세 "안전 제한 검증") */
violations: SafetyViolation[];
/** 안전 구역을 벗어난 실행 순서상 위치 (1-based) — 통과했거나 값 자체가 무효하면 없음.
* 결과 화면 "N번째 블록에서 멈춤"·실행순서 단계별 상태 표시에 쓴다. */
failedAtIndex?: number;
/** 실제 물리 결과와 차이가 있을 수 있음 등 안내 문구 */
notes: string[];
};
22 changes: 0 additions & 22 deletions src/shared/testing/routerMock.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/views/experience/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
export { ExperienceView } from './ui/ExperienceView';
export { SimulationResultView } from './ui/SimulationResultView';
106 changes: 0 additions & 106 deletions src/views/experience/lib/simulationResultStorage.test.ts

This file was deleted.

Loading