feat: integrate invitation access, world NPCs, and deployment
This commit is contained in:
11
scripts/export_world_npc_graph.ts
Normal file
11
scripts/export_world_npc_graph.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { WORLD_LOCATIONS, WORLD_ROUTE_EDGES } from '../src/business/world_npc/world_npc.world';
|
||||
|
||||
const output = String(process.env.WORLD_NPC_GRAPH_OUTPUT || '').trim();
|
||||
if (!output) throw new Error('WORLD_NPC_GRAPH_OUTPUT is required');
|
||||
writeFileSync(resolve(output), JSON.stringify({
|
||||
locations: WORLD_LOCATIONS,
|
||||
edges: WORLD_ROUTE_EDGES,
|
||||
}, null, 2));
|
||||
console.log(`WORLD_NPC_GRAPH_EXPORTED: ${resolve(output)}`);
|
||||
40
scripts/run_migrations.ts
Normal file
40
scripts/run_migrations.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
import { resolve } from 'path';
|
||||
import { createConnection } from 'mysql2/promise';
|
||||
|
||||
const MIGRATIONS = [
|
||||
'src/business/invitation/migrations/create-invitation-codes.sql',
|
||||
];
|
||||
|
||||
function requiredEnv(name: string): string {
|
||||
const value = String(process.env[name] || '').trim();
|
||||
if (!value) throw new Error(`Missing required environment variable: ${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const connection = await createConnection({
|
||||
host: requiredEnv('DB_HOST'),
|
||||
port: Number(process.env.DB_PORT || 3306),
|
||||
user: requiredEnv('DB_USERNAME'),
|
||||
password: requiredEnv('DB_PASSWORD'),
|
||||
database: requiredEnv('DB_NAME'),
|
||||
multipleStatements: true,
|
||||
charset: 'utf8mb4',
|
||||
});
|
||||
|
||||
try {
|
||||
for (const migration of MIGRATIONS) {
|
||||
const sql = await readFile(resolve(process.cwd(), migration), 'utf8');
|
||||
await connection.query(sql);
|
||||
console.log(`Applied migration: ${migration}`);
|
||||
}
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from scipy import ndimage
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
@@ -33,15 +34,22 @@ DEFAULT_CUTOUT_SCRIPT = SCRIPT_DIR / "tools" / "birefnet_cutout.py"
|
||||
DEFAULT_EXPAND_POSE_TRIPLET_SCRIPT = SCRIPT_DIR / "tools" / "expand_pose_triplet.py"
|
||||
DEFAULT_REFERENCE_STRIPS_DIR = SCRIPT_DIR / "references"
|
||||
DEFAULT_IDENTITY_REFERENCE_IMAGE = (
|
||||
DEFAULT_REFERENCE_STRIPS_DIR / "whaleboy_reference_down.png"
|
||||
DEFAULT_REFERENCE_STRIPS_DIR / "whaleboy_identity_single.png"
|
||||
)
|
||||
FRAME_SIZE = 160
|
||||
SPRITESHEET_COLUMNS = 8
|
||||
SPRITESHEET_ROWS = 4
|
||||
# Official style strips keep their source canvas for stable cell extraction.
|
||||
# The generated identity master is intentionally square and much smaller.
|
||||
REFERENCE_CANVAS_SIZE = (1536, 1024)
|
||||
IDENTITY_GENERATION_SIZE = "1024x1024"
|
||||
SINGLE_POSE_GENERATION_SIZE = "832x832"
|
||||
SINGLE_POSE_REFERENCE_SIZE = 1024
|
||||
SINGLE_POSE_REFERENCE_BODY_HEIGHT = 700
|
||||
POSE_LAYOUT_MARGIN_RATIO = 0.05
|
||||
POSE_LAYOUT_MAX_BODY_HEIGHT_RATIO = 0.88
|
||||
POSE_LAYOUT_MAX_BODY_WIDTH_RATIO = 0.68
|
||||
POSE_LAYOUT_BOTTOM_RATIO = 0.92
|
||||
LOWER_BODY_EDIT_START_RATIO = 0.68
|
||||
LOWER_BODY_EDIT_FEATHER_RATIO = 0.025
|
||||
LOWER_BODY_EDIT_TOP_HALF_WIDTH_RATIO = 0.18
|
||||
@@ -342,6 +350,15 @@ def _run_with_retries(
|
||||
raise last_error or RuntimeError("Novamailio command failed")
|
||||
|
||||
|
||||
SINGLE_POSE_LAYOUT_RULES = """
|
||||
Current generation-canvas placement (mandatory, before later 160x160 assembly):
|
||||
- Treat the full current image canvas as the frame. Keep exactly one complete character inside it; never crop any hair, hand, shoe, or shadow edge.
|
||||
- Leave a clean empty margin on every side: at least 5% of the current canvas width on the left and right, and 5% of the current canvas height at the top and bottom.
|
||||
- Place the visible character bounding-box center on the canvas centerline. The bounding-box center may deviate no more than 8% of the current canvas width.
|
||||
- Keep the character at the same scale and occupancy as the supplied pose reference. Do not enlarge it until it touches an edge, and do not shrink it into a tiny figure.
|
||||
""".strip()
|
||||
|
||||
|
||||
def _direction_prompt(direction: str) -> str:
|
||||
if direction in POSE_TRIPLET_DIRECTIONS:
|
||||
return _pose_triplet_prompt(direction)
|
||||
@@ -459,28 +476,44 @@ def _single_pose_prompt(direction: str, pose_index: int) -> str:
|
||||
reference_row = DIRECTION_REFERENCE_ROWS[direction]
|
||||
reference_column = (1, 2, 4)[pose_index]
|
||||
action = POSE_SINGLE_ACTIONS[direction][pose_index]
|
||||
if pose_index == 0:
|
||||
motion_rules = """
|
||||
- This is the neutral anchor pose. Both legs and shoes remain still, compact, symmetrical where the viewing direction permits, and on the same baseline.
|
||||
- Do not invent a lifted, forward, backward, or active leg. Do not create a walking step in this neutral pose.
|
||||
- Keep the head, shoulders, torso, hips, arms, and hands in a neutral upright standing pose. Both arms hang straight down at the sides.
|
||||
""".strip()
|
||||
identity_input = "Image 1 is the approved identity master. Preserve its identity and rendering exactly while converting it to the requested viewing direction."
|
||||
reference_match_rule = "Match Image 2's compact neutral stance, leg spacing, shoe placement, and viewing direction exactly."
|
||||
else:
|
||||
motion_rules = """
|
||||
- Keep the head, hair, shoulders, torso, hips, and both arms in the accepted neutral pose. Do not swing or bend an arm, rotate the torso, lean the body, or create a running pose.
|
||||
- Only the active leg and shoe may differ from the neutral pose. Both feet must remain visible: one active foot and one grounded foot.
|
||||
- The active leg must be recognizable from the outer silhouette, not only from color or shading.
|
||||
""".strip()
|
||||
identity_input = "Image 1 is the already accepted neutral pose and the immutable appearance/upper-body master."
|
||||
reference_match_rule = "Match Image 2's compact stride and actual leg/shoe geometry. Do not widen the stance or move a foot toward a canvas edge."
|
||||
return f"""
|
||||
Task:
|
||||
Create one single canonical {direction.upper()} pose for a WhaleTown walking animation.
|
||||
|
||||
Inputs:
|
||||
- Image 1 is appearance identity only. Preserve its hair, face or back-head design, outfit, colors, proportions, outline, and rendering style. Ignore all poses in Image 1.
|
||||
- {identity_input}
|
||||
- Image 2 is an enlarged single-pose crop extracted from column {reference_column} of the official WhaleTown {reference_row} motion row. Copy the complete body, leg, ankle, shoe, spacing, and perspective geometry visible in Image 2.
|
||||
- Image 3 is a layout-only construction guide. Use its safe box, centerline, and baseline to place the character. Never copy its lines, colors, background, or guide marks into the output.
|
||||
|
||||
Output:
|
||||
- Exactly ONE full-body character on the entire canvas. Do not create a row, sequence, comparison, duplicate, or additional character.
|
||||
- Center the character on a flat pure magenta #FF00FF background.
|
||||
{SINGLE_POSE_LAYOUT_RULES}
|
||||
- No text, labels, dividers, UI, watermark, props, or shadows.
|
||||
- Target assembled character height is {spec["body_height"]} px, width about {spec["body_width"]} px, and foot baseline y={spec["foot_y"]} px in a 160x160 frame.
|
||||
- These pixel targets describe the later 160x160 assembled frame, not the current generation canvas. On the current canvas, match Image 2's character occupancy and scale; do not draw a tiny 116px character.
|
||||
|
||||
Required pose:
|
||||
- {action}
|
||||
- {DIRECTION_LOCKS[direction]}
|
||||
- Keep the head, hair, shoulders, torso, hips, and both arms in a perfectly neutral upright standing pose. Both arms hang straight down at the sides. Do not swing or bend an arm, rotate the torso, lean the body, or create a running pose.
|
||||
- Only the active leg and shoe may differ from a neutral standing pose. The motion must remain compact enough for a calm game walk cycle.
|
||||
- Both feet must remain visible: one active foot and one grounded/neutral foot where applicable.
|
||||
- Match Image 2's compact stride and actual leg/shoe geometry. Do not widen the stance or move a foot toward a canvas edge.
|
||||
- The active leg must be recognizable from the outer silhouette, not only from color or shading.
|
||||
{motion_rules}
|
||||
- {reference_match_rule}
|
||||
|
||||
Identity locks:
|
||||
- Image 1 controls identity and all neutral upper-body geometry; Image 2 controls the requested leg and shoe pose only.
|
||||
@@ -510,6 +543,7 @@ Create one single canonical {direction.upper()} action pose for a WhaleTown walk
|
||||
Inputs:
|
||||
- Image 1 is the neutral pose A and the immutable appearance master.
|
||||
- Image 2 is the already accepted opposite-leg action pose of the SAME character. It shows this source action: {source_action}
|
||||
- Image 3 is a layout-only construction guide. Use its safe box, centerline, and baseline to place the character. Never copy its lines, colors, background, or guide marks into the output.
|
||||
- Produce the opposite action shown below. Use Image 2 only to match stride size, forward-depth perspective, and motion strength; switch the active leg exactly as requested.
|
||||
|
||||
Required target action:
|
||||
@@ -527,8 +561,10 @@ Immutable appearance contract:
|
||||
|
||||
Output:
|
||||
- Exactly ONE full-body character, centered on flat pure magenta #FF00FF.
|
||||
{SINGLE_POSE_LAYOUT_RULES}
|
||||
- No row, sequence, duplicate, text, labels, dividers, UI, watermark, props, or shadows.
|
||||
- Target assembled character height is {spec["body_height"]} px, width about {spec["body_width"]} px, and foot baseline y={spec["foot_y"]} px in a 160x160 frame.
|
||||
- These pixel targets describe the later 160x160 assembled frame, not the current generation canvas. Match Image 1's full-body scale and occupancy exactly; do not draw a tiny 116px character.
|
||||
""".strip()
|
||||
|
||||
|
||||
@@ -539,16 +575,18 @@ Create one locked front-facing identity master for a WhaleTown V2 player skin.
|
||||
|
||||
Input images:
|
||||
- Image 1 is the player's uploaded character reference. Preserve its main identity: face impression, hairstyle or head silhouette, outfit idea, dominant colors, and overall character feeling.
|
||||
- Image 2 is the official WhaleTown DOWN/front 8-frame reference row. Use it for sprite structure: body height, body width, baseline, frame spacing, simple rounded proportions, and clean 2D game rendering.
|
||||
- Image 2 is one official front-facing Sea Breeze Boy style reference. Use it only for WhaleTown sprite structure: body proportions, scale, baseline, dark outline, simple cel shading, and clean 2D game rendering.
|
||||
- Do not copy Image 2's face, hair, whale hood, outfit, colors, accessories, or character identity.
|
||||
|
||||
Output:
|
||||
- Exactly 8 equal columns x 1 row.
|
||||
- Same character identity in every column, front-facing, full-body, centered, same size and baseline.
|
||||
- Exactly ONE front-facing full-body character, centered on the canvas.
|
||||
- This is a single identity master, not a row, spritesheet, sequence, turnaround, or animation.
|
||||
- Human character only. If the uploaded image is animal-like, mascot-like, realistic, or non-human, adapt it into a human WhaleTown player character while preserving the visual inspiration.
|
||||
- Do not invent new accessories, props, hats, tails, ears, weapons, or costume details that are not visible in the uploaded image.
|
||||
- If the uploaded image and resulting identity have no hat or hood, keep the head uncovered in every frame. Do not add a hat, hood, cap, helmet, animal ears, hair accessory, or new head accessory.
|
||||
- If the uploaded image has no hat or hood, keep the head uncovered. Do not add a hat, hood, cap, helmet, animal ears, hair accessory, or new head accessory.
|
||||
- Match WhaleTown style: compact rounded 2D game sprite, clean dark outline, simple cel shading, low texture density.
|
||||
- Target assembled frame size is 160x160 px; visible character box about 73 px wide, 116 px high, bottom baseline y=137.
|
||||
- These pixel targets describe later assembly, not the current identity-generation canvas. Match Image 2's single-character occupancy and scale; do not draw a tiny 73x116 character.
|
||||
- Flat pure magenta #FF00FF background.
|
||||
- No text, labels, dividers, UI, watermark, props, shadows, green, or magenta/pink on the character.
|
||||
""".strip()
|
||||
@@ -561,7 +599,11 @@ def _parse_args(argv: Iterable[str]) -> argparse.Namespace:
|
||||
parser.add_argument("--name", default="")
|
||||
parser.add_argument("--result-json", type=Path, required=True)
|
||||
parser.add_argument("--status-json", type=Path, required=True)
|
||||
parser.add_argument("--size", default="1536x1024")
|
||||
parser.add_argument(
|
||||
"--size",
|
||||
default=IDENTITY_GENERATION_SIZE,
|
||||
help="Identity-master generation canvas; the official style reference keeps its own 1536x1024 source canvas.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--quality", default=os.getenv("SKIN_GENERATION_QUALITY", "medium")
|
||||
)
|
||||
@@ -571,6 +613,14 @@ def _parse_args(argv: Iterable[str]) -> argparse.Namespace:
|
||||
)
|
||||
parser.add_argument("--assemble-script", type=Path, default=DEFAULT_ASSEMBLE_SCRIPT)
|
||||
parser.add_argument("--reference-strips-dir", type=Path, default=None)
|
||||
parser.add_argument("--phase", choices=("all", "identity", "actions", "pose"), default="all")
|
||||
parser.add_argument("--direction", choices=DIRECTIONS)
|
||||
parser.add_argument("--pose", choices=POSE_NAMES)
|
||||
parser.add_argument(
|
||||
"--qa-feedback",
|
||||
default="",
|
||||
help="Previous pose QA failure to incorporate into a targeted repair attempt.",
|
||||
)
|
||||
return parser.parse_args(list(argv))
|
||||
|
||||
|
||||
@@ -998,7 +1048,7 @@ def _resolve_identity_reference_path(reference_strips_dir: Path | None) -> Path:
|
||||
source_dir = reference_strips_dir
|
||||
if not source_dir.is_absolute():
|
||||
source_dir = SCRIPT_DIR / source_dir
|
||||
path = source_dir / "whaleboy_reference_down.png"
|
||||
path = source_dir / "whaleboy_identity_single.png"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Identity reference image is missing: {path}")
|
||||
with Image.open(path) as image:
|
||||
@@ -1047,14 +1097,93 @@ def _create_single_pose_reference(
|
||||
(SINGLE_POSE_REFERENCE_SIZE, SINGLE_POSE_REFERENCE_SIZE),
|
||||
(255, 0, 255),
|
||||
)
|
||||
canvas.paste(
|
||||
resized,
|
||||
((canvas.width - resized.width) // 2, (canvas.height - resized.height) // 2),
|
||||
)
|
||||
# Keep the official pose inside a stable, invisible layout box. The model
|
||||
# sees the same margins for every direction, while the box itself is not
|
||||
# drawn and therefore cannot leak into the generated sprite.
|
||||
box_left = round(canvas.width * POSE_LAYOUT_MARGIN_RATIO)
|
||||
box_right = canvas.width - box_left
|
||||
box_bottom = round(canvas.height * (1 - POSE_LAYOUT_MARGIN_RATIO))
|
||||
x = (box_left + box_right - resized.width) // 2
|
||||
y = box_bottom - resized.height
|
||||
canvas.paste(resized, (x, y))
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
canvas.save(output_path)
|
||||
|
||||
|
||||
def _create_single_pose_layout_guide(output_path: Path) -> None:
|
||||
"""Create a layout-only construction guide for the image model."""
|
||||
size = SINGLE_POSE_REFERENCE_SIZE
|
||||
margin = round(size * POSE_LAYOUT_MARGIN_RATIO)
|
||||
image = Image.new("RGB", (size, size), "#f7f7f7")
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle((margin, margin, size - margin - 1, size - margin - 1), outline="#2f80ed", width=4)
|
||||
center_x = size // 2
|
||||
draw.line((center_x, margin, center_x, size - margin), fill="#9aa5ad", width=3)
|
||||
baseline = round(size * POSE_LAYOUT_BOTTOM_RATIO)
|
||||
draw.line((margin, baseline, size - margin, baseline), fill="#9aa5ad", width=3)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.save(output_path)
|
||||
|
||||
|
||||
def _normalize_single_pose_cutout(path: Path) -> dict[str, float | int]:
|
||||
"""Fit a generated cutout into the shared safe layout before QA/assembly."""
|
||||
with Image.open(path).convert("RGBA") as source:
|
||||
image = source.copy()
|
||||
array = np.asarray(image).copy()
|
||||
alpha = array[:, :, 3] > 16
|
||||
ys, xs = np.where(alpha)
|
||||
if not xs.size:
|
||||
raise RuntimeError("逐格QA失败:抠图中没有角色")
|
||||
width, height = image.size
|
||||
# Keep the character's meaningful connected regions and discard isolated
|
||||
# specks before measuring its box. Small separated regions such as shoes
|
||||
# remain because the area threshold is relative to the main component.
|
||||
labels, count = ndimage.label(alpha, structure=ndimage.generate_binary_structure(2, 1))
|
||||
if count:
|
||||
sizes = np.bincount(labels.ravel())
|
||||
largest = int(sizes[1:].max()) if sizes.size > 1 else 0
|
||||
min_component_area = max(12, round(largest * 0.002))
|
||||
keep = (labels > 0) & (sizes[labels] >= min_component_area)
|
||||
array[:, :, 3] = np.where(keep, array[:, :, 3], 0).astype(np.uint8)
|
||||
alpha = keep
|
||||
edge_counts = (int(alpha[0].sum()), int(alpha[-1].sum()), int(alpha[:, 0].sum()), int(alpha[:, -1].sum()))
|
||||
edge_noise_limit = max(4, round(min(width, height) * 0.005))
|
||||
if max(edge_counts) > edge_noise_limit:
|
||||
raise RuntimeError("逐格QA失败:角色主体贴近画布边缘,无法安全裁切")
|
||||
# Remove isolated one-pixel edge residue before calculating the crop box.
|
||||
if edge_counts[0] <= edge_noise_limit:
|
||||
array[0, :, 3] = 0
|
||||
if edge_counts[1] <= edge_noise_limit:
|
||||
array[-1, :, 3] = 0
|
||||
if edge_counts[2] <= edge_noise_limit:
|
||||
array[:, 0, 3] = 0
|
||||
if edge_counts[3] <= edge_noise_limit:
|
||||
array[:, -1, 3] = 0
|
||||
image = Image.fromarray(array, "RGBA")
|
||||
alpha = np.asarray(image)[:, :, 3] > 16
|
||||
ys, xs = np.where(alpha)
|
||||
if not xs.size:
|
||||
raise RuntimeError("逐格QA失败:去除边缘噪点后没有角色")
|
||||
bbox = (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1)
|
||||
body_w, body_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
max_w = max(1, round(width * POSE_LAYOUT_MAX_BODY_WIDTH_RATIO))
|
||||
max_h = max(1, round(height * POSE_LAYOUT_MAX_BODY_HEIGHT_RATIO))
|
||||
scale = min(1.0, max_w / body_w, max_h / body_h)
|
||||
cropped = image.crop(bbox)
|
||||
if scale < 1.0:
|
||||
cropped = cropped.resize(
|
||||
(max(1, round(cropped.width * scale)), max(1, round(cropped.height * scale))),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
x = round((width - cropped.width) / 2)
|
||||
y = round(height * POSE_LAYOUT_BOTTOM_RATIO) - cropped.height
|
||||
y = max(round(height * POSE_LAYOUT_MARGIN_RATIO), min(height - round(height * POSE_LAYOUT_MARGIN_RATIO) - cropped.height, y))
|
||||
canvas.alpha_composite(cropped, (x, y))
|
||||
canvas.save(path)
|
||||
return {"scale": round(scale, 4), "body_width": cropped.width, "body_height": cropped.height, "center_error": 0.0, "components": int(count)}
|
||||
|
||||
|
||||
def _create_lower_body_edit_mask(neutral_pose_path: Path, output_path: Path) -> None:
|
||||
"""Protect a neutral pose except for a compact, character-relative leg region."""
|
||||
with Image.open(neutral_pose_path) as source:
|
||||
@@ -1097,6 +1226,46 @@ def _create_lower_body_edit_mask(neutral_pose_path: Path, output_path: Path) ->
|
||||
mask.save(output_path)
|
||||
|
||||
|
||||
def _validate_single_pose_cutout(path: Path, pose_name: str, neutral_path: Path | None) -> dict[str, object]:
|
||||
image = Image.open(path).convert("RGBA")
|
||||
alpha = np.asarray(image)[:, :, 3] > 16
|
||||
ys, xs = np.where(alpha)
|
||||
if not xs.size:
|
||||
raise RuntimeError("逐格QA失败:抠图中没有角色")
|
||||
width, height = image.size
|
||||
bbox = (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1)
|
||||
body_w, body_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
center_error = abs((bbox[0] + bbox[2]) / 2 - width / 2) / width
|
||||
failures = []
|
||||
warnings = []
|
||||
if not 0.35 <= body_h / height <= 0.95: failures.append(f"角色高度占比异常 {body_h / height:.2f}")
|
||||
if not 0.08 <= body_w / width <= 0.70: failures.append(f"角色宽度占比异常 {body_w / width:.2f}")
|
||||
if center_error > 0.18:
|
||||
if center_error <= 0.24:
|
||||
warnings.append(f"角色轻微水平偏心 {center_error:.2f}")
|
||||
else:
|
||||
failures.append(f"角色水平偏心 {center_error:.2f}")
|
||||
edge_margin = max(2, round(min(width, height) * 0.01))
|
||||
edge_pixels = int(alpha[:edge_margin].sum() + alpha[-edge_margin:].sum() + alpha[:, :edge_margin].sum() + alpha[:, -edge_margin:].sum())
|
||||
if edge_pixels > 0:
|
||||
warnings.append(f"边缘有少量残留像素 {edge_pixels}")
|
||||
motion_xor = None
|
||||
upper_xor = None
|
||||
if pose_name != "neutral" and neutral_path and neutral_path.exists():
|
||||
neutral = np.asarray(Image.open(neutral_path).convert("RGBA"))[:, :, 3] > 16
|
||||
if neutral.shape == alpha.shape:
|
||||
split = max(1, round(bbox[1] + body_h * 0.64))
|
||||
upper_union = np.logical_or(neutral[:split], alpha[:split])
|
||||
upper_xor = float(np.logical_xor(neutral[:split], alpha[:split]).sum() / max(1, upper_union.sum()))
|
||||
lower_union = np.logical_or(neutral[split:], alpha[split:])
|
||||
motion_xor = float(np.logical_xor(neutral[split:], alpha[split:]).sum() / max(1, lower_union.sum()))
|
||||
if upper_xor > 0.12: failures.append(f"上半身漂移过大 {upper_xor:.3f}")
|
||||
if motion_xor < 0.012: failures.append(f"腿脚动作不足 {motion_xor:.3f}")
|
||||
qa = {"passed": not failures, "bbox": bbox, "canvas": [width, height], "body_width": body_w, "body_height": body_h, "center_error": round(center_error, 4), "upper_xor": upper_xor, "motion_xor": motion_xor, "failures": failures, "warnings": warnings}
|
||||
if failures: raise RuntimeError("逐格QA失败:" + ";".join(failures))
|
||||
return qa
|
||||
|
||||
|
||||
def main(argv: Iterable[str]) -> int:
|
||||
args = _parse_args(argv)
|
||||
out_dir = args.out_dir.resolve()
|
||||
@@ -1159,12 +1328,13 @@ def main(argv: Iterable[str]) -> int:
|
||||
identity_prompt_path = prompt_dir / f"{skin_name}_identity.txt"
|
||||
identity_path = identity_dir / f"{skin_name}_identity_reference.png"
|
||||
identity_prompt_path.write_text(_identity_prompt(), encoding="utf-8")
|
||||
_status(
|
||||
args.status_json,
|
||||
"identity",
|
||||
"正在基于上传图片和whaleboy参考条生成角色身份母版",
|
||||
)
|
||||
_run_with_retries(
|
||||
if args.phase != "actions":
|
||||
_status(
|
||||
args.status_json,
|
||||
"identity",
|
||||
"正在基于上传图片和海风少年单格参考生成角色身份母版",
|
||||
)
|
||||
_run_with_retries(
|
||||
[
|
||||
sys.executable,
|
||||
str(args.novamailio_script),
|
||||
@@ -1191,26 +1361,45 @@ def main(argv: Iterable[str]) -> int:
|
||||
log_path=log_path,
|
||||
env=child_env,
|
||||
attempts=3,
|
||||
retry_delay=10.0,
|
||||
)
|
||||
retry_delay=10.0,
|
||||
)
|
||||
elif not identity_path.exists():
|
||||
raise FileNotFoundError("已确认的身份母版不存在,请先完成身份任务")
|
||||
|
||||
if args.phase == "identity":
|
||||
result = {"ok": True, "phase": "identity", "identity_path": str(identity_path), "identity_prompt_path": str(identity_prompt_path), "log_path": str(log_path)}
|
||||
args.result_json.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
_status(args.status_json, "identity_done", "身份母版已生成,请确认后继续动作")
|
||||
return 0
|
||||
|
||||
_status(args.status_json, "prompt", "正在规划四方向8帧动作")
|
||||
canonical_front_identity_path: Path | None = None
|
||||
for direction in DIRECTIONS:
|
||||
active_directions = (args.direction,) if args.phase == "pose" and args.direction else DIRECTIONS
|
||||
for direction in active_directions:
|
||||
pose_cutouts: dict[int, Path] = {}
|
||||
pose_raw_paths: dict[int, Path] = {}
|
||||
neutral_pose_path: Path | None = None
|
||||
for existing_index, existing_name in enumerate(POSE_NAMES):
|
||||
existing_raw = raw_dir / f"{skin_name}_{direction}_{existing_name}_source.png"
|
||||
existing_cutout = cutout_dir / f"{skin_name}_{direction}_{existing_name}_cutout.png"
|
||||
if existing_raw.exists(): pose_raw_paths[existing_index] = existing_raw
|
||||
if existing_cutout.exists(): pose_cutouts[existing_index] = existing_cutout
|
||||
neutral_pose_path: Path | None = pose_raw_paths.get(0)
|
||||
lower_body_mask_path = (
|
||||
pose_reference_dir / f"{direction}_lower_body_edit_mask.png"
|
||||
)
|
||||
for generation_step, pose_index in enumerate(
|
||||
POSE_GENERATION_ORDER, start=1
|
||||
):
|
||||
if args.phase == "pose" and POSE_NAMES[pose_index] != args.pose:
|
||||
continue
|
||||
pose_name = POSE_NAMES[pose_index]
|
||||
prompt_path = prompt_dir / f"{skin_name}_{direction}_{pose_name}.txt"
|
||||
pose_reference_path = (
|
||||
pose_reference_dir / f"{direction}_{pose_name}_reference.png"
|
||||
)
|
||||
layout_guide_path = (
|
||||
pose_reference_dir / f"{direction}_{pose_name}_layout_guide.png"
|
||||
)
|
||||
raw_path = raw_dir / f"{skin_name}_{direction}_{pose_name}_source.png"
|
||||
cutout_path = (
|
||||
cutout_dir / f"{skin_name}_{direction}_{pose_name}_cutout.png"
|
||||
@@ -1224,22 +1413,26 @@ def main(argv: Iterable[str]) -> int:
|
||||
(0, 1, 3)[pose_index],
|
||||
pose_reference_path,
|
||||
)
|
||||
_create_single_pose_layout_guide(layout_guide_path)
|
||||
if pose_index == 1:
|
||||
sibling_pose_path = pose_raw_paths.get(2)
|
||||
if sibling_pose_path is None:
|
||||
raise RuntimeError(
|
||||
f"{direction} opposite-leg sibling pose was not generated first"
|
||||
)
|
||||
prompt_path.write_text(
|
||||
_single_pose_from_sibling_prompt(direction, pose_index),
|
||||
encoding="utf-8",
|
||||
)
|
||||
prompt_text = _single_pose_from_sibling_prompt(direction, pose_index)
|
||||
motion_reference_path = sibling_pose_path
|
||||
else:
|
||||
prompt_path.write_text(
|
||||
_single_pose_prompt(direction, pose_index), encoding="utf-8"
|
||||
)
|
||||
prompt_text = _single_pose_prompt(direction, pose_index)
|
||||
motion_reference_path = pose_reference_path
|
||||
if args.qa_feedback:
|
||||
prompt_text += (
|
||||
"\n\nTargeted repair feedback from the previous candidate:\n"
|
||||
"- The previous candidate failed this QA check: "
|
||||
+ args.qa_feedback.strip()
|
||||
+ "\n- Correct only the reported layout or motion issue. Preserve the approved identity and requested action exactly.\n"
|
||||
)
|
||||
prompt_path.write_text(prompt_text, encoding="utf-8")
|
||||
|
||||
_status(
|
||||
args.status_json,
|
||||
@@ -1267,6 +1460,8 @@ def main(argv: Iterable[str]) -> int:
|
||||
),
|
||||
"--image",
|
||||
str(motion_reference_path),
|
||||
"--image",
|
||||
str(layout_guide_path),
|
||||
"--prompt-file",
|
||||
str(prompt_path),
|
||||
"--size",
|
||||
@@ -1328,6 +1523,15 @@ def main(argv: Iterable[str]) -> int:
|
||||
env=child_env,
|
||||
)
|
||||
pose_cutouts[pose_index] = cutout_path
|
||||
layout_qa = _normalize_single_pose_cutout(cutout_path)
|
||||
pose_qa = _validate_single_pose_cutout(cutout_path, pose_name, pose_cutouts.get(0))
|
||||
pose_qa["layout"] = layout_qa
|
||||
|
||||
if args.phase == "pose":
|
||||
result = {"ok": True, "phase": "pose", "direction": direction, "pose": pose_name, "raw_path": str(raw_path), "cutout_path": str(cutout_path), "preview_path": str(preview_path), "prompt_path": str(prompt_path), "layout_guide_path": str(layout_guide_path), "pose_reference_path": str(pose_reference_path), "qa": pose_qa}
|
||||
args.result_json.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
_status(args.status_json, "pose_done", f"{direction} {pose_name} 动作已生成", direction=direction, pose=pose_name)
|
||||
return 0
|
||||
|
||||
expanded_path = expanded_dir / f"{skin_name}_{direction}_8frame_cutout.png"
|
||||
expand_command = [
|
||||
|
||||
BIN
scripts/skin_generation/references/whaleboy_identity_single.png
Normal file
BIN
scripts/skin_generation/references/whaleboy_identity_single.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
410
scripts/test_world_npc_runtime.ts
Normal file
410
scripts/test_world_npc_runtime.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildNpcInteractionMessages, fallbackNpcPlan, townDate, WorldNpcPlanner,
|
||||
} from '../src/business/world_npc/world_npc.planner';
|
||||
import { WorldNpcService } from '../src/business/world_npc/world_npc.service';
|
||||
import { WorldNpcConversationEvent, WorldNpcDailyPlan } from '../src/business/world_npc/world_npc.types';
|
||||
import {
|
||||
findWorldRoute, getWorldLocation, isWorldNpcPublicMap, WORLD_LOCATIONS,
|
||||
WORLD_NPC_PUBLIC_MAP_IDS, WORLD_ROUTE_EDGES,
|
||||
} from '../src/business/world_npc/world_npc.world';
|
||||
import { WorldNpcClock } from '../src/business/world_npc/world_npc.clock';
|
||||
import { WORLD_NPC_DEFINITIONS } from '../src/business/world_npc/world_npc.registry';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
process.env.WORLD_NPC_PERSISTENCE = 'off';
|
||||
const dialogueMessages = buildNpcInteractionMessages({
|
||||
definition: {
|
||||
npcId: 'npc_whale_researcher', name: '鲸小研', role: '科研观察员', personality: '友善、好奇',
|
||||
dailyFocus: '收集科研兴趣', homeLocationId: 'square_dock_research', scene: 'classic_whale',
|
||||
},
|
||||
activity: {
|
||||
id: 'square_interviews', title: '广场访谈', intention: '收集科研话题', locationId: 'square_forum',
|
||||
startMinute: 540, endMinute: 660, activityKind: 'socialize', dialogue: '你最近在研究什么?',
|
||||
},
|
||||
dailyGoal: '整理居民的科研问题', username: '测试居民', residentSummary: '居民对海洋科学感兴趣',
|
||||
sessionTurns: [
|
||||
{ role: 'user', content: '你还记得我刚才说的方向吗?', createdAt: 1 },
|
||||
{ role: 'assistant', content: '记得,你想先看海流数据。', createdAt: 2 },
|
||||
],
|
||||
message: '那我们继续吧。',
|
||||
});
|
||||
assert.deepEqual(dialogueMessages.map((message) => message.role),
|
||||
['system', 'user', 'assistant', 'user'],
|
||||
'short-term NPC conversation must use normal multi-turn chat roles');
|
||||
assert.match(String(dialogueMessages[0].content), /当前每日目标/);
|
||||
assert.match(String(dialogueMessages[0].content), /海洋科学/);
|
||||
assert.match(String(dialogueMessages[0].content), /Agent 工具 query_npc_memory/);
|
||||
assert.equal(dialogueMessages.at(-1)?.content, '那我们继续吧。');
|
||||
const realPlanner = new WorldNpcPlanner();
|
||||
const planningDefinition = {
|
||||
npcId: 'npc_whale_researcher', name: '鲸小研', role: '科研观察员', personality: '友善、好奇',
|
||||
dailyFocus: '长期跟进小镇的科研需求', homeLocationId: 'square_dock_research', scene: 'classic_whale' as const,
|
||||
};
|
||||
const planningSystemPrompt = (realPlanner as any).systemPrompt(planningDefinition, {
|
||||
previousDailyPlan: fallbackNpcPlan(planningDefinition, Date.parse('2026-08-27T12:00:00+08:00')),
|
||||
npcMemories: [], residentNeedSummaries: ['居民希望今天有一场海洋科学分享'], activeResidentSignals: [],
|
||||
});
|
||||
assert.match(planningSystemPrompt, /角色长期记忆/);
|
||||
assert.match(planningSystemPrompt, /长期跟进小镇的科研需求/);
|
||||
assert.match(planningSystemPrompt, /海洋科学分享/);
|
||||
assert.match(planningSystemPrompt, /locationName/);
|
||||
assert.doesNotMatch(planningSystemPrompt, /locationId/);
|
||||
const modelPlan = (realPlanner as any).validatePlan({
|
||||
goal: '在广场整理居民的科研问题',
|
||||
activities: [{
|
||||
id: 'forum_research', title: '广场科研交流', intention: '收集科研问题',
|
||||
locationName: '广场交流区', startMinute: 0, endMinute: 1440,
|
||||
activityKind: 'socialize', dialogue: '今天想和大家聊聊最近关心的科研问题。',
|
||||
}],
|
||||
}, '2026-08-29');
|
||||
assert.equal(modelPlan.activities[0].locationId, 'square_forum');
|
||||
assert.throws(() => (realPlanner as any).validatePlan({
|
||||
goal: '错误示例',
|
||||
activities: [{
|
||||
id: 'internal_id', title: '错误地点', intention: '测试内部 ID',
|
||||
locationId: 'square_forum', startMinute: 0, endMinute: 1440,
|
||||
activityKind: 'socialize', dialogue: '这条计划不应该通过校验。',
|
||||
}],
|
||||
}, '2026-08-29'), /invalid enum/);
|
||||
let previousPlanDateSeenByPlanner = '';
|
||||
const crossDayPlanner = {
|
||||
createDailyPlan: async (definition: any, context: any, planNow: number) => {
|
||||
if (definition.npcId === 'npc_whale_researcher') {
|
||||
previousPlanDateSeenByPlanner = String(context.previousDailyPlan?.date || '');
|
||||
}
|
||||
return fallbackNpcPlan(definition, planNow);
|
||||
},
|
||||
} as WorldNpcPlanner;
|
||||
const crossDayService = new WorldNpcService(crossDayPlanner);
|
||||
const previousDayNow = Date.parse('2026-08-28T12:00:00+08:00');
|
||||
const nextDayNow = Date.parse('2026-08-29T00:01:00+08:00');
|
||||
crossDayService.replacePlanForTesting(fallbackNpcPlan(planningDefinition, previousDayNow));
|
||||
await crossDayService.tick(nextDayNow);
|
||||
assert.equal(previousPlanDateSeenByPlanner, '2026-08-28',
|
||||
'the completed daily plan must become long-term planning memory after midnight');
|
||||
const planner = {
|
||||
createDailyPlan: async (definition: any, _memories: unknown, planNow: number) => fallbackNpcPlan(definition, planNow),
|
||||
createInteractionReply: async () => '这个问题很有意思,我已经记进今天的研究笔记了。',
|
||||
} as WorldNpcPlanner;
|
||||
const service = new WorldNpcService(planner);
|
||||
const now = Date.now();
|
||||
const plan: WorldNpcDailyPlan = {
|
||||
date: townDate(now), goal: '去咖啡馆收集研究问题', source: 'agent',
|
||||
activities: [{
|
||||
id: 'cafe_visit', title: '咖啡馆访谈', intention: '前往咖啡馆访谈',
|
||||
locationId: 'cafe_research_table', startMinute: 0, endMinute: 1440,
|
||||
activityKind: 'socialize', dialogue: '你最近在研究什么?',
|
||||
}],
|
||||
};
|
||||
service.replacePlanForTesting(plan);
|
||||
for (const edge of WORLD_ROUTE_EDGES) {
|
||||
const from = getWorldLocation(edge.from);
|
||||
const to = getWorldLocation(edge.to);
|
||||
assert(from.x !== to.x || from.y !== to.y || from.mapId !== to.mapId, `zero-length edge: ${edge.from}`);
|
||||
assert.equal(edge.kind === 'transition', from.mapId !== to.mapId, `edge/map mismatch: ${edge.from} -> ${edge.to}`);
|
||||
}
|
||||
for (const location of WORLD_LOCATIONS) {
|
||||
assert(isWorldNpcPublicMap(location.mapId), `private map exposed to NPC planner: ${location.mapId}`);
|
||||
assert(findWorldRoute(WORLD_LOCATIONS[0].id, location.id).length > 0, `disconnected location: ${location.id}`);
|
||||
if (!location.tags.includes('transit')) {
|
||||
const movableDefinitions = WORLD_NPC_DEFINITIONS.filter((definition) => !definition.stationary);
|
||||
assert((location.slots?.length || 0) >= movableDefinitions.length,
|
||||
`activity location must have one slot per movable NPC: ${location.id}`);
|
||||
const slotKeys = new Set(location.slots!.map((slot) => `${slot.x}:${slot.y}`));
|
||||
assert.equal(slotKeys.size, location.slots!.length,
|
||||
`activity location contains duplicate NPC slots: ${location.id}`);
|
||||
const assignedPoints = movableDefinitions.map((definition) =>
|
||||
(service as any).locationPointForNpc(definition.npcId, location.id) as { x: number; y: number });
|
||||
assert.equal(new Set(assignedPoints.map((point) => `${point.x}:${point.y}`)).size,
|
||||
movableDefinitions.length, `NPC slot assignment reuses a position: ${location.id}`);
|
||||
for (let first = 0; first < location.slots!.length; first += 1) {
|
||||
for (let second = first + 1; second < location.slots!.length; second += 1) {
|
||||
assert(Math.hypot(
|
||||
location.slots![first].x - location.slots![second].x,
|
||||
location.slots![first].y - location.slots![second].y,
|
||||
) >= 80, `activity location slots are too close: ${location.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert(!WORLD_NPC_PUBLIC_MAP_IDS.includes('personal_space' as any), 'personal rooms must not be in the NPC world');
|
||||
assert.equal(service.getMapSnapshot('personal_space', now).npcs.length, 0,
|
||||
'personal rooms must never receive world NPC snapshots');
|
||||
const cafeRoute = findWorldRoute('square_dock_research', 'cafe_research_table');
|
||||
assert.equal(cafeRoute[0], 'square_dock_research');
|
||||
assert.equal(cafeRoute[cafeRoute.length - 1], 'cafe_research_table');
|
||||
assert(cafeRoute.includes('square_work_gate') && cafeRoute.includes('work_square_gate'));
|
||||
assert(cafeRoute.includes('work_cafe_gate') && cafeRoute.includes('cafe_entrance'));
|
||||
|
||||
let clock = now;
|
||||
let previousLocation = 'square_dock_research';
|
||||
let sawTransition = false;
|
||||
for (let step = 0; step < 24; step += 1) {
|
||||
await service.tick(clock);
|
||||
const active = service.getRuntimeForTesting().activeAction;
|
||||
assert(active, 'runtime must always have a route or activity action');
|
||||
assert.equal(active.fromLocationId, previousLocation, 'each action must start at the previous destination');
|
||||
sawTransition ||= active.kind === 'transition';
|
||||
clock = active.completesAt + 1;
|
||||
await service.tick(clock);
|
||||
previousLocation = service.getRuntimeForTesting().locationId;
|
||||
if (previousLocation === 'cafe_research_table') break;
|
||||
}
|
||||
const runtime = service.getRuntimeForTesting();
|
||||
assert.equal(runtime.locationId, 'cafe_research_table');
|
||||
assert.equal(runtime.mapId, 'whale_cafe');
|
||||
assert(sawTransition);
|
||||
assert(!service.getMapSnapshot('whale_port', clock).npcs.some((npc) => npc.npcId === 'npc_whale_researcher'));
|
||||
assert.equal(service.getMapSnapshot('whale_cafe', clock).npcs.find((npc) =>
|
||||
npc.npcId === 'npc_whale_researcher')?.state, 'talking');
|
||||
|
||||
await assert.rejects(() => service.interact({
|
||||
npcId: 'npc_whale_researcher', userId: 'far-user', username: '远处玩家',
|
||||
mapId: 'whale_cafe', x: 1_000, y: 1_000, message: '听得到吗?', now: clock,
|
||||
}), /距离NPC太远/);
|
||||
const interaction = await service.interact({
|
||||
npcId: 'npc_whale_researcher', userId: 'near-user', username: '测试居民',
|
||||
mapId: 'whale_cafe', x: -200, y: 300, message: '今天研究什么?', now: clock,
|
||||
});
|
||||
assert.equal(interaction.response, '这个问题很有意思,我已经记进今天的研究笔记了。');
|
||||
assert.equal(service.getTownStatus(clock).npcs.find((npc) =>
|
||||
npc.definition.npcId === 'npc_whale_researcher')?.memoryCount, 1);
|
||||
assert.equal(service.getTownStatus(clock).npcs.length, WORLD_NPC_DEFINITIONS.length);
|
||||
|
||||
const replanNow = Date.parse('2026-08-28T10:00:00+08:00');
|
||||
const initialReplanPlan = fallbackNpcPlan({
|
||||
npcId: 'npc_whale_researcher',
|
||||
name: '鲸小研',
|
||||
role: '科研交流员',
|
||||
personality: '好奇、耐心',
|
||||
dailyFocus: '收集科研兴趣',
|
||||
homeLocationId: 'square_dock_research',
|
||||
scene: 'classic_whale',
|
||||
}, replanNow);
|
||||
let revisionMemoryCount = 0;
|
||||
const replanningPlanner = {
|
||||
createDailyPlan: async () => initialReplanPlan,
|
||||
createInteractionReply: async () => '我会把这个需求放进今天后续的安排。',
|
||||
reviseRemainingPlan: async (_definition: unknown, current: WorldNpcDailyPlan, context: any) => {
|
||||
revisionMemoryCount = context.npcMemories.length + context.residentNeedSummaries.length
|
||||
+ context.activeResidentSignals.length;
|
||||
return {
|
||||
...current,
|
||||
goal: '根据居民需求调整今天剩余的科研交流',
|
||||
source: 'agent' as const,
|
||||
revisionReason: 'interaction' as const,
|
||||
generatedAt: replanNow,
|
||||
activities: current.activities.map((activity) => activity.id === 'evening_share'
|
||||
? { ...activity, locationId: 'square_forum', intention: '回应居民提出的新需求' }
|
||||
: activity),
|
||||
};
|
||||
},
|
||||
} as unknown as WorldNpcPlanner;
|
||||
const replanningService = new WorldNpcService(replanningPlanner);
|
||||
replanningService.replacePlanForTesting(initialReplanPlan);
|
||||
await replanningService.tick(replanNow);
|
||||
let routeAction = replanningService.getRuntimeForTesting().activeAction;
|
||||
assert(routeAction && routeAction.kind === 'walk');
|
||||
let replanClock = routeAction.completesAt + 1;
|
||||
while (routeAction && routeAction.kind !== 'perform') {
|
||||
await replanningService.tick(replanClock);
|
||||
routeAction = replanningService.getRuntimeForTesting().activeAction;
|
||||
if (routeAction && routeAction.kind !== 'perform') replanClock = routeAction.completesAt + 1;
|
||||
}
|
||||
assert.equal(replanningService.getRuntimeForTesting().locationId, 'square_forum');
|
||||
const activityBeforeRevision = replanningService.getRuntimeForTesting().activeAction;
|
||||
assert.equal(activityBeforeRevision?.kind, 'perform');
|
||||
assert.equal(activityBeforeRevision?.completesAt, Date.parse('2026-08-28T11:00:00+08:00'),
|
||||
'activity end must stay anchored to the daily schedule after travel');
|
||||
process.env.WORLD_NPC_REPLAN_COOLDOWN_MS = '0';
|
||||
const replanSnapshot = replanningService.getMapSnapshot('whale_port', replanClock).npcs.find((npc) =>
|
||||
npc.npcId === 'npc_whale_researcher')!;
|
||||
await replanningService.interact({
|
||||
npcId: 'npc_whale_researcher', userId: 'replan-user', username: '提出需求的居民',
|
||||
mapId: 'whale_port', x: replanSnapshot.x, y: replanSnapshot.y, message: '傍晚可以在广场回应这个问题吗?',
|
||||
now: replanClock,
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const revisedRuntime = replanningService.getRuntimeForTesting();
|
||||
assert.equal(revisionMemoryCount, 1);
|
||||
assert.equal(revisedRuntime.plan.revisionReason, 'interaction');
|
||||
assert.equal(revisedRuntime.plan.goal, '根据居民需求调整今天剩余的科研交流');
|
||||
assert.equal(revisedRuntime.activeAction?.actionId, activityBeforeRevision?.actionId,
|
||||
'replanning must not interrupt the current activity');
|
||||
assert.equal(revisedRuntime.plan.activities.find((activity) => activity.id === 'evening_share')?.locationId,
|
||||
'square_forum');
|
||||
delete process.env.WORLD_NPC_REPLAN_COOLDOWN_MS;
|
||||
|
||||
const persistedAction = routeAction!;
|
||||
const persistedRuntime = replanningService.getRuntimeForTesting();
|
||||
persistedRuntime.locationId = persistedAction.fromLocationId;
|
||||
persistedRuntime.mapId = persistedAction.fromMapId;
|
||||
persistedRuntime.x = persistedAction.fromX;
|
||||
persistedRuntime.y = persistedAction.fromY;
|
||||
persistedRuntime.activityId = persistedAction.activityId;
|
||||
persistedRuntime.activeAction = persistedAction;
|
||||
const normalizeRuntime = (replanningService as any).normalizeRuntime.bind(replanningService);
|
||||
const restored = normalizeRuntime(
|
||||
replanningService.getTownStatus(replanNow).npcs.find((npc) => npc.definition.npcId === 'npc_whale_researcher')!.definition,
|
||||
persistedRuntime,
|
||||
persistedAction.startedAt + 1,
|
||||
);
|
||||
assert.equal(restored.activeAction?.actionId, persistedAction.actionId,
|
||||
'a running persisted route must survive a server restart');
|
||||
const completedOnRestart = normalizeRuntime(
|
||||
replanningService.getTownStatus(replanNow).npcs.find((npc) => npc.definition.npcId === 'npc_whale_researcher')!.definition,
|
||||
persistedRuntime,
|
||||
persistedAction.completesAt + 1,
|
||||
);
|
||||
assert.equal(completedOnRestart.locationId, persistedAction.toLocationId,
|
||||
'an expired persisted route must recover at its destination');
|
||||
|
||||
const dayStart = Date.parse('2026-08-28T00:00:00+08:00');
|
||||
for (const npcId of ['npc_whale_researcher']) {
|
||||
const dayService = new WorldNpcService(planner);
|
||||
const definition = dayService.getTownStatus(dayStart).npcs.find((npc) => npc.definition.npcId === npcId)!.definition;
|
||||
const dayPlan = fallbackNpcPlan(definition, dayStart);
|
||||
dayService.replacePlanForTesting(dayPlan, npcId);
|
||||
for (const scheduledActivity of dayPlan.activities) {
|
||||
let activityClock = dayStart + scheduledActivity.startMinute * 60_000;
|
||||
let reachedActivity = false;
|
||||
for (let actionStep = 0; actionStep < 20; actionStep += 1) {
|
||||
await dayService.tick(activityClock);
|
||||
const active = dayService.getRuntimeForTesting(npcId).activeAction;
|
||||
assert(active, `${npcId}/${scheduledActivity.id} must have an active action`);
|
||||
if (active.kind === 'perform' && active.activityId === scheduledActivity.id) {
|
||||
reachedActivity = true;
|
||||
break;
|
||||
}
|
||||
activityClock = active.completesAt + 1;
|
||||
}
|
||||
const arrived = dayService.getRuntimeForTesting(npcId);
|
||||
assert(reachedActivity, `${npcId}/${scheduledActivity.id} did not reach its activity`);
|
||||
assert.equal(arrived.locationId, scheduledActivity.locationId,
|
||||
`${npcId}/${scheduledActivity.id} arrived at the wrong semantic location`);
|
||||
assert.equal(arrived.mapId, getWorldLocation(scheduledActivity.locationId).mapId,
|
||||
`${npcId}/${scheduledActivity.id} arrived on the wrong map`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [npcId, expected] of [
|
||||
['npc_town_mayor', { locationId: 'square_guild_reception', x: -199, y: -515 }],
|
||||
['npc_dock_guide', { locationId: 'square_dock_guide', x: -825, y: 437 }],
|
||||
] as const) {
|
||||
const stationaryService = new WorldNpcService(planner);
|
||||
const definition = stationaryService.getTownStatus(dayStart).npcs.find((npc) => npc.definition.npcId === npcId)!.definition;
|
||||
stationaryService.replacePlanForTesting(fallbackNpcPlan(definition, dayStart), npcId);
|
||||
for (const minute of [0, 600, 1200]) {
|
||||
await stationaryService.tick(dayStart + minute * 60_000);
|
||||
const runtime = stationaryService.getRuntimeForTesting(npcId);
|
||||
assert.equal(runtime.locationId, expected.locationId);
|
||||
assert.deepEqual({ x: runtime.x, y: runtime.y }, { x: expected.x, y: expected.y });
|
||||
assert.equal(runtime.activeAction?.kind, 'perform');
|
||||
assert.equal(runtime.activeAction?.fromLocationId, expected.locationId);
|
||||
assert.equal(runtime.activeAction?.toLocationId, expected.locationId);
|
||||
assert.equal(stationaryService.getMapSnapshot('whale_port', dayStart + minute * 60_000)
|
||||
.npcs.find((npc) => npc.npcId === npcId)?.movementState, 'idle');
|
||||
}
|
||||
}
|
||||
|
||||
let socialPlannerCalls = 0;
|
||||
const socialPlanner = {
|
||||
createDailyPlan: async (definition: any, _memories: unknown, planNow: number) => fallbackNpcPlan(definition, planNow),
|
||||
createInteractionReply: async () => '',
|
||||
createNpcConversation: async (input: any) => {
|
||||
socialPlannerCalls += 1;
|
||||
return [
|
||||
{ speakerNpcId: input.first.npcId, speakerName: input.first.name, text: '我收集到一个值得继续研究的问题。' },
|
||||
{ speakerNpcId: input.second.npcId, speakerName: input.second.name, text: '我会把它带到今天的居民交流里。' },
|
||||
];
|
||||
},
|
||||
} as WorldNpcPlanner;
|
||||
const socialService = new WorldNpcService(socialPlanner);
|
||||
const socialDate = townDate(replanNow);
|
||||
const atForum = (id: string): WorldNpcDailyPlan => ({
|
||||
date: socialDate,
|
||||
goal: '在广场交换今天的信息',
|
||||
source: 'agent',
|
||||
activities: [{
|
||||
id, title: '广场交流', intention: '与其他小镇成员交换信息',
|
||||
locationId: 'square_forum', startMinute: 0, endMinute: 1440,
|
||||
activityKind: 'socialize', dialogue: '今天有什么新消息?',
|
||||
}],
|
||||
});
|
||||
const atReception = (id: string): WorldNpcDailyPlan => ({
|
||||
date: socialDate,
|
||||
goal: '在公会接待处交换今天的信息',
|
||||
source: 'agent',
|
||||
activities: [{
|
||||
id, title: '接待处交流', intention: '与到访居民交换信息',
|
||||
locationId: 'square_guild_reception', startMinute: 0, endMinute: 1440,
|
||||
activityKind: 'socialize', dialogue: '今天有什么新消息?',
|
||||
}],
|
||||
});
|
||||
socialService.replacePlanForTesting(atReception('researcher_social'), 'npc_whale_researcher');
|
||||
socialService.replacePlanForTesting(atReception('mayor_social'), 'npc_town_mayor');
|
||||
const nonSocialPlan = (id: string, locationId: string): WorldNpcDailyPlan => ({
|
||||
date: socialDate,
|
||||
goal: '独立完成今天的工作',
|
||||
source: 'agent',
|
||||
activities: [{
|
||||
id, title: '独立工作', intention: '完成自己的日常工作', locationId,
|
||||
startMinute: 0, endMinute: 1440, activityKind: 'organize', dialogue: '我正在整理今天的工作。',
|
||||
}],
|
||||
});
|
||||
socialService.replacePlanForTesting(nonSocialPlan('guide_work', 'square_dock_guide'), 'npc_dock_guide');
|
||||
socialService.replacePlanForTesting(nonSocialPlan('niulai_work', 'square_dock_research'), 'npc_niulai');
|
||||
let socialClock = replanNow;
|
||||
const socialConversations: WorldNpcConversationEvent[] = [];
|
||||
for (let step = 0; step < 12; step += 1) {
|
||||
const tickResult = await socialService.tick(socialClock);
|
||||
socialConversations.push(...tickResult.conversations);
|
||||
const participants = ['npc_whale_researcher', 'npc_town_mayor']
|
||||
.map((npcId) => socialService.getRuntimeForTesting(npcId));
|
||||
if (participants.every((runtime) => runtime.locationId === 'square_guild_reception'
|
||||
&& runtime.activeAction?.kind === 'perform')) break;
|
||||
const completionTimes = participants
|
||||
.map((runtime) => runtime.activeAction?.completesAt)
|
||||
.filter((value): value is number => typeof value === 'number' && value > socialClock);
|
||||
assert(completionTimes.length > 0, 'social participants stopped before reaching the forum');
|
||||
socialClock = Math.min(...completionTimes) + 1;
|
||||
}
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const socialTick = await socialService.tick(socialClock);
|
||||
socialConversations.push(...socialTick.conversations);
|
||||
assert.equal(socialPlannerCalls, 1, 'one co-located NPC pair should create one encounter');
|
||||
assert.equal(socialConversations.length, 1);
|
||||
assert.deepEqual(socialConversations[0].participantNpcIds,
|
||||
['npc_town_mayor', 'npc_whale_researcher']);
|
||||
assert.equal(socialService.getTownStatus(socialClock).npcs.find((npc) =>
|
||||
npc.definition.npcId === 'npc_whale_researcher')?.recentNpcEncounters.length, 1);
|
||||
const socialPositions = ['npc_whale_researcher', 'npc_town_mayor']
|
||||
.map((npcId) => socialService.getMapSnapshot('whale_port', socialClock).npcs.find((npc) => npc.npcId === npcId)!);
|
||||
assert.notDeepEqual(
|
||||
{ x: socialPositions[0].x, y: socialPositions[0].y },
|
||||
{ x: socialPositions[1].x, y: socialPositions[1].y },
|
||||
'co-located NPCs must use distinct visual slots',
|
||||
);
|
||||
assert(Math.hypot(socialPositions[0].x - socialPositions[1].x,
|
||||
socialPositions[0].y - socialPositions[1].y) <= 160,
|
||||
'conversation slots must remain visually close');
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const duplicateSocialTick = await socialService.tick(socialClock + 1);
|
||||
assert.equal(duplicateSocialTick.conversations.length, 0, 'the same encounter must not repeat');
|
||||
assert.equal(socialPlannerCalls, 1);
|
||||
|
||||
process.env.WORLD_NPC_TIME_SCALE = '60';
|
||||
const worldClock = new WorldNpcClock();
|
||||
const realAnchor = Date.now();
|
||||
const acceleratedDelta = worldClock.now(realAnchor + 1_000) - worldClock.now(realAnchor);
|
||||
assert.equal(acceleratedDelta, 60_000);
|
||||
worldClock.setForTesting(Date.parse('2026-08-28T10:00:00+08:00'));
|
||||
assert.equal(worldClock.now(), Date.parse('2026-08-28T10:00:00+08:00'));
|
||||
console.log('WORLD_NPC_RUNTIME_OK');
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user