TL;DR
Melee skins change your damage, and 12 base melees deal 25 no matter what they say.
emitHit sends item.id to the server as the weapon. For a skinned melee that id is the skin's id, and server.js does WEAPON_DAMAGE[data.weapon] || 25. A skin is documented in the code as "a new name and look, never a new weapon" — effectiveMeleeItem() deliberately keeps the base's damage, reach, cooldown and ability — but the one number that actually decides a fight is taken from a table the skin id isn't in.
(B) is the one to fix first — it needs no skin equipped and it hits paying customers.
Found while reviewing #51; not caused by it. This is CLAUDE.md gotcha #4 ("client ↔ server must mirror WEAPON_DAMAGE") going unenforced, which is exactly what #7 proposes to fix structurally.
(B) 12 base melees deal 25 regardless of what the client says 🗡️
public/game.js declares the damage; server.js decides it. These 12 are in the first table and not the second:
| melee |
client says |
server deals |
|
phase_blade |
90 |
25 |
−72% |
meat_cleaver |
60 |
25 |
−58% |
machete |
56 |
25 |
−55% |
hatchet |
50 |
25 |
−50% |
volt_whip |
50 |
25 |
−50% |
pipe |
44 |
25 |
−43% |
cricket_bat |
42 |
25 |
−40% |
golf_club |
40 |
25 |
−38% |
fire_poker |
38 |
25 |
−34% |
wrench |
36 |
25 |
−31% |
brass_knuckles |
28 |
25 |
−11% |
tennis_racket |
26 |
25 |
−4% |
The Phase Blade is a Sci-fi P2W melee. Someone paid for 90 damage and is swinging 25. The shop, the wiki and the loadout screen all show 90, so there is no way to notice except by counting hits.
Client-side prediction disagrees too, which is why it feels like desync rather than a number being wrong: getClientWeaponDamage misses these and returns 10, so you predict 10, the server applies 25, and the health bar jumps.
(A) A cosmetic skin changes the number 🎨
emitHit(pid, …, effectiveWeaponId, …) where effectiveWeaponId = item.id, and item came from equippedMeleeItem() → effectiveMeleeItem(), which sets id to the skin's id and stashes the real one in baseId.
25 of 27 skins send an id the server has never heard of and fall to || 25:
| skin |
worn on |
should deal |
actually deals |
combat_axe_firemans |
Combat Axe |
78 |
25 |
sledge_gold_brick |
Sledge |
70 |
25 |
sledge_anvil_on_a_stick |
Sledge |
70 |
25 |
katana_ruler |
Katana |
65 |
25 |
machete |
Katana |
65 |
25 |
spear_broom_handle |
Spear |
50 |
25 |
fire_poker |
Spear |
50 |
25 |
chainsaw_pruning |
Chainsaw |
45 |
25 |
| … |
|
|
(17 more) |
2 skins are worse than that — they resolve to a completely different melee's entry, because the skin id happens to also be a real melee id:
| skin |
worn on |
should deal |
actually deals |
why |
cane |
Spear |
50 |
30 |
WEAPON_DAMAGE['cane'] is the Walking Cane's |
shovel |
Sledge |
70 |
55 |
WEAPON_DAMAGE['shovel'] is the Shovel's |
The same skin id also reaches getSecretSynergy, falloffMultiplier, headshotMultFor and INSTAKILL_HS_WEAPONS inside emitHit — so e.g. the Fire Poker skin worn on a Spear picks up FIRE_WEAPONS map synergy that the Spear itself does not have.
Suggested fix
(A) is the same shape as the swing-sound fix in #51: resolve to the base once at the top of the melee hit loop and use it for every id that goes on the wire. effectiveMeleeItem() already provides baseId.
const hitId = item.baseId || item.id; // a skin is paint, not a weapon
let effectiveWeaponId = hitId;
// …and the same for the heavy-strike double emit, the slam, and the lunge,
// which each read item.id separately.
(B) is data: add the 12 missing ids to WEAPON_DAMAGE in server.js with the values MELEE_ITEMS already declares — and check the numbers first, since several of these have presumably been balanced by feel at 25 for a while. Going from 25 to 90 on the Phase Blade is a real balance change, not just a bug fix.
Worth more than either: a guard in tools/verify-weapons.js asserting every MELEE_ITEMS / WEAPONS id has a WEAPON_DAMAGE entry, and every BASIC_MELEE_SKINS id either has one or is never sent. That turns gotcha #4 from a thing to remember into a thing that fails the build. #7 (shared/tables.js) is the deeper version of the same idea.
中文摘要
近战皮肤会改伤害,而且 12 把基础近战不管客户端写多少都只打 25。
emitHit 把 item.id 当武器 id 发给服务器,而装了皮肤之后这个 id 是皮肤的 id;服务器 WEAPON_DAMAGE[data.weapon] || 25 查不到就给 25。代码注释白纸黑字写着皮肤"只是换个名字和外观,绝不是换把武器",effectiveMeleeItem() 也特意保留了基础武器的伤害/距离/冷却/技能——唯独真正决定胜负的那个数字,取自一张皮肤 id 根本不在的表。
- (A) 27 个皮肤里 25 个塌成 25 伤害;另外 2 个(
cane、shovel)更离谱,取到了另一把近战的数值。
- (B) 跟皮肤无关:42 把基础近战里有 12 把压根没有
WEAPON_DAMAGE 条目,同样只打 25。相位刃标着 90,实际 25(弱了 72%)——那是把 P2W 武器,玩家是花钱买的。
建议先修 (B):不用装皮肤就中招,而且坑的是付费玩家。但改数值之前先确认手感——这几把按 25 打了很久了,直接提到 90 是实打实的平衡改动,不只是修 bug。
顺带:tools/verify-weapons.js 应该加一条守卫,强制每个武器 id 都有 WEAPON_DAMAGE 条目,把 gotcha #4 从"要记得"变成"忘了就构建失败"。#7 提的 shared/tables.js 是同一件事的彻底版。
TL;DR
Melee skins change your damage, and 12 base melees deal 25 no matter what they say.
emitHitsendsitem.idto the server as the weapon. For a skinned melee that id is the skin's id, andserver.jsdoesWEAPON_DAMAGE[data.weapon] || 25. A skin is documented in the code as "a new name and look, never a new weapon" —effectiveMeleeItem()deliberately keeps the base's damage, reach, cooldown and ability — but the one number that actually decides a fight is taken from a table the skin id isn't in.|| 25default. Two more resolve to a different melee's damage.WEAPON_DAMAGEentry at all and also deal 25. The Phase Blade advertises 90 and deals 25.(B) is the one to fix first — it needs no skin equipped and it hits paying customers.
Found while reviewing #51; not caused by it. This is CLAUDE.md gotcha #4 ("client ↔ server must mirror
WEAPON_DAMAGE") going unenforced, which is exactly what #7 proposes to fix structurally.(B) 12 base melees deal 25 regardless of what the client says 🗡️
public/game.jsdeclares the damage;server.jsdecides it. These 12 are in the first table and not the second:phase_blademeat_cleavermachetehatchetvolt_whippipecricket_batgolf_clubfire_pokerwrenchbrass_knucklestennis_racketThe Phase Blade is a Sci-fi P2W melee. Someone paid for 90 damage and is swinging 25. The shop, the wiki and the loadout screen all show 90, so there is no way to notice except by counting hits.
Client-side prediction disagrees too, which is why it feels like desync rather than a number being wrong:
getClientWeaponDamagemisses these and returns 10, so you predict 10, the server applies 25, and the health bar jumps.(A) A cosmetic skin changes the number 🎨
emitHit(pid, …, effectiveWeaponId, …)whereeffectiveWeaponId = item.id, anditemcame fromequippedMeleeItem()→effectiveMeleeItem(), which setsidto the skin's id and stashes the real one inbaseId.25 of 27 skins send an id the server has never heard of and fall to
|| 25:combat_axe_firemanssledge_gold_bricksledge_anvil_on_a_stickkatana_rulermachetespear_broom_handlefire_pokerchainsaw_pruning2 skins are worse than that — they resolve to a completely different melee's entry, because the skin id happens to also be a real melee id:
caneWEAPON_DAMAGE['cane']is the Walking Cane'sshovelWEAPON_DAMAGE['shovel']is the Shovel'sThe same skin id also reaches
getSecretSynergy,falloffMultiplier,headshotMultForandINSTAKILL_HS_WEAPONSinsideemitHit— so e.g. the Fire Poker skin worn on a Spear picks upFIRE_WEAPONSmap synergy that the Spear itself does not have.Suggested fix
(A) is the same shape as the swing-sound fix in #51: resolve to the base once at the top of the melee hit loop and use it for every id that goes on the wire.
effectiveMeleeItem()already providesbaseId.(B) is data: add the 12 missing ids to
WEAPON_DAMAGEinserver.jswith the valuesMELEE_ITEMSalready declares — and check the numbers first, since several of these have presumably been balanced by feel at 25 for a while. Going from 25 to 90 on the Phase Blade is a real balance change, not just a bug fix.Worth more than either: a guard in
tools/verify-weapons.jsasserting everyMELEE_ITEMS/WEAPONSid has aWEAPON_DAMAGEentry, and everyBASIC_MELEE_SKINSid either has one or is never sent. That turns gotcha #4 from a thing to remember into a thing that fails the build. #7 (shared/tables.js) is the deeper version of the same idea.中文摘要
近战皮肤会改伤害,而且 12 把基础近战不管客户端写多少都只打 25。
emitHit把item.id当武器 id 发给服务器,而装了皮肤之后这个 id 是皮肤的 id;服务器WEAPON_DAMAGE[data.weapon] || 25查不到就给 25。代码注释白纸黑字写着皮肤"只是换个名字和外观,绝不是换把武器",effectiveMeleeItem()也特意保留了基础武器的伤害/距离/冷却/技能——唯独真正决定胜负的那个数字,取自一张皮肤 id 根本不在的表。cane、shovel)更离谱,取到了另一把近战的数值。WEAPON_DAMAGE条目,同样只打 25。相位刃标着 90,实际 25(弱了 72%)——那是把 P2W 武器,玩家是花钱买的。建议先修 (B):不用装皮肤就中招,而且坑的是付费玩家。但改数值之前先确认手感——这几把按 25 打了很久了,直接提到 90 是实打实的平衡改动,不只是修 bug。
顺带:
tools/verify-weapons.js应该加一条守卫,强制每个武器 id 都有WEAPON_DAMAGE条目,把 gotcha #4 从"要记得"变成"忘了就构建失败"。#7 提的shared/tables.js是同一件事的彻底版。