Files
blub-blub/tests/smoke.gd
T
ttipo 446d8d177c 바탕화면 바닷속 방치형 위젯 첫 커밋
화면 아래 가장자리에 가로로 깔리는 Godot 4.7 위젯.
거품을 모아 바다를 꾸미고, 꾸밀수록 거품이 더 빨리 모인다.

- 도킹 창: 투명·테두리 없음, 화면 폭 전체, 위/아래 가장자리 스냅,
  높이 프리셋, 클릭 통과(모서리로 복귀), DPI 배율 보정
- 3/4 부감 격자: 구역당 9x6, 전체 45x6 = 270칸.
  바닥은 위에서, 물체는 서 있는 모습으로 그리고 Y 정렬로 앞뒤를 가린다
- 구역 5개(얕은 바다 -> 열수구): 앞 구역을 꾸며야 다음이 열린다
- 칸이 곧 한계인 경제: 배치/이동/치우기(절반 환불), 여러 칸짜리 장식
- 저장: 30초 자동 저장, 백업 한 세대, 8시간 상한 오프라인 보상,
  격자 이전 형식 저장본 자동 이전
- 스모크 테스트 60여 개 (test.ps1)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 17:59:50 +09:00

358 lines
15 KiB
GDScript

extends Node
## 경제·배치 로직 스모크 테스트. 저장 파일은 건드리지 않고 메모리 안에서만 돈다.
## Godot_v4.7.2-stable_win64_console.exe --headless --path . res://tests/smoke.tscn
var _failures := 0
func _ready() -> void:
# 이 테스트는 실제 저장 파일을 건드리면 안 된다.
# 끄지 않으면 종료할 때 초기화된 상태가 저장되어 진행도가 날아간다.
SaveManager.persistence_enabled = false
GameState.reset()
_test_click()
_test_producer_cost_curve()
_test_place()
_test_footprint_and_multi_tile()
_test_zone_boundaries()
_test_move()
_test_remove_refund()
_test_space_is_the_limit()
_test_zone_gating()
_test_zone_unlock()
_test_zone_beauty_is_per_zone()
_test_ramp_continuity()
_test_serialization_round_trip()
_test_legacy_save_migration()
_test_number_format()
_test_korean_particles()
if _failures == 0:
print("모든 검사 통과")
else:
print("실패 %d" % _failures)
get_tree().quit(1 if _failures > 0 else 0)
func check(label: String, condition: bool, detail := "") -> void:
if condition:
print(" ok ", label)
else:
_failures += 1
print(" FAIL ", label, " ", detail)
## 모든 구역을 열고 거품을 넉넉히 준 상태에서 시작한다.
func _rich_and_open() -> void:
GameState.reset()
GameState.unlocked_zones = Catalog.zone_count()
GameState.bubbles = 1e15
func _test_click() -> void:
print("클릭")
GameState.reset()
var gained := GameState.click()
check("클릭 한 번에 기본 1 거품", is_equal_approx(gained, 1.0), str(gained))
check("잔액과 누적이 함께 오른다",
is_equal_approx(GameState.bubbles, 1.0) and is_equal_approx(GameState.total_bubbles, 1.0))
func _test_producer_cost_curve() -> void:
print("가격 곡선")
GameState.reset()
GameState.bubbles = 1000.0
var first := GameState.producer_cost("bubble_stone")
check("첫 거품돌은 15", is_equal_approx(first, 15.0), str(first))
GameState.place("producer", "bubble_stone", 0, 0)
var second := GameState.producer_cost("bubble_stone")
check("놓을수록 비싸진다 (x1.15)", second > first and second <= ceil(15.0 * 1.15) + 0.001,
"%f -> %f" % [first, second])
func _test_place() -> void:
print("배치")
GameState.reset()
GameState.bubbles = 1000.0
var before := GameState.bubbles
var index := GameState.place("producer", "seaweed", 2, 3)
check("빈 칸에 놓인다", index == 0, str(index))
check("가격만큼 빠진다", is_equal_approx(GameState.bubbles, before - 110.0), str(GameState.bubbles))
check("그 칸이 자기 것이 된다", GameState.placement_at(2, 3) == 0)
check("옆 칸은 여전히 빈다", GameState.placement_at(3, 3) == -1)
check("아름다움이 붙는다", is_equal_approx(GameState.beauty, 3.0), str(GameState.beauty))
check("같은 칸에 또 놓을 수 없다", GameState.place("producer", "seaweed", 2, 3) == -1)
check("격자 밖에는 놓을 수 없다", GameState.place("producer", "seaweed", 2, 99) == -1)
GameState.bubbles = 5.0
check("잔액이 모자라면 못 놓는다", GameState.place("producer", "bubble_stone", 5, 0) == -1)
check("실패했으면 잔액 그대로", is_equal_approx(GameState.bubbles, 5.0))
func _test_footprint_and_multi_tile() -> void:
print("여러 칸짜리")
_rich_and_open()
var span := Catalog.size_of("ornament", "shipwreck")
check("난파선은 3x2", span == Vector2i(3, 2), str(span))
# 산호초(1번 구역)의 두 번째 열에 난파선(3x2)을 놓으면 세 열 두 행을 덮는다.
var left := Catalog.ZONE_COLUMNS + 1
var index := GameState.place("ornament", "shipwreck", left, 0)
check("놓인다", index >= 0, str(index))
var covered := 0
for col in range(left, left + 3):
for row in 2:
if GameState.placement_at(col, row) == index:
covered += 1
check("여섯 칸을 모두 차지한다", covered == 6, str(covered))
check("발자국 안쪽에는 겹쳐 놓을 수 없다",
GameState.place("producer", "clownfish", left + 1, 1) == -1)
check("발자국 바로 옆에는 놓인다",
GameState.place("producer", "clownfish", left + 3, 1) >= 0)
func _test_zone_boundaries() -> void:
print("구역 경계")
_rich_and_open()
# 얕은 바다는 0번 구역. 해초는 그 구역 것이라 다음 구역 첫 열에는 못 간다.
var last := Catalog.ZONE_COLUMNS - 1
check("자기 구역 안에는 놓인다", GameState.can_place_at("producer", "seaweed", last, 0))
check("남의 구역에는 못 놓는다",
not GameState.can_place_at("producer", "seaweed", last + 1, 0))
# 항아리(2x1)를 마지막 열에 놓으면 다음 구역까지 걸친다.
check("구역을 걸치면 못 놓는다",
not GameState.can_place_at("ornament", "amphora", last, 0))
check("한 구역 안에 들어가면 놓인다",
GameState.can_place_at("ornament", "amphora", last - 1, 0))
func _test_move() -> void:
print("옮기기")
_rich_and_open()
var index := GameState.place("producer", "seaweed", 1, 1)
var spent := GameState.bubbles
check("빈 칸으로 옮겨진다", GameState.move(index, 4, 2))
check("옮기는 건 공짜", is_equal_approx(GameState.bubbles, spent), str(GameState.bubbles))
check("옛 칸이 비었다", GameState.placement_at(1, 1) == -1)
check("새 칸이 자기 것", GameState.placement_at(4, 2) == index)
GameState.place("producer", "seaweed", 0, 0)
check("남이 있는 칸으로는 못 옮긴다", not GameState.move(index, 0, 0))
check("실패했으면 제자리", GameState.placement_at(4, 2) == index)
check("제자리에 그대로 놓는 건 된다", GameState.move(index, 4, 2))
func _test_remove_refund() -> void:
print("치우기와 환불")
_rich_and_open()
GameState.place("producer", "seaweed", 0, 0)
var index := GameState.place("producer", "seaweed", 1, 0)
var before := GameState.bubbles
# 두 번째 해초 가격은 올림(110 * 1.15). 그 절반이 돌아온다.
var expected: float = floor(ceil(110.0 * 1.15) * Catalog.REFUND_RATE)
var refund := GameState.remove(index)
check("절반을 돌려받는다", is_equal_approx(refund, expected),
"%f vs %f" % [refund, expected])
check("잔액에 더해진다", is_equal_approx(GameState.bubbles, before + expected))
check("칸이 비었다", GameState.placement_at(1, 0) == -1)
check("개수가 줄었다", GameState.count_of("seaweed") == 1)
var oi := GameState.place("ornament", "amphora", 2, 0)
check("장식은 하나만", GameState.place("ornament", "amphora", 4, 0) == -1)
GameState.remove(oi)
check("치우면 다시 살 수 있다", GameState.place("ornament", "amphora", 4, 0) >= 0)
func _test_space_is_the_limit() -> void:
print("공간이 곧 한계")
_rich_and_open()
var total := Catalog.GRID_ROWS * Catalog.ZONE_COLUMNS
check("구역 하나는 %d" % total, GameState.free_cells_in_zone(0) == total,
str(GameState.free_cells_in_zone(0)))
var placed := 0
while true:
var spot := GameState.first_free_spot("producer", "seaweed")
if spot.x < 0:
break
if GameState.place("producer", "seaweed", spot.x, spot.y) < 0:
break
placed += 1
check("칸이 차면 더 못 놓는다", placed == total, str(placed))
check("남은 칸 0", GameState.free_cells_in_zone(0) == 0)
check("거품이 남아도 못 놓는다", GameState.place("producer", "seaweed", 0, 0) == -1)
check("다른 구역은 아직 비어 있다",
GameState.free_cells_in_zone(1) == total, str(GameState.free_cells_in_zone(1)))
func _test_zone_gating() -> void:
print("구역 잠금")
GameState.reset()
GameState.bubbles = 1e15
check("시작은 1구역만 열림", GameState.unlocked_zones == 1)
check("0구역 물건은 보인다", GameState.is_revealed("producer", "bubble_stone"))
check("1구역 물건은 안 보인다", not GameState.is_revealed("producer", "clownfish"))
check("돈이 넘쳐도 잠긴 구역에는 못 놓는다",
GameState.place("producer", "clownfish", Catalog.ZONE_COLUMNS, 0) == -1)
check("실패했으면 잔액 그대로", is_equal_approx(GameState.bubbles, 1e15))
GameState.unlocked_zones = 2
check("열린 뒤에는 보인다", GameState.is_revealed("producer", "clownfish"))
check("열린 뒤에는 놓인다",
GameState.place("producer", "clownfish", Catalog.ZONE_COLUMNS, 0) >= 0)
func _test_zone_unlock() -> void:
print("구역 해금")
GameState.reset()
var need_beauty := GameState.next_zone_required_beauty()
var cost := GameState.next_zone_cost()
check("2구역 해금 조건이 정의되어 있다", need_beauty > 0.0 and cost > 0.0,
"beauty=%f cost=%f" % [need_beauty, cost])
GameState.bubbles = cost * 4.0
check("앞 구역을 안 꾸미면 못 연다", not GameState.can_unlock_next())
check("사유가 아름다움 부족이라고 나온다",
GameState.next_zone_blocker().contains("꾸며야"), GameState.next_zone_blocker())
# 앞 구역(얕은 바다)을 해초로 채워 아름다움을 올린다. 한 포기당 3.
while GameState.beauty_in_zone(0) < need_beauty:
var spot := GameState.first_free_spot("producer", "seaweed")
if spot.x < 0 or GameState.place("producer", "seaweed", spot.x, spot.y) < 0:
break
check("칸 안에서 조건을 채울 수 있다", GameState.beauty_in_zone(0) >= need_beauty,
"%f / %f" % [GameState.beauty_in_zone(0), need_beauty])
GameState.bubbles = cost
check("조건을 채우면 열 수 있다", GameState.can_unlock_next(), GameState.next_zone_blocker())
check("해금 성공", GameState.unlock_next_zone())
check("해금 비용이 빠진다", is_equal_approx(GameState.bubbles, 0.0), str(GameState.bubbles))
check("열린 구역이 2개", GameState.unlocked_zones == 2)
func _test_zone_beauty_is_per_zone() -> void:
print("구역별 아름다움")
_rich_and_open()
GameState.place("producer", "seaweed", 0, 0) # 0구역, 아름다움 3
GameState.place("producer", "clownfish", Catalog.ZONE_COLUMNS, 0) # 1구역, 아름다움 8
check("0구역에 3", is_equal_approx(GameState.beauty_in_zone(0), 3.0),
str(GameState.beauty_in_zone(0)))
check("1구역에 8", is_equal_approx(GameState.beauty_in_zone(1), 8.0),
str(GameState.beauty_in_zone(1)))
check("총합은 11", is_equal_approx(GameState.beauty, 11.0), str(GameState.beauty))
check("건드리지 않은 구역은 0", is_equal_approx(GameState.beauty_in_zone(4), 0.0))
func _test_ramp_continuity() -> void:
print("색 램프")
var n := Catalog.zone_count()
check("왼쪽 끝은 첫 구역 색",
Catalog.ramp_color("tint", 0.0).is_equal_approx(Catalog.ZONES[0].tint))
check("오른쪽 끝은 마지막 구역 색",
Catalog.ramp_color("tint", 1.0).is_equal_approx(Catalog.ZONES[n - 1].tint))
# 램프가 끊기지 않아야 구역 경계에 이음매가 보이지 않는다.
var max_jump := 0.0
var prev := Catalog.ramp_color("tint", 0.0)
for i in range(1, 201):
var c := Catalog.ramp_color("tint", float(i) / 200.0)
max_jump = maxf(max_jump, absf(c.r - prev.r) + absf(c.g - prev.g) + absf(c.b - prev.b))
prev = c
check("램프에 색이 튀는 곳이 없다", max_jump < 0.05, "최대 변화 %f" % max_jump)
# 구역 중심에서는 그 구역 값 그대로여야 한다.
var center_ok := true
for i in n:
var f := (float(i) + 0.5) / n
if not Catalog.ramp_color("sand", f).is_equal_approx(Catalog.ZONES[i].sand):
center_ok = false
if not is_equal_approx(Catalog.ramp_float("murk", f), float(Catalog.ZONES[i].murk)):
center_ok = false
check("구역 중심은 그 구역 값 그대로", center_ok)
func _test_serialization_round_trip() -> void:
print("저장 직렬화")
_rich_and_open()
GameState.place("producer", "seaweed", 1, 1)
GameState.place("producer", "seaweed", 2, 1)
GameState.place("ornament", "shipwreck", Catalog.ZONE_COLUMNS + 1, 2)
GameState.upgrades["u_touch1"] = true
GameState.clicks = 42
# 배치가 끝난 뒤에 잔액을 맞춘다. 배치는 값을 치르기 때문에 먼저 정하면 어긋난다.
GameState.bubbles = 12345.0
GameState.recalc()
var bps_before := GameState.bps
# 실제 저장 경로와 똑같이 JSON 문자열을 거쳐서 되돌린다.
var text := JSON.stringify(GameState.to_dict())
GameState.reset()
GameState.from_dict(JSON.parse_string(text))
check("보유량 복원", is_equal_approx(GameState.bubbles, 12345.0), str(GameState.bubbles))
check("배치 개수 복원", GameState.placements.size() == 3, str(GameState.placements.size()))
var wreck := Catalog.ZONE_COLUMNS + 1
check("배치된 칸까지 그대로",
GameState.placement_at(2, 1) >= 0 and GameState.placement_at(wreck, 2) >= 0)
check("난파선 발자국도 복원",
GameState.placement_at(wreck + 2, 3) == GameState.placement_at(wreck, 2))
check("업그레이드 복원", GameState.has_upgrade("u_touch1"))
check("클릭 수 복원", GameState.clicks == 42)
check("열린 구역 수 복원", GameState.unlocked_zones == Catalog.zone_count(),
str(GameState.unlocked_zones))
check("초당 생산량이 그대로", is_equal_approx(GameState.bps, bps_before),
"%f vs %f" % [GameState.bps, bps_before])
func _test_legacy_save_migration() -> void:
print("옛 저장본 이전")
GameState.reset()
# 격자가 없던 시절의 저장본. 개수만 있고 위치가 없다.
GameState.from_dict({
"bubbles": 500.0,
"unlocked_zones": 2,
"producers": {"seaweed": 4, "clownfish": 2},
"ornaments": {"amphora": true},
})
check("개수만큼 칸에 놓인다", GameState.placements.size() == 7,
str(GameState.placements.size()))
check("해초 4포기", GameState.count_of("seaweed") == 4, str(GameState.count_of("seaweed")))
check("장식도 옮겨온다", GameState.has_ornament("amphora"))
var all_in_zone := true
for p in GameState.placements:
if Catalog.zone_of_column(p.col) != Catalog.zone_of(p.kind, p.id):
all_in_zone = false
check("각자 제 구역에 놓인다", all_in_zone)
GameState.from_dict({"bubbles": 1.0})
check("구역 개념이 없던 저장본은 1구역으로", GameState.unlocked_zones == 1)
func _test_number_format() -> void:
print("숫자 표기")
check("999", NumberFormat.short(999.0) == "999", NumberFormat.short(999.0))
check("1.23K", NumberFormat.short(1234.0) == "1.23K", NumberFormat.short(1234.0))
check("12.3M", NumberFormat.short(12_345_678.0) == "12.3M", NumberFormat.short(12_345_678.0))
check("1.00B", NumberFormat.short(1e9) == "1.00B", NumberFormat.short(1e9))
check("소수점", NumberFormat.short(2.5) == "2.5", NumberFormat.short(2.5))
check("시간 표기", NumberFormat.duration(3 * 3600 + 720) == "3시간 12분",
NumberFormat.duration(3 * 3600 + 720))
func _test_korean_particles() -> void:
print("한글 조사")
check("받침 있으면 을", Korean.eul("켈프 숲") == "켈프 숲을", Korean.eul("켈프 숲"))
check("받침 없으면 를", Korean.eul("얕은 바다") == "얕은 바다를", Korean.eul("얕은 바다"))
check("받침 있으면 이", Korean.i("등불") == "등불이", Korean.i("등불"))
check("받침 없으면 가", Korean.i("심해") == "심해가", Korean.i("심해"))
var ok := true
for z in Catalog.ZONES:
if not Korean.eul(String(z.name)).begins_with(String(z.name)):
ok = false
check("구역 이름 전부 처리된다", ok)